Lars Talian commited on
Commit
f549fda
·
unverified ·
1 Parent(s): c83edc7

Add snapshot lineage and parent-based mutation runtime (#49)

Browse files
README.md CHANGED
@@ -12,15 +12,17 @@ A multi-agent cybersecurity gymnasium on [OpenEnv](https://github.com/meta-pytor
12
 
13
  ## How It Works
14
 
15
- A **manifest** declares a family of legal enterprise worlds — topology, services, identities, vulnerability classes, difficulty. A shared **ManagedSnapshotRuntime** inside the shipped OpenEnv server process owns the snapshot pool. It loads admitted snapshots from disk or preloads a deterministic pool from the manifest, renders each admitted snapshot into concrete Docker artifacts under `snapshots/<id>/artifacts`, then optionally refills that pool in the background. `reset()` selects one frozen admitted snapshot. `step()` runs commands inside it.
16
 
17
  ```mermaid
18
  flowchart LR
19
- M[Manifest<br/>topology, services,<br/>bug families, difficulty] --> R[ManagedSnapshotRuntime<br/>shared inside server process]
20
- R --> B[Builder / mutator<br/>deterministic by default,<br/>LiteLLM optional]
21
- B --> V{Validator}
22
- V -->|fail| B
23
- V -->|pass| S[Frozen admitted snapshots]
 
 
24
  S --> E["reset() → step() → obs + reward"]
25
 
26
  style V fill:#ffd93d,color:#333
@@ -64,15 +66,15 @@ uv run pytest tests/ -v --tb=short
64
 
65
  ## Core Components
66
 
67
- **Manifest** — YAML defining the legal world: hosts, zones, services, users, NPCs, data assets, credential policies, monitoring coverage, trust relationships, and which vulnerability classes the Builder may plant. Three example manifests ship (healthcare, fintech, SaaS) at tiers 1-3.
68
 
69
- **ManagedSnapshotRuntime** — Shared singleton created at server startup. Owns the `SnapshotStore`, builder/mutator, validator gate, `SnapshotRenderer`, snapshot preload, optional background refill, and episode-result feedback. This is the hidden orchestrator behind the env; callers still only see `reset()`, `step()`, and `state()`.
70
 
71
- **Builder** — Takes a manifest + curriculum context, outputs a `SnapshotSpec`: topology graph, truth graph (planted vulns + exploit chain), evidence graph (what Blue can find), flags, golden path, NPC traffic, and task briefings. Three implementations: `LLMSnapshotBuilder` (production, via litellm), `TemplateOnlyBuilder` (deterministic shipped default), `FileBuilder` (load from disk).
72
 
73
  The deployed package exposes the standard OpenEnv `reset()`, `step()`, and `state()` contract through `server.app:app`, which is the entrypoint referenced by `openenv.yaml`.
74
 
75
- **Validator** — Admission gate for candidate snapshots. The shipped runtime uses structural checks that operate on the compiled `SnapshotSpec` without requiring live model calls; richer container-backed checks remain available for private/local generation workflows.
76
 
77
  **Environment** — `RangeEnvironment(Environment)` following the OpenEnv contract. `reset()` asks the shared runtime for a frozen admitted snapshot. `step(action)` routes commands to the appropriate container — Red runs on the attacker box, Blue runs on the SIEM. No artificial command allowlists; the container's installed tools are the constraint.
78
 
 
12
 
13
  ## How It Works
14
 
15
+ A **manifest** declares a family of legal enterprise worlds — topology, services, identities, trust relationships, vulnerability classes, and mutation bounds. A shared **ManagedSnapshotRuntime** inside the shipped OpenEnv server process owns the admitted snapshot population. It compiles a root snapshot from the manifest, then derives child snapshots by applying explicit typed mutations to admitted parents. Each candidate child is validated before render/materialization and then stored under `snapshots/<id>/`. `reset()` selects one frozen admitted snapshot. `step()` runs commands inside it.
16
 
17
  ```mermaid
18
  flowchart LR
19
+ M[Manifest<br/>legal family +<br/>mutation envelope] --> B[Base snapshot compiler]
20
+ B --> P[Admitted root snapshot]
21
+ P --> R[ManagedSnapshotRuntime<br/>shared inside server process]
22
+ R --> U[Parent selector +<br/>typed mutator]
23
+ U --> V{Validator<br/>manifest + graph +<br/>runtime checks}
24
+ V -->|fail| U
25
+ V -->|pass| S[Admitted snapshot population]
26
  S --> E["reset() → step() → obs + reward"]
27
 
28
  style V fill:#ffd93d,color:#333
 
66
 
67
  ## Core Components
68
 
69
+ **Manifest** — YAML defining the legal world family and mutation envelope: hosts, zones, services, users, NPCs, data assets, credential policies, monitoring coverage, trust relationships, and which vulnerability classes the runtime may plant or extend. Three example manifests ship (healthcare, fintech, SaaS) at tiers 1-3.
70
 
71
+ **ManagedSnapshotRuntime** — Shared singleton created at server startup. Owns the `SnapshotStore`, base builder, parent-snapshot mutator, validator gate, `SnapshotRenderer`, snapshot preload, optional background refill, and episode-result feedback. This is the hidden orchestrator behind the env; callers still only see `reset()`, `step()`, and `state()`.
72
 
73
+ **Builder / Mutator** — The base builder compiles an initial `SnapshotSpec` from a manifest. The mutator then derives child `SnapshotSpec`s from admitted parents using typed mutation plans plus curriculum context. Each snapshot carries lineage metadata (`snapshot_id`, `parent_snapshot_id`, `root_snapshot_id`, generation depth, mutation summary). Three base builders ship: `LLMSnapshotBuilder` (production, via litellm), `TemplateOnlyBuilder` (deterministic shipped default), `FileBuilder` (load from disk).
74
 
75
  The deployed package exposes the standard OpenEnv `reset()`, `step()`, and `state()` contract through `server.app:app`, which is the entrypoint referenced by `openenv.yaml`.
76
 
77
+ **Validator** — Admission gate for candidate snapshots. The shipped runtime now enforces manifest compliance and graph consistency before structural/task checks. These mechanical checks operate on the compiled `SnapshotSpec` without requiring live model calls; richer container-backed checks remain available for private/local generation workflows.
78
 
79
  **Environment** — `RangeEnvironment(Environment)` following the OpenEnv contract. `reset()` asks the shared runtime for a frozen admitted snapshot. `step(action)` routes commands to the appropriate container — Red runs on the attacker box, Blue runs on the SIEM. No artificial command allowlists; the container's installed tools are the constraint.
80
 
src/open_range/builder/mutator.py CHANGED
@@ -8,12 +8,44 @@ context from failed validations.
8
  from __future__ import annotations
9
 
10
  import logging
 
 
11
  from typing import Any
12
 
13
- from open_range.protocols import BuildContext, SnapshotBuilder, SnapshotSpec
 
 
 
 
 
 
 
 
 
 
14
 
15
  logger = logging.getLogger(__name__)
16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
 
18
  class Mutator:
19
  """Orchestrate vuln mutation across resets.
@@ -38,16 +70,21 @@ class Mutator:
38
  manifest: dict,
39
  context: BuildContext | None = None,
40
  error: dict[str, Any] | None = None,
 
 
41
  ) -> SnapshotSpec:
42
- """Generate a mutated snapshot, avoiding recent vuln classes.
43
 
44
  Args:
45
  manifest: Parsed manifest dict.
46
  context: Optional base context (curriculum stats, etc.).
47
  error: Error feedback from a failed validation attempt.
 
 
48
 
49
  Returns:
50
- A new SnapshotSpec with different vulns from the previous episode.
 
51
  """
52
  if context is None:
53
  context = BuildContext()
@@ -64,7 +101,16 @@ class Mutator:
64
  except (AttributeError, ValueError):
65
  pass # protocol version without error field
66
 
67
- snapshot = await self.builder.build(manifest, context)
 
 
 
 
 
 
 
 
 
68
 
69
  # Update history
70
  new_classes = [v.type for v in snapshot.truth_graph.vulns]
@@ -90,3 +136,529 @@ class Mutator:
90
  def history(self) -> list[str]:
91
  """All vuln classes used so far, in order."""
92
  return list(self._history)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  from __future__ import annotations
9
 
10
  import logging
11
+ import random
12
+ from copy import deepcopy
13
  from typing import Any
14
 
15
+ from open_range.protocols import (
16
+ BuildContext,
17
+ EvidenceItem,
18
+ ExploitStep,
19
+ LineageMetadata,
20
+ MutationOp,
21
+ MutationPlan,
22
+ SnapshotBuilder,
23
+ SnapshotSpec,
24
+ Vulnerability,
25
+ )
26
 
27
  logger = logging.getLogger(__name__)
28
 
29
+ _SUPPORTED_MUTATION_OPS = {
30
+ "add_service",
31
+ "add_user",
32
+ "add_dependency_edge",
33
+ "add_trust_edge",
34
+ "seed_vuln",
35
+ "add_benign_noise",
36
+ }
37
+
38
+ _INJECTION_POINTS = {
39
+ "sqli": "/legacy/search.php?q=",
40
+ "idor": "/api/users/{id}",
41
+ "path_traversal": "/download?file=",
42
+ "command_injection": "/admin/diagnostics?host=",
43
+ "ssrf": "/fetch?url=",
44
+ "weak_creds": "ssh svc_app@host",
45
+ "broken_auth": "/admin/login",
46
+ "xss": "/search?q=",
47
+ }
48
+
49
 
50
  class Mutator:
51
  """Orchestrate vuln mutation across resets.
 
70
  manifest: dict,
71
  context: BuildContext | None = None,
72
  error: dict[str, Any] | None = None,
73
+ parent_snapshot: SnapshotSpec | None = None,
74
+ parent_snapshot_id: str | None = None,
75
  ) -> SnapshotSpec:
76
+ """Generate a root or child snapshot, avoiding recent vuln classes.
77
 
78
  Args:
79
  manifest: Parsed manifest dict.
80
  context: Optional base context (curriculum stats, etc.).
81
  error: Error feedback from a failed validation attempt.
82
+ parent_snapshot: Admitted parent snapshot to mutate forward.
83
+ parent_snapshot_id: Persisted ID for *parent_snapshot*.
84
 
85
  Returns:
86
+ A new SnapshotSpec. Root snapshots are compiled from the manifest;
87
+ child snapshots are mutated from the parent.
88
  """
89
  if context is None:
90
  context = BuildContext()
 
101
  except (AttributeError, ValueError):
102
  pass # protocol version without error field
103
 
104
+ if parent_snapshot is None:
105
+ snapshot = await self.builder.build(manifest, context)
106
+ snapshot = self._hydrate_root_snapshot(snapshot, manifest)
107
+ else:
108
+ snapshot = self._mutate_parent_snapshot(
109
+ manifest=manifest,
110
+ parent_snapshot=parent_snapshot,
111
+ parent_snapshot_id=parent_snapshot_id,
112
+ context=context,
113
+ )
114
 
115
  # Update history
116
  new_classes = [v.type for v in snapshot.truth_graph.vulns]
 
136
  def history(self) -> list[str]:
137
  """All vuln classes used so far, in order."""
138
  return list(self._history)
139
+
140
+ def _hydrate_root_snapshot(
141
+ self,
142
+ snapshot: SnapshotSpec,
143
+ manifest: dict[str, Any],
144
+ ) -> SnapshotSpec:
145
+ root = snapshot.model_copy(deep=True)
146
+ topology = dict(root.topology)
147
+ company = manifest.get("company", {}) if isinstance(manifest.get("company"), dict) else {}
148
+ topology.setdefault("domain", company.get("domain", "acmecorp.local"))
149
+ topology.setdefault("org_name", company.get("name", "AcmeCorp"))
150
+ topology.setdefault("manifest_name", manifest.get("name", ""))
151
+ topology.setdefault("difficulty", deepcopy(manifest.get("difficulty", {})))
152
+ topology.setdefault("host_catalog", _build_host_catalog(manifest))
153
+ topology.setdefault("host_details", {})
154
+ topology.setdefault("dependency_edges", [])
155
+ topology.setdefault("trust_edges", [])
156
+ root.topology = topology
157
+ root.lineage = LineageMetadata(
158
+ manifest_id=str(manifest.get("name", "")),
159
+ generation_depth=0,
160
+ mutation_summary=["compile_base_snapshot"],
161
+ )
162
+ root.mutation_plan = None
163
+ return root
164
+
165
+ def _mutate_parent_snapshot(
166
+ self,
167
+ *,
168
+ manifest: dict[str, Any],
169
+ parent_snapshot: SnapshotSpec,
170
+ parent_snapshot_id: str | None,
171
+ context: BuildContext,
172
+ ) -> SnapshotSpec:
173
+ rng = random.Random(context.seed if context.seed is not None else self._episode_count + 1)
174
+ child = parent_snapshot.model_copy(deep=True)
175
+ child.topology = _ensure_mutable_topology(child.topology, manifest)
176
+
177
+ plan = self._plan_mutations(
178
+ manifest=manifest,
179
+ snapshot=child,
180
+ parent_snapshot_id=parent_snapshot_id,
181
+ context=context,
182
+ rng=rng,
183
+ )
184
+ self._apply_plan(child, plan, manifest, context)
185
+
186
+ lineage = parent_snapshot.lineage.model_copy(deep=True)
187
+ child.lineage = LineageMetadata(
188
+ parent_snapshot_id=parent_snapshot_id or parent_snapshot.lineage.snapshot_id or None,
189
+ root_snapshot_id=lineage.root_snapshot_id or parent_snapshot_id or "",
190
+ manifest_id=lineage.manifest_id or str(manifest.get("name", "")),
191
+ generation_depth=lineage.generation_depth + 1,
192
+ mutation_ids=[op.mutation_id for op in plan.ops],
193
+ mutation_summary=[_mutation_summary(op) for op in plan.ops],
194
+ )
195
+ child.mutation_plan = plan
196
+ return child
197
+
198
+ def _plan_mutations(
199
+ self,
200
+ *,
201
+ manifest: dict[str, Any],
202
+ snapshot: SnapshotSpec,
203
+ parent_snapshot_id: str | None,
204
+ context: BuildContext,
205
+ rng: random.Random,
206
+ ) -> MutationPlan:
207
+ ops: list[MutationOp] = []
208
+ used_ids: set[str] = set()
209
+
210
+ structural_candidates = []
211
+ op = self._candidate_add_service(manifest, snapshot, rng)
212
+ if op is not None:
213
+ structural_candidates.append(op)
214
+ op = self._candidate_add_user(manifest, snapshot, context, rng)
215
+ if op is not None:
216
+ structural_candidates.append(op)
217
+ op = self._candidate_add_dependency_edge(manifest, snapshot, rng)
218
+ if op is not None:
219
+ structural_candidates.append(op)
220
+ op = self._candidate_add_trust_edge(manifest, snapshot, rng)
221
+ if op is not None:
222
+ structural_candidates.append(op)
223
+
224
+ if structural_candidates:
225
+ chosen = rng.choice(structural_candidates)
226
+ ops.append(chosen)
227
+ used_ids.add(chosen.mutation_id)
228
+
229
+ security_candidates = []
230
+ op = self._candidate_seed_vuln(manifest, snapshot, context, rng)
231
+ if op is not None:
232
+ security_candidates.append(op)
233
+ op = self._candidate_add_benign_noise(snapshot, rng)
234
+ if op is not None:
235
+ security_candidates.append(op)
236
+
237
+ if security_candidates:
238
+ chosen = rng.choice(security_candidates)
239
+ if chosen.mutation_id not in used_ids:
240
+ ops.append(chosen)
241
+
242
+ if not ops:
243
+ fallback = self._candidate_add_benign_noise(snapshot, rng)
244
+ if fallback is not None:
245
+ ops.append(fallback)
246
+
247
+ return MutationPlan(
248
+ parent_snapshot_id=parent_snapshot_id,
249
+ ops=ops,
250
+ predicted_complexity_delta=len(ops),
251
+ predicted_chain_delta=sum(1 for op in ops if op.op_type == "seed_vuln"),
252
+ predicted_novelty=round(0.2 * len({op.op_type for op in ops}), 2),
253
+ )
254
+
255
+ def _candidate_add_service(
256
+ self,
257
+ manifest: dict[str, Any],
258
+ snapshot: SnapshotSpec,
259
+ rng: random.Random,
260
+ ) -> MutationOp | None:
261
+ topology = snapshot.topology
262
+ host_catalog = topology.get("host_catalog", {})
263
+ host_details = topology.get("host_details", {})
264
+ candidates: list[tuple[str, str]] = []
265
+ if not isinstance(host_catalog, dict) or not isinstance(host_details, dict):
266
+ return None
267
+ for host, raw_catalog in host_catalog.items():
268
+ if not isinstance(raw_catalog, dict):
269
+ continue
270
+ allowed = raw_catalog.get("services", [])
271
+ detail = host_details.get(host, {})
272
+ current = detail.get("services", []) if isinstance(detail, dict) else []
273
+ if not isinstance(allowed, list) or not isinstance(current, list):
274
+ continue
275
+ for service in allowed:
276
+ if service and service not in current:
277
+ candidates.append((str(host), str(service)))
278
+ if not candidates:
279
+ return None
280
+ host, service = rng.choice(candidates)
281
+ return MutationOp(
282
+ mutation_id=f"mut_add_service_{host}_{service}",
283
+ op_type="add_service",
284
+ target_selector={"host": host},
285
+ params={"service": service},
286
+ expected_effects=[f"service {service} added to {host}"],
287
+ risk_tags=["surface_expansion"],
288
+ )
289
+
290
+ def _candidate_add_user(
291
+ self,
292
+ manifest: dict[str, Any],
293
+ snapshot: SnapshotSpec,
294
+ context: BuildContext,
295
+ rng: random.Random,
296
+ ) -> MutationOp | None:
297
+ existing = _existing_usernames(snapshot)
298
+ candidates = [
299
+ raw for raw in manifest.get("users", [])
300
+ if isinstance(raw, dict) and raw.get("username") not in existing
301
+ ]
302
+ if not candidates:
303
+ return None
304
+ user = deepcopy(rng.choice(candidates))
305
+ username = str(user.get("username", "")).strip()
306
+ if not username:
307
+ return None
308
+ password = _predictable_password(username, context.seed)
309
+ return MutationOp(
310
+ mutation_id=f"mut_add_user_{username}",
311
+ op_type="add_user",
312
+ target_selector={"user": username},
313
+ params={
314
+ "username": username,
315
+ "password": password,
316
+ "hosts": deepcopy(user.get("hosts", [])),
317
+ "groups": [str(user.get("department", "") or "users").lower().replace(" ", "_")],
318
+ "email": str(user.get("email", "")),
319
+ "full_name": str(user.get("full_name", "")),
320
+ "department": str(user.get("department", "")),
321
+ "role": str(user.get("role", "")),
322
+ },
323
+ expected_effects=[f"user {username} added to snapshot accounts"],
324
+ risk_tags=["identity_expansion"],
325
+ )
326
+
327
+ def _candidate_add_dependency_edge(
328
+ self,
329
+ manifest: dict[str, Any],
330
+ snapshot: SnapshotSpec,
331
+ rng: random.Random,
332
+ ) -> MutationOp | None:
333
+ topology = snapshot.topology
334
+ current = {
335
+ (str(edge.get("source", "")), str(edge.get("target", "")))
336
+ for edge in topology.get("dependency_edges", [])
337
+ if isinstance(edge, dict)
338
+ }
339
+ candidates: list[tuple[str, str]] = []
340
+ for raw in manifest.get("topology", {}).get("hosts", []):
341
+ if not isinstance(raw, dict):
342
+ continue
343
+ source = str(raw.get("name", "")).strip()
344
+ raw_targets = raw.get("connects_to", [])
345
+ if not source or not isinstance(raw_targets, list):
346
+ continue
347
+ for target_raw in raw_targets:
348
+ target = str(target_raw).strip()
349
+ if target and (source, target) not in current:
350
+ candidates.append((source, target))
351
+ if not candidates:
352
+ return None
353
+ source, target = rng.choice(candidates)
354
+ return MutationOp(
355
+ mutation_id=f"mut_add_dep_{source}_{target}",
356
+ op_type="add_dependency_edge",
357
+ target_selector={"source": source, "target": target},
358
+ params={},
359
+ expected_effects=[f"dependency edge {source}->{target} added"],
360
+ risk_tags=["topology_expansion"],
361
+ )
362
+
363
+ def _candidate_add_trust_edge(
364
+ self,
365
+ manifest: dict[str, Any],
366
+ snapshot: SnapshotSpec,
367
+ rng: random.Random,
368
+ ) -> MutationOp | None:
369
+ topology = snapshot.topology
370
+ current = {
371
+ (
372
+ str(edge.get("source", "")),
373
+ str(edge.get("target", "")),
374
+ str(edge.get("type", "")),
375
+ )
376
+ for edge in topology.get("trust_edges", [])
377
+ if isinstance(edge, dict)
378
+ }
379
+ candidates: list[dict[str, str]] = []
380
+ for raw in manifest.get("trust_relationships", []):
381
+ if not isinstance(raw, dict):
382
+ continue
383
+ source = str(raw.get("source") or raw.get("from") or "").strip()
384
+ target = str(raw.get("target") or raw.get("to") or "").strip()
385
+ edge_type = str(raw.get("type", "")).strip()
386
+ if source and target and (source, target, edge_type) not in current:
387
+ candidates.append(
388
+ {
389
+ "source": source,
390
+ "target": target,
391
+ "type": edge_type,
392
+ "context": str(raw.get("context") or raw.get("description") or ""),
393
+ }
394
+ )
395
+ if not candidates:
396
+ return None
397
+ choice = rng.choice(candidates)
398
+ return MutationOp(
399
+ mutation_id=f"mut_add_trust_{choice['source']}_{choice['target']}_{choice['type']}",
400
+ op_type="add_trust_edge",
401
+ target_selector={"source": choice["source"], "target": choice["target"]},
402
+ params={"type": choice["type"], "context": choice["context"]},
403
+ expected_effects=[f"trust edge {choice['source']}->{choice['target']} added"],
404
+ risk_tags=["trust_expansion"],
405
+ )
406
+
407
+ def _candidate_seed_vuln(
408
+ self,
409
+ manifest: dict[str, Any],
410
+ snapshot: SnapshotSpec,
411
+ context: BuildContext,
412
+ rng: random.Random,
413
+ ) -> MutationOp | None:
414
+ allowed = [str(v) for v in manifest.get("bug_families", []) if v]
415
+ if not allowed:
416
+ return None
417
+ existing = {v.type for v in snapshot.truth_graph.vulns}
418
+ preferred = [v for v in context.weak_areas if v in allowed and v not in existing]
419
+ remaining = [v for v in allowed if v not in existing]
420
+ choices = preferred or remaining or allowed
421
+ vuln_type = rng.choice(choices)
422
+
423
+ host_catalog = snapshot.topology.get("host_catalog", {})
424
+ host_candidates = list(host_catalog.keys()) if isinstance(host_catalog, dict) else []
425
+ if not host_candidates:
426
+ host_candidates = list(_existing_hosts(snapshot))
427
+ if not host_candidates:
428
+ return None
429
+ host = str(rng.choice(host_candidates))
430
+ service = ""
431
+ if isinstance(host_catalog, dict):
432
+ raw_catalog = host_catalog.get(host, {})
433
+ if isinstance(raw_catalog, dict):
434
+ raw_services = raw_catalog.get("services", [])
435
+ if isinstance(raw_services, list) and raw_services:
436
+ service = str(raw_services[0])
437
+
438
+ return MutationOp(
439
+ mutation_id=f"mut_seed_vuln_{vuln_type}_{host}_{len(snapshot.truth_graph.vulns)+1}",
440
+ op_type="seed_vuln",
441
+ target_selector={"host": host},
442
+ params={"vuln_type": vuln_type, "service": service},
443
+ expected_effects=[f"new {vuln_type} foothold on {host}"],
444
+ risk_tags=["security_condition"],
445
+ )
446
+
447
+ def _candidate_add_benign_noise(
448
+ self,
449
+ snapshot: SnapshotSpec,
450
+ rng: random.Random,
451
+ ) -> MutationOp | None:
452
+ locations = [item.location for item in snapshot.evidence_spec if item.location]
453
+ location = rng.choice(locations) if locations else "siem:background.log"
454
+ return MutationOp(
455
+ mutation_id=f"mut_add_noise_{len(snapshot.evidence_spec)+1}",
456
+ op_type="add_benign_noise",
457
+ target_selector={"location": location},
458
+ params={"location": location},
459
+ expected_effects=[f"benign evidence noise added at {location}"],
460
+ risk_tags=["observability_noise"],
461
+ )
462
+
463
+ def _apply_plan(
464
+ self,
465
+ snapshot: SnapshotSpec,
466
+ plan: MutationPlan,
467
+ manifest: dict[str, Any],
468
+ context: BuildContext,
469
+ ) -> None:
470
+ topology = snapshot.topology
471
+ host_details = topology.setdefault("host_details", {})
472
+ dependency_edges = topology.setdefault("dependency_edges", [])
473
+ trust_edges = topology.setdefault("trust_edges", [])
474
+ users = topology.setdefault("users", [])
475
+
476
+ if not isinstance(host_details, dict):
477
+ host_details = {}
478
+ topology["host_details"] = host_details
479
+ if not isinstance(dependency_edges, list):
480
+ dependency_edges = []
481
+ topology["dependency_edges"] = dependency_edges
482
+ if not isinstance(trust_edges, list):
483
+ trust_edges = []
484
+ topology["trust_edges"] = trust_edges
485
+ if not isinstance(users, list):
486
+ users = []
487
+ topology["users"] = users
488
+
489
+ for op in plan.ops:
490
+ if op.op_type not in _SUPPORTED_MUTATION_OPS:
491
+ raise ValueError(f"Unsupported mutation op {op.op_type!r}")
492
+
493
+ if op.op_type == "add_service":
494
+ host = op.target_selector["host"]
495
+ detail = host_details.setdefault(host, {"services": [], "connects_to": []})
496
+ services = detail.setdefault("services", [])
497
+ service = str(op.params.get("service", "")).strip()
498
+ if service and service not in services:
499
+ services.append(service)
500
+
501
+ elif op.op_type == "add_user":
502
+ users.append(
503
+ {
504
+ "username": str(op.params.get("username", "")),
505
+ "password": str(op.params.get("password", "")),
506
+ "groups": deepcopy(op.params.get("groups", [])),
507
+ "hosts": deepcopy(op.params.get("hosts", [])),
508
+ "email": str(op.params.get("email", "")),
509
+ "full_name": str(op.params.get("full_name", "")),
510
+ "department": str(op.params.get("department", "")),
511
+ "role": str(op.params.get("role", "")),
512
+ }
513
+ )
514
+
515
+ elif op.op_type == "add_dependency_edge":
516
+ dependency_edges.append(
517
+ {
518
+ "source": op.target_selector["source"],
519
+ "target": op.target_selector["target"],
520
+ }
521
+ )
522
+
523
+ elif op.op_type == "add_trust_edge":
524
+ trust_edges.append(
525
+ {
526
+ "source": op.target_selector["source"],
527
+ "target": op.target_selector["target"],
528
+ "type": str(op.params.get("type", "")),
529
+ "context": str(op.params.get("context", "")),
530
+ }
531
+ )
532
+
533
+ elif op.op_type == "seed_vuln":
534
+ vuln_type = str(op.params.get("vuln_type", "")).strip()
535
+ host = op.target_selector["host"]
536
+ service = str(op.params.get("service", "")).strip()
537
+ vuln_id = f"{vuln_type}_{len(snapshot.truth_graph.vulns) + 1}"
538
+ snapshot.truth_graph.vulns.append(
539
+ Vulnerability(
540
+ id=vuln_id,
541
+ type=vuln_type,
542
+ host=host,
543
+ service=service,
544
+ injection_point=_INJECTION_POINTS.get(vuln_type, f"/debug/{vuln_type}"),
545
+ vulnerable_code=f"// mutation-added {vuln_type} surface on {host}",
546
+ root_cause=f"Mutation introduced {vuln_type} on {host}",
547
+ blast_radius=f"Additional foothold on {host}",
548
+ remediation=f"Remove the {vuln_type} issue and review dependent trust paths",
549
+ )
550
+ )
551
+ snapshot.truth_graph.exploit_chain.append(
552
+ ExploitStep(
553
+ vuln_id=vuln_id,
554
+ command=f"probe {host} for {vuln_type}",
555
+ description=f"Use the new {vuln_type} foothold on {host}",
556
+ )
557
+ )
558
+ snapshot.evidence_spec.append(
559
+ EvidenceItem(
560
+ type="log_entry",
561
+ location=f"{host}:app.log",
562
+ pattern=f"Mutation-added {vuln_type} activity on {host}",
563
+ )
564
+ )
565
+
566
+ elif op.op_type == "add_benign_noise":
567
+ location = str(op.params.get("location", "siem:background.log"))
568
+ snapshot.evidence_spec.append(
569
+ EvidenceItem(
570
+ type="log_entry",
571
+ location=location,
572
+ pattern=(
573
+ f"Benign background activity {context.episode_count + len(snapshot.evidence_spec)}"
574
+ ),
575
+ )
576
+ )
577
+
578
+ snapshot.topology = topology
579
+
580
+
581
+ def _build_host_catalog(manifest: dict[str, Any]) -> dict[str, dict[str, Any]]:
582
+ catalog: dict[str, dict[str, Any]] = {}
583
+ for raw in manifest.get("topology", {}).get("hosts", []):
584
+ if not isinstance(raw, dict):
585
+ continue
586
+ name = str(raw.get("name", "")).strip()
587
+ if not name:
588
+ continue
589
+ catalog[name] = {
590
+ "zone": str(raw.get("zone", "")),
591
+ "services": deepcopy(raw.get("services", [])),
592
+ "connects_to": deepcopy(raw.get("connects_to", [])),
593
+ }
594
+ return catalog
595
+
596
+
597
+ def _ensure_mutable_topology(
598
+ topology: dict[str, Any],
599
+ manifest: dict[str, Any],
600
+ ) -> dict[str, Any]:
601
+ updated = dict(topology)
602
+ updated.setdefault("manifest_name", manifest.get("name", ""))
603
+ updated.setdefault("difficulty", deepcopy(manifest.get("difficulty", {})))
604
+ updated.setdefault("host_catalog", _build_host_catalog(manifest))
605
+ updated.setdefault("host_details", {})
606
+ updated.setdefault("dependency_edges", [])
607
+ updated.setdefault("trust_edges", [])
608
+ return updated
609
+
610
+
611
+ def _existing_hosts(snapshot: SnapshotSpec) -> set[str]:
612
+ hosts: set[str] = set()
613
+ for raw in snapshot.topology.get("hosts", []):
614
+ if isinstance(raw, dict):
615
+ name = str(raw.get("name", "")).strip()
616
+ if name:
617
+ hosts.add(name)
618
+ else:
619
+ name = str(raw).strip()
620
+ if name:
621
+ hosts.add(name)
622
+ return hosts
623
+
624
+
625
+ def _existing_usernames(snapshot: SnapshotSpec) -> set[str]:
626
+ usernames: set[str] = set()
627
+ for raw in snapshot.topology.get("users", []):
628
+ if not isinstance(raw, dict):
629
+ continue
630
+ username = str(raw.get("username", "")).strip()
631
+ if username:
632
+ usernames.add(username)
633
+ return usernames
634
+
635
+
636
+ def _predictable_password(username: str, seed: int | None) -> str:
637
+ suffix = 2025 if seed is None else 2025 + (seed % 3)
638
+ base = username.split("@", 1)[0] or "Welcome"
639
+ return f"{base.capitalize()}!{suffix}"
640
+
641
+
642
+ def _mutation_summary(op: MutationOp) -> str:
643
+ if op.op_type == "add_service":
644
+ return f"add service {op.params.get('service', '')} to {op.target_selector.get('host', '')}"
645
+ if op.op_type == "add_user":
646
+ return f"add user {op.params.get('username', '')}"
647
+ if op.op_type == "add_dependency_edge":
648
+ return (
649
+ f"add dependency {op.target_selector.get('source', '')}->"
650
+ f"{op.target_selector.get('target', '')}"
651
+ )
652
+ if op.op_type == "add_trust_edge":
653
+ return (
654
+ f"add trust {op.target_selector.get('source', '')}->"
655
+ f"{op.target_selector.get('target', '')}"
656
+ )
657
+ if op.op_type == "seed_vuln":
658
+ return (
659
+ f"seed {op.params.get('vuln_type', '')} on "
660
+ f"{op.target_selector.get('host', '')}"
661
+ )
662
+ if op.op_type == "add_benign_noise":
663
+ return f"add benign noise at {op.params.get('location', '')}"
664
+ return op.op_type
src/open_range/builder/snapshot_store.py CHANGED
@@ -70,6 +70,10 @@ class SnapshotStore:
70
  "flag_count": len(snapshot.flags),
71
  "npc_count": len(snapshot.npc_personas),
72
  "has_compose": bool(snapshot.compose),
 
 
 
 
73
  "stored_at": time.time(),
74
  }
75
  meta_path = snap_dir / "metadata.json"
 
70
  "flag_count": len(snapshot.flags),
71
  "npc_count": len(snapshot.npc_personas),
72
  "has_compose": bool(snapshot.compose),
73
+ "parent_snapshot_id": snapshot.lineage.parent_snapshot_id,
74
+ "root_snapshot_id": snapshot.lineage.root_snapshot_id,
75
+ "generation_depth": snapshot.lineage.generation_depth,
76
+ "mutation_summary": list(snapshot.lineage.mutation_summary),
77
  "stored_at": time.time(),
78
  }
79
  meta_path = snap_dir / "metadata.json"
src/open_range/protocols.py CHANGED
@@ -46,6 +46,40 @@ class BuildContext(BaseModel):
46
  )
47
 
48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  class Vulnerability(BaseModel):
50
  """Single planted vulnerability in the truth graph."""
51
 
@@ -160,6 +194,8 @@ class SnapshotSpec(BaseModel):
160
  task: TaskSpec = Field(default_factory=TaskSpec)
161
  compose: dict[str, Any] = Field(default_factory=dict) # rendered docker-compose
162
  files: dict[str, str] = Field(default_factory=dict) # path -> content
 
 
163
 
164
 
165
  class Stimulus(BaseModel):
 
46
  )
47
 
48
 
49
+ class MutationOp(BaseModel):
50
+ """Single typed edit applied to derive a child snapshot from a parent."""
51
+
52
+ mutation_id: str
53
+ op_type: str
54
+ target_selector: dict[str, str] = Field(default_factory=dict)
55
+ magnitude: int = 1
56
+ params: dict[str, Any] = Field(default_factory=dict)
57
+ expected_effects: list[str] = Field(default_factory=list)
58
+ risk_tags: list[str] = Field(default_factory=list)
59
+
60
+
61
+ class MutationPlan(BaseModel):
62
+ """Ordered list of mutations used to produce a child snapshot."""
63
+
64
+ parent_snapshot_id: str | None = None
65
+ ops: list[MutationOp] = Field(default_factory=list)
66
+ predicted_complexity_delta: int = 0
67
+ predicted_chain_delta: int = 0
68
+ predicted_novelty: float = 0.0
69
+
70
+
71
+ class LineageMetadata(BaseModel):
72
+ """Lineage and mutation provenance for a stored snapshot."""
73
+
74
+ snapshot_id: str = ""
75
+ parent_snapshot_id: str | None = None
76
+ root_snapshot_id: str = ""
77
+ manifest_id: str = ""
78
+ generation_depth: int = 0
79
+ mutation_ids: list[str] = Field(default_factory=list)
80
+ mutation_summary: list[str] = Field(default_factory=list)
81
+
82
+
83
  class Vulnerability(BaseModel):
84
  """Single planted vulnerability in the truth graph."""
85
 
 
194
  task: TaskSpec = Field(default_factory=TaskSpec)
195
  compose: dict[str, Any] = Field(default_factory=dict) # rendered docker-compose
196
  files: dict[str, str] = Field(default_factory=dict) # path -> content
197
+ lineage: LineageMetadata = Field(default_factory=LineageMetadata)
198
+ mutation_plan: MutationPlan | None = None
199
 
200
 
201
  class Stimulus(BaseModel):
src/open_range/server/runtime.py CHANGED
@@ -32,6 +32,8 @@ from open_range.protocols import (
32
  SnapshotSpec,
33
  )
34
  from open_range.server.models import RangeState
 
 
35
  from open_range.validator.task_feasibility import TaskFeasibilityCheck
36
  from open_range.validator.validator import ValidationResult, ValidatorGate
37
 
@@ -242,11 +244,13 @@ def _default_builder() -> SnapshotBuilder:
242
  )
243
 
244
 
245
- def _default_validator() -> ValidatorGate:
246
  # These checks work directly against the compiled snapshot spec and do not
247
  # require booted containers. They are the safe default for shipped mode.
248
  return ValidatorGate(
249
  [
 
 
250
  StructuralSnapshotCheck(),
251
  TaskFeasibilityCheck(),
252
  ]
@@ -280,7 +284,7 @@ class ManagedSnapshotRuntime:
280
  self.store = SnapshotStore(str(self.store_dir))
281
  self.builder = builder or _default_builder()
282
  self.mutator = Mutator(self.builder)
283
- self.validator = validator or _default_validator()
284
  self.renderer = SnapshotRenderer()
285
  self.curriculum = CurriculumTracker()
286
  self.pool_size = max(1, pool_size)
@@ -452,11 +456,14 @@ class ManagedSnapshotRuntime:
452
  last_error: str | None = None
453
  for attempt in range(1, self.generation_retries + 1):
454
  context = self._build_context()
 
455
  snapshot = _run_coro_sync(
456
  self.mutator.mutate(
457
  self.manifest,
458
  context=context,
459
  error={"message": last_error} if last_error else None,
 
 
460
  )
461
  )
462
  validation = self._validate_snapshot(snapshot)
@@ -516,6 +523,11 @@ class ManagedSnapshotRuntime:
516
  prefix = "snap_" + "_".join(vuln_types[:3]) if vuln_types else "snap_generated"
517
  return f"{prefix}_{int(time.time() * 1000)}"
518
 
 
 
 
 
 
519
  def _snapshot_dir(self, snapshot_id: str) -> Path:
520
  return self.store_dir / snapshot_id
521
 
@@ -532,6 +544,9 @@ class ManagedSnapshotRuntime:
532
  topology = dict(rendered.topology)
533
  topology["snapshot_id"] = snapshot_id
534
  rendered.topology = topology
 
 
 
535
 
536
  snapshot_dir = self._snapshot_dir(snapshot_id)
537
  artifacts_dir = self._artifacts_dir(snapshot_id)
 
32
  SnapshotSpec,
33
  )
34
  from open_range.server.models import RangeState
35
+ from open_range.validator.graph_consistency import GraphConsistencyCheck
36
+ from open_range.validator.manifest_compliance import ManifestComplianceCheck
37
  from open_range.validator.task_feasibility import TaskFeasibilityCheck
38
  from open_range.validator.validator import ValidationResult, ValidatorGate
39
 
 
244
  )
245
 
246
 
247
+ def _default_validator(manifest: dict[str, Any]) -> ValidatorGate:
248
  # These checks work directly against the compiled snapshot spec and do not
249
  # require booted containers. They are the safe default for shipped mode.
250
  return ValidatorGate(
251
  [
252
+ ManifestComplianceCheck(manifest),
253
+ GraphConsistencyCheck(),
254
  StructuralSnapshotCheck(),
255
  TaskFeasibilityCheck(),
256
  ]
 
284
  self.store = SnapshotStore(str(self.store_dir))
285
  self.builder = builder or _default_builder()
286
  self.mutator = Mutator(self.builder)
287
+ self.validator = validator or _default_validator(self.manifest)
288
  self.renderer = SnapshotRenderer()
289
  self.curriculum = CurriculumTracker()
290
  self.pool_size = max(1, pool_size)
 
456
  last_error: str | None = None
457
  for attempt in range(1, self.generation_retries + 1):
458
  context = self._build_context()
459
+ parent_entry = self._select_parent_entry()
460
  snapshot = _run_coro_sync(
461
  self.mutator.mutate(
462
  self.manifest,
463
  context=context,
464
  error={"message": last_error} if last_error else None,
465
+ parent_snapshot=parent_entry.snapshot if parent_entry else None,
466
+ parent_snapshot_id=parent_entry.snapshot_id if parent_entry else None,
467
  )
468
  )
469
  validation = self._validate_snapshot(snapshot)
 
523
  prefix = "snap_" + "_".join(vuln_types[:3]) if vuln_types else "snap_generated"
524
  return f"{prefix}_{int(time.time() * 1000)}"
525
 
526
+ def _select_parent_entry(self):
527
+ if self.snapshot_count() == 0:
528
+ return None
529
+ return _run_coro_sync(self.store.select_entry(strategy=self.selection_strategy))
530
+
531
  def _snapshot_dir(self, snapshot_id: str) -> Path:
532
  return self.store_dir / snapshot_id
533
 
 
544
  topology = dict(rendered.topology)
545
  topology["snapshot_id"] = snapshot_id
546
  rendered.topology = topology
547
+ rendered.lineage.snapshot_id = snapshot_id
548
+ if not rendered.lineage.root_snapshot_id:
549
+ rendered.lineage.root_snapshot_id = snapshot_id
550
 
551
  snapshot_dir = self._snapshot_dir(snapshot_id)
552
  artifacts_dir = self._artifacts_dir(snapshot_id)
src/open_range/validator/graph_consistency.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Graph-level consistency checks for compiled snapshot state."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from open_range.protocols import CheckResult, ContainerSet, SnapshotSpec
6
+ from open_range.validator.graphs import compile_snapshot_graphs
7
+
8
+
9
+ class GraphConsistencyCheck:
10
+ """Verify internal consistency of the canonical graph views."""
11
+
12
+ async def check(self, snapshot: SnapshotSpec, containers: ContainerSet) -> CheckResult:
13
+ compiled = compile_snapshot_graphs(snapshot)
14
+ issues: list[str] = []
15
+
16
+ for source, target in compiled.dependency_edges:
17
+ if source not in compiled.hosts or target not in compiled.hosts:
18
+ issues.append(f"dependency edge '{source}->{target}' references unknown host")
19
+
20
+ for source, target, _edge_type in compiled.trust_edges:
21
+ if source not in compiled.users or target not in compiled.users:
22
+ issues.append(f"trust edge '{source}->{target}' references unknown user")
23
+
24
+ lineage = snapshot.lineage
25
+ if lineage.generation_depth == 0 and lineage.parent_snapshot_id:
26
+ issues.append("root snapshot must not have parent_snapshot_id")
27
+ if lineage.generation_depth > 0 and not lineage.parent_snapshot_id:
28
+ issues.append("child snapshot missing parent_snapshot_id")
29
+ if snapshot.mutation_plan is not None:
30
+ if snapshot.mutation_plan.parent_snapshot_id != lineage.parent_snapshot_id:
31
+ issues.append("mutation plan parent does not match lineage parent")
32
+ for op in snapshot.mutation_plan.ops:
33
+ if op.op_type in {"add_service", "seed_vuln"}:
34
+ host = op.target_selector.get("host", "")
35
+ if host and host not in compiled.hosts:
36
+ issues.append(
37
+ f"mutation '{op.mutation_id}' targets unknown host '{host}'"
38
+ )
39
+ if op.op_type == "add_dependency_edge":
40
+ source = op.target_selector.get("source", "")
41
+ target = op.target_selector.get("target", "")
42
+ if source and source not in compiled.hosts:
43
+ issues.append(
44
+ f"mutation '{op.mutation_id}' source host '{source}' missing"
45
+ )
46
+ if target and target not in compiled.hosts:
47
+ issues.append(
48
+ f"mutation '{op.mutation_id}' target host '{target}' missing"
49
+ )
50
+ if op.op_type == "add_trust_edge":
51
+ source = op.target_selector.get("source", "")
52
+ target = op.target_selector.get("target", "")
53
+ if source and source not in compiled.users:
54
+ issues.append(
55
+ f"mutation '{op.mutation_id}' source user '{source}' missing"
56
+ )
57
+ if target and target not in compiled.users:
58
+ issues.append(
59
+ f"mutation '{op.mutation_id}' target user '{target}' missing"
60
+ )
61
+
62
+ passed = len(issues) == 0
63
+ return CheckResult(
64
+ name="graph_consistency",
65
+ passed=passed,
66
+ details={
67
+ "hosts": len(compiled.hosts),
68
+ "users": len(compiled.users),
69
+ "dependency_edges": len(compiled.dependency_edges),
70
+ "trust_edges": len(compiled.trust_edges),
71
+ },
72
+ error="" if passed else "; ".join(issues),
73
+ )
src/open_range/validator/graphs.py ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Compile SnapshotSpec into lightweight canonical graph views.
2
+
3
+ These helpers intentionally stay small and dependency-free. The validator uses
4
+ them to reason about host membership, dependency edges, trust edges, evidence
5
+ locations, and mutation targets before any live container checks run.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+
12
+ from open_range.protocols import SnapshotSpec
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class CompiledGraphs:
17
+ """Canonical graph-like views derived from a snapshot."""
18
+
19
+ hosts: frozenset[str]
20
+ users: frozenset[str]
21
+ services_by_host: dict[str, frozenset[str]]
22
+ dependency_edges: frozenset[tuple[str, str]]
23
+ trust_edges: frozenset[tuple[str, str, str]]
24
+ vuln_ids: frozenset[str]
25
+ evidence_locations: frozenset[str]
26
+
27
+
28
+ def compile_snapshot_graphs(snapshot: SnapshotSpec) -> CompiledGraphs:
29
+ """Compile a snapshot into canonical graph views."""
30
+
31
+ topology = snapshot.topology or {}
32
+ hosts = _compile_hosts(topology)
33
+ users = _compile_users(topology)
34
+ services_by_host = _compile_services(topology, hosts)
35
+ dependency_edges = _compile_dependency_edges(topology)
36
+ trust_edges = _compile_trust_edges(topology)
37
+ vuln_ids = frozenset(v.id for v in snapshot.truth_graph.vulns if v.id)
38
+ evidence_locations = frozenset(item.location for item in snapshot.evidence_spec if item.location)
39
+
40
+ return CompiledGraphs(
41
+ hosts=hosts,
42
+ users=users,
43
+ services_by_host=services_by_host,
44
+ dependency_edges=dependency_edges,
45
+ trust_edges=trust_edges,
46
+ vuln_ids=vuln_ids,
47
+ evidence_locations=evidence_locations,
48
+ )
49
+
50
+
51
+ def _compile_hosts(topology: dict[str, object]) -> frozenset[str]:
52
+ raw_hosts = topology.get("hosts", [])
53
+ hosts: set[str] = set()
54
+ for raw in raw_hosts if isinstance(raw_hosts, list) else []:
55
+ if isinstance(raw, dict):
56
+ name = str(raw.get("name", "")).strip()
57
+ if name:
58
+ hosts.add(name)
59
+ else:
60
+ name = str(raw).strip()
61
+ if name:
62
+ hosts.add(name)
63
+ return frozenset(hosts)
64
+
65
+
66
+ def _compile_users(topology: dict[str, object]) -> frozenset[str]:
67
+ raw_users = topology.get("users", [])
68
+ users: set[str] = set()
69
+ for raw in raw_users if isinstance(raw_users, list) else []:
70
+ if not isinstance(raw, dict):
71
+ continue
72
+ username = str(raw.get("username", "")).strip()
73
+ if username:
74
+ users.add(username)
75
+ return frozenset(users)
76
+
77
+
78
+ def _compile_services(
79
+ topology: dict[str, object],
80
+ hosts: frozenset[str],
81
+ ) -> dict[str, frozenset[str]]:
82
+ host_details = topology.get("host_details", {})
83
+ compiled: dict[str, frozenset[str]] = {}
84
+ for host in hosts:
85
+ detail = {}
86
+ if isinstance(host_details, dict):
87
+ raw_detail = host_details.get(host, {})
88
+ if isinstance(raw_detail, dict):
89
+ detail = raw_detail
90
+ services = detail.get("services", [])
91
+ if not isinstance(services, list):
92
+ services = []
93
+ compiled[host] = frozenset(str(service) for service in services if service)
94
+ return compiled
95
+
96
+
97
+ def _compile_dependency_edges(topology: dict[str, object]) -> frozenset[tuple[str, str]]:
98
+ raw_edges = topology.get("dependency_edges", [])
99
+ edges: set[tuple[str, str]] = set()
100
+ for raw in raw_edges if isinstance(raw_edges, list) else []:
101
+ if not isinstance(raw, dict):
102
+ continue
103
+ source = str(raw.get("source", "")).strip()
104
+ target = str(raw.get("target", "")).strip()
105
+ if source and target:
106
+ edges.add((source, target))
107
+ return frozenset(edges)
108
+
109
+
110
+ def _compile_trust_edges(topology: dict[str, object]) -> frozenset[tuple[str, str, str]]:
111
+ raw_edges = topology.get("trust_edges", [])
112
+ edges: set[tuple[str, str, str]] = set()
113
+ for raw in raw_edges if isinstance(raw_edges, list) else []:
114
+ if not isinstance(raw, dict):
115
+ continue
116
+ source = str(raw.get("source", "")).strip()
117
+ target = str(raw.get("target", "")).strip()
118
+ edge_type = str(raw.get("type", "")).strip()
119
+ if source and target:
120
+ edges.add((source, target, edge_type))
121
+ return frozenset(edges)
src/open_range/validator/manifest_compliance.py ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Manifest-bounded legality checks for candidate snapshots."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from open_range.protocols import CheckResult, ContainerSet, SnapshotSpec
8
+ from open_range.validator.graphs import compile_snapshot_graphs
9
+
10
+ _SUPPORTED_MUTATION_OPS = {
11
+ "add_service",
12
+ "add_user",
13
+ "add_dependency_edge",
14
+ "add_trust_edge",
15
+ "seed_vuln",
16
+ "add_benign_noise",
17
+ }
18
+
19
+ _SYSTEM_USERS = {"admin", "testuser"}
20
+
21
+
22
+ class ManifestComplianceCheck:
23
+ """Ensure a candidate child stays inside the manifest-defined family."""
24
+
25
+ def __init__(self, manifest: dict[str, Any]) -> None:
26
+ self.manifest = manifest
27
+
28
+ async def check(self, snapshot: SnapshotSpec, containers: ContainerSet) -> CheckResult:
29
+ compiled = compile_snapshot_graphs(snapshot)
30
+ issues: list[str] = []
31
+
32
+ manifest_hosts = _manifest_hosts(self.manifest)
33
+ allowed_bug_families = set(str(v) for v in self.manifest.get("bug_families", []))
34
+ allowed_users = set(_manifest_users(self.manifest))
35
+ allowed_services = _manifest_services(self.manifest)
36
+ allowed_dependency_edges = _manifest_dependency_edges(self.manifest)
37
+ allowed_trust_edges = _manifest_trust_edges(self.manifest)
38
+
39
+ unknown_hosts = compiled.hosts - manifest_hosts
40
+ if unknown_hosts:
41
+ issues.append(f"hosts outside manifest family: {sorted(unknown_hosts)}")
42
+
43
+ illegal_users = {
44
+ user
45
+ for user in compiled.users
46
+ if user not in allowed_users and user not in _SYSTEM_USERS and not user.startswith("svc_")
47
+ }
48
+ if illegal_users:
49
+ issues.append(f"users outside manifest family: {sorted(illegal_users)}")
50
+
51
+ for host, services in compiled.services_by_host.items():
52
+ illegal = services - allowed_services.get(host, frozenset())
53
+ if illegal:
54
+ issues.append(
55
+ f"host '{host}' has services outside manifest family: {sorted(illegal)}"
56
+ )
57
+
58
+ for vuln in snapshot.truth_graph.vulns:
59
+ if vuln.type and allowed_bug_families and vuln.type not in allowed_bug_families:
60
+ issues.append(f"vuln '{vuln.id}' uses disallowed family '{vuln.type}'")
61
+ if vuln.host and vuln.host not in manifest_hosts:
62
+ issues.append(f"vuln '{vuln.id}' references host outside manifest '{vuln.host}'")
63
+
64
+ plan = snapshot.mutation_plan
65
+ if plan is not None:
66
+ for op in plan.ops:
67
+ if op.op_type not in _SUPPORTED_MUTATION_OPS:
68
+ issues.append(f"unsupported mutation op '{op.op_type}'")
69
+ continue
70
+
71
+ if op.op_type == "add_service":
72
+ host = op.target_selector.get("host", "")
73
+ service = str(op.params.get("service", "")).strip()
74
+ if host not in manifest_hosts:
75
+ issues.append(f"add_service targets unknown host '{host}'")
76
+ elif service and service not in allowed_services.get(host, frozenset()):
77
+ issues.append(f"add_service introduces illegal service '{service}' on '{host}'")
78
+
79
+ if op.op_type == "add_user":
80
+ username = str(op.params.get("username", "")).strip()
81
+ if username and username not in allowed_users:
82
+ issues.append(f"add_user introduces unknown manifest user '{username}'")
83
+
84
+ if op.op_type == "add_dependency_edge":
85
+ source = op.target_selector.get("source", "")
86
+ target = op.target_selector.get("target", "")
87
+ if (source, target) not in allowed_dependency_edges:
88
+ issues.append(
89
+ f"add_dependency_edge introduces illegal edge '{source}->{target}'"
90
+ )
91
+
92
+ if op.op_type == "add_trust_edge":
93
+ source = op.target_selector.get("source", "")
94
+ target = op.target_selector.get("target", "")
95
+ edge_type = str(op.params.get("type", "")).strip()
96
+ if (source, target, edge_type) not in allowed_trust_edges:
97
+ issues.append(
98
+ f"add_trust_edge introduces illegal trust edge "
99
+ f"'{source}->{target}' ({edge_type})"
100
+ )
101
+
102
+ if op.op_type == "seed_vuln":
103
+ host = op.target_selector.get("host", "")
104
+ vuln_type = str(op.params.get("vuln_type", "")).strip()
105
+ if host not in manifest_hosts:
106
+ issues.append(f"seed_vuln targets unknown host '{host}'")
107
+ if vuln_type and vuln_type not in allowed_bug_families:
108
+ issues.append(f"seed_vuln uses illegal family '{vuln_type}'")
109
+
110
+ passed = len(issues) == 0
111
+ return CheckResult(
112
+ name="manifest_compliance",
113
+ passed=passed,
114
+ details={
115
+ "issue_count": len(issues),
116
+ "manifest": self.manifest.get("name", ""),
117
+ },
118
+ error="" if passed else "; ".join(issues),
119
+ )
120
+
121
+
122
+ def _manifest_hosts(manifest: dict[str, Any]) -> set[str]:
123
+ hosts: set[str] = set()
124
+ for raw in manifest.get("topology", {}).get("hosts", []):
125
+ if isinstance(raw, dict):
126
+ name = str(raw.get("name", "")).strip()
127
+ if name:
128
+ hosts.add(name)
129
+ return hosts
130
+
131
+
132
+ def _manifest_users(manifest: dict[str, Any]) -> set[str]:
133
+ users: set[str] = set()
134
+ for raw in manifest.get("users", []):
135
+ if isinstance(raw, dict):
136
+ username = str(raw.get("username", "")).strip()
137
+ if username:
138
+ users.add(username)
139
+ return users
140
+
141
+
142
+ def _manifest_services(manifest: dict[str, Any]) -> dict[str, frozenset[str]]:
143
+ services: dict[str, frozenset[str]] = {}
144
+ for raw in manifest.get("topology", {}).get("hosts", []):
145
+ if not isinstance(raw, dict):
146
+ continue
147
+ name = str(raw.get("name", "")).strip()
148
+ if not name:
149
+ continue
150
+ raw_services = raw.get("services", [])
151
+ if not isinstance(raw_services, list):
152
+ raw_services = []
153
+ services[name] = frozenset(str(service) for service in raw_services if service)
154
+ return services
155
+
156
+
157
+ def _manifest_dependency_edges(manifest: dict[str, Any]) -> set[tuple[str, str]]:
158
+ edges: set[tuple[str, str]] = set()
159
+ for raw in manifest.get("topology", {}).get("hosts", []):
160
+ if not isinstance(raw, dict):
161
+ continue
162
+ source = str(raw.get("name", "")).strip()
163
+ raw_targets = raw.get("connects_to", [])
164
+ if not source or not isinstance(raw_targets, list):
165
+ continue
166
+ for raw_target in raw_targets:
167
+ target = str(raw_target).strip()
168
+ if target:
169
+ edges.add((source, target))
170
+ return edges
171
+
172
+
173
+ def _manifest_trust_edges(manifest: dict[str, Any]) -> set[tuple[str, str, str]]:
174
+ edges: set[tuple[str, str, str]] = set()
175
+ for raw in manifest.get("trust_relationships", []):
176
+ if not isinstance(raw, dict):
177
+ continue
178
+ source = str(raw.get("source") or raw.get("from") or "").strip()
179
+ target = str(raw.get("target") or raw.get("to") or "").strip()
180
+ edge_type = str(raw.get("type", "")).strip()
181
+ if source and target:
182
+ edges.add((source, target, edge_type))
183
+ return edges
tests/test_builder.py CHANGED
@@ -103,6 +103,28 @@ async def test_template_builder_has_task_briefings(tier1_manifest):
103
  assert spec.task.blue_briefing != ""
104
 
105
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
  # ---------------------------------------------------------------------------
107
  # FileBuilder
108
  # ---------------------------------------------------------------------------
 
103
  assert spec.task.blue_briefing != ""
104
 
105
 
106
+ @pytest.mark.asyncio
107
+ async def test_mutator_builds_child_snapshot_with_lineage(tier1_manifest):
108
+ from open_range.builder.builder import TemplateOnlyBuilder
109
+ from open_range.builder.mutator import Mutator
110
+
111
+ mutator = Mutator(TemplateOnlyBuilder())
112
+ root = await mutator.mutate(tier1_manifest, context=BuildContext(seed=1, tier=1))
113
+ child = await mutator.mutate(
114
+ tier1_manifest,
115
+ context=BuildContext(seed=2, tier=1),
116
+ parent_snapshot=root,
117
+ parent_snapshot_id="root_snap",
118
+ )
119
+
120
+ assert child.lineage.parent_snapshot_id == "root_snap"
121
+ assert child.lineage.generation_depth == 1
122
+ assert child.mutation_plan is not None
123
+ assert child.mutation_plan.parent_snapshot_id == "root_snap"
124
+ assert child.mutation_plan.ops
125
+ assert child.lineage.mutation_summary
126
+
127
+
128
  # ---------------------------------------------------------------------------
129
  # FileBuilder
130
  # ---------------------------------------------------------------------------
tests/test_runtime.py CHANGED
@@ -80,6 +80,42 @@ class TestManagedSnapshotRuntime:
80
  finally:
81
  runtime.stop()
82
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
 
84
  class TestEnvironmentRuntimeIntegration:
85
  def test_reset_uses_managed_runtime_snapshot(self, tier1_manifest, tmp_path):
 
80
  finally:
81
  runtime.stop()
82
 
83
+ def test_start_records_root_and_child_lineage(self, tier1_manifest, tmp_path):
84
+ runtime = ManagedSnapshotRuntime(
85
+ manifest=tier1_manifest,
86
+ store_dir=tmp_path / "snapshots",
87
+ pool_size=2,
88
+ selection_strategy="latest",
89
+ refill_enabled=False,
90
+ )
91
+
92
+ runtime.start()
93
+ try:
94
+ listing = runtime.list_snapshots()
95
+ assert len(listing) == 2
96
+ depths = {item["generation_depth"] for item in listing}
97
+ assert 0 in depths
98
+ assert 1 in depths
99
+ assert any(item["parent_snapshot_id"] for item in listing)
100
+ finally:
101
+ runtime.stop()
102
+
103
+ def test_acquire_snapshot_exposes_lineage_metadata(self, tier1_manifest, tmp_path):
104
+ runtime = ManagedSnapshotRuntime(
105
+ manifest=tier1_manifest,
106
+ store_dir=tmp_path / "snapshots",
107
+ pool_size=2,
108
+ refill_enabled=False,
109
+ )
110
+
111
+ runtime.start()
112
+ try:
113
+ admitted = runtime.acquire_snapshot()
114
+ assert admitted.snapshot.lineage.snapshot_id == admitted.snapshot_id
115
+ assert admitted.snapshot.lineage.root_snapshot_id
116
+ finally:
117
+ runtime.stop()
118
+
119
 
120
  class TestEnvironmentRuntimeIntegration:
121
  def test_reset_uses_managed_runtime_snapshot(self, tier1_manifest, tmp_path):
tests/test_validator.py CHANGED
@@ -10,6 +10,8 @@ from open_range.protocols import (
10
  EvidenceItem,
11
  FlagSpec,
12
  GoldenPathStep,
 
 
13
  NPCPersona,
14
  SnapshotSpec,
15
  TaskSpec,
@@ -19,6 +21,58 @@ from open_range.protocols import (
19
  from open_range.validator.validator import ValidatorGate, ValidationResult
20
 
21
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  # ---------------------------------------------------------------------------
23
  # Check 1: BuildBoot
24
  # ---------------------------------------------------------------------------
 
10
  EvidenceItem,
11
  FlagSpec,
12
  GoldenPathStep,
13
+ MutationOp,
14
+ MutationPlan,
15
  NPCPersona,
16
  SnapshotSpec,
17
  TaskSpec,
 
21
  from open_range.validator.validator import ValidatorGate, ValidationResult
22
 
23
 
24
+ @pytest.mark.asyncio
25
+ async def test_manifest_compliance_rejects_illegal_mutation_plan(
26
+ tier1_manifest,
27
+ sample_snapshot_spec,
28
+ mock_containers,
29
+ ):
30
+ from open_range.validator.manifest_compliance import ManifestComplianceCheck
31
+
32
+ spec = sample_snapshot_spec.model_copy(deep=True)
33
+ spec.mutation_plan = MutationPlan(
34
+ parent_snapshot_id="root_snap",
35
+ ops=[
36
+ MutationOp(
37
+ mutation_id="illegal1",
38
+ op_type="seed_vuln",
39
+ target_selector={"host": "web"},
40
+ params={"vuln_type": "totally_fake_bug"},
41
+ )
42
+ ],
43
+ )
44
+ spec.lineage.parent_snapshot_id = "root_snap"
45
+ spec.lineage.generation_depth = 1
46
+
47
+ result = await ManifestComplianceCheck(tier1_manifest).check(spec, mock_containers)
48
+ assert result.passed is False
49
+ assert "illegal family" in result.error
50
+
51
+
52
+ @pytest.mark.asyncio
53
+ async def test_graph_consistency_rejects_missing_parent_lineage(sample_snapshot_spec, mock_containers):
54
+ from open_range.validator.graph_consistency import GraphConsistencyCheck
55
+
56
+ spec = sample_snapshot_spec.model_copy(deep=True)
57
+ spec.mutation_plan = MutationPlan(
58
+ parent_snapshot_id="root_snap",
59
+ ops=[
60
+ MutationOp(
61
+ mutation_id="mut1",
62
+ op_type="add_benign_noise",
63
+ target_selector={"location": "siem:noise.log"},
64
+ params={"location": "siem:noise.log"},
65
+ )
66
+ ],
67
+ )
68
+ spec.lineage.generation_depth = 1
69
+ spec.lineage.parent_snapshot_id = None
70
+
71
+ result = await GraphConsistencyCheck().check(spec, mock_containers)
72
+ assert result.passed is False
73
+ assert "missing parent_snapshot_id" in result.error
74
+
75
+
76
  # ---------------------------------------------------------------------------
77
  # Check 1: BuildBoot
78
  # ---------------------------------------------------------------------------