Lars Talian commited on
Commit
0a3cd7a
·
unverified ·
2 Parent(s): 890f5f35b3b677

Merge pull request #52 from open-cybernauts/feat/issue50-canonical-base-graph-policy

Browse files
README.md CHANGED
@@ -20,14 +20,14 @@ A multi-agent cybersecurity gymnasium on [OpenEnv](https://github.com/meta-pytor
20
 
21
  ## How It Works
22
 
23
- 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 structurally validated, rendered into a concrete artifact bundle under `snapshots/<id>/`, and, in managed-generation mode, can also be booted and live-validated before admission. `reset()` selects one frozen admitted snapshot. `step()` runs commands inside it.
24
 
25
  ```mermaid
26
  flowchart LR
27
  M[Manifest<br/>legal family +<br/>mutation envelope] --> B[Base snapshot compiler]
28
  B --> P[Admitted root snapshot]
29
  P --> R[ManagedSnapshotRuntime<br/>shared inside server process]
30
- R --> U[Parent selector +<br/>typed mutator]
31
  U --> V{Validator<br/>manifest + graph +<br/>runtime checks}
32
  V -->|fail| U
33
  V -->|pass| S[Admitted snapshot population]
@@ -85,13 +85,13 @@ uv run pytest tests/ -v --tb=short
85
 
86
  **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.
87
 
88
- **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()`.
89
 
90
- **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) and can emit constrained service/app payloads through `SnapshotSpec.files`. Three base builders ship: `LLMSnapshotBuilder` (production, via litellm), `TemplateOnlyBuilder` (deterministic shipped default), `FileBuilder` (load from disk).
91
 
92
  The deployed package exposes the standard OpenEnv `reset()`, `step()`, and `state()` contract through `server.app:app`, which is the entrypoint referenced by `openenv.yaml`.
93
 
94
- **Validator** — Admission gate for candidate snapshots. The shipped runtime enforces manifest compliance and graph consistency before structural/task checks. When `OPENRANGE_ENABLE_LIVE_ADMISSION=1`, the runtime also boots the rendered child bundle, applies rendered payload files, constructs a real `ContainerSet`, and runs live build/exploit/evidence/reward checks before admission. Public/HF mode can still rely on a prebuilt admitted pool with live admission disabled.
95
 
96
  **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.
97
 
 
20
 
21
  ## How It Works
22
 
23
+ 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 graph-friendly root snapshot from the manifest, normalizing trust-only principals into a canonical principal catalog, then derives child snapshots by applying explicit typed mutations to admitted parents. Parent selection is policy-driven over the admitted population rather than raw latest/random sampling. Each candidate child is validated in layers: manifest compliance, canonical graph checks, structural/task checks, and, in managed-generation mode, booted runtime checks before admission. `reset()` selects one frozen admitted snapshot. `step()` runs commands inside it.
24
 
25
  ```mermaid
26
  flowchart LR
27
  M[Manifest<br/>legal family +<br/>mutation envelope] --> B[Base snapshot compiler]
28
  B --> P[Admitted root snapshot]
29
  P --> R[ManagedSnapshotRuntime<br/>shared inside server process]
30
+ R --> U[Policy-guided parent selector +<br/>typed mutator]
31
  U --> V{Validator<br/>manifest + graph +<br/>runtime checks}
32
  V -->|fail| U
33
  V -->|pass| S[Admitted snapshot population]
 
85
 
86
  **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.
87
 
88
+ **ManagedSnapshotRuntime** — Shared singleton created at server startup. Owns the `SnapshotStore`, base builder, population-aware parent selector, 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()`.
89
 
90
+ **Builder / Mutator** — The base builder compiles an initial `SnapshotSpec` from a manifest. Root hydration then expands that into canonical topology state: host details, dependency edges, trust edges, and a principal catalog that can represent trust-only people without inventing login accounts. The mutator derives child `SnapshotSpec`s from admitted parents using typed mutation plans plus an explicit mutation-policy layer that scores parents and candidate edits with curriculum, replay, novelty, and lineage signals. Each snapshot carries lineage metadata (`snapshot_id`, `parent_snapshot_id`, `root_snapshot_id`, generation depth, mutation summary) and can emit constrained service/app payloads through `SnapshotSpec.files`. Three base builders ship: `LLMSnapshotBuilder` (production, via litellm), `TemplateOnlyBuilder` (deterministic shipped default), `FileBuilder` (load from disk).
91
 
92
  The deployed package exposes the standard OpenEnv `reset()`, `step()`, and `state()` contract through `server.app:app`, which is the entrypoint referenced by `openenv.yaml`.
93
 
94
+ **Validator** — Admission gate for candidate snapshots. The shipped runtime first enforces manifest compliance plus graph-native checks such as graph consistency, path solvability, evidence sufficiency, and reward grounding before structural/task checks. When `OPENRANGE_ENABLE_LIVE_ADMISSION=1`, the runtime also boots the rendered child bundle, applies rendered payload files, constructs a real `ContainerSet`, and runs live build/exploit/evidence/reward checks before admission. Public/HF mode can still rely on a prebuilt admitted pool with live admission disabled.
95
 
96
  **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.
97
 
src/open_range/builder/manifest_graph.py ADDED
@@ -0,0 +1,340 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Manifest-to-topology compilation helpers for root snapshot hydration.
2
+
3
+ These helpers turn a manifest's declared company world into the canonical
4
+ topology fields the mutator, validators, and runtime expect to reason about.
5
+ They intentionally keep "real login users" separate from trust-only narrative
6
+ principals so the trust graph can be compiled without silently creating extra
7
+ accounts in rendered services.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from copy import deepcopy
13
+ from typing import Any
14
+
15
+
16
+ def build_host_catalog(manifest: dict[str, Any]) -> dict[str, dict[str, Any]]:
17
+ """Return the manifest-defined host catalog keyed by host name."""
18
+ catalog: dict[str, dict[str, Any]] = {}
19
+ for raw in manifest.get("topology", {}).get("hosts", []):
20
+ if not isinstance(raw, dict):
21
+ continue
22
+ name = str(raw.get("name", "")).strip()
23
+ if not name:
24
+ continue
25
+ catalog[name] = {
26
+ "zone": str(raw.get("zone", "")),
27
+ "services": deepcopy(raw.get("services", [])),
28
+ "connects_to": deepcopy(raw.get("connects_to", [])),
29
+ "purpose": str(raw.get("purpose", "")),
30
+ "hostname": str(raw.get("hostname", "")),
31
+ "os": str(raw.get("os", "")),
32
+ "exposure": deepcopy(raw.get("exposure", {})),
33
+ }
34
+ return catalog
35
+
36
+
37
+ def build_principal_catalog(
38
+ manifest: dict[str, Any],
39
+ existing: dict[str, Any] | None = None,
40
+ ) -> tuple[dict[str, dict[str, Any]], list[str]]:
41
+ """Return a canonical principal catalog plus normalized trust-only names."""
42
+ catalog: dict[str, dict[str, Any]] = {}
43
+ trust_only: set[str] = set()
44
+
45
+ if isinstance(existing, dict):
46
+ for name, raw in existing.items():
47
+ principal = str(name).strip()
48
+ if not principal or not isinstance(raw, dict):
49
+ continue
50
+ catalog[principal] = deepcopy(raw)
51
+
52
+ for raw in manifest.get("users", []):
53
+ if not isinstance(raw, dict):
54
+ continue
55
+ username = str(raw.get("username", "")).strip()
56
+ if not username:
57
+ continue
58
+ principal = catalog.setdefault(username, {})
59
+ principal.update(
60
+ {
61
+ "username": username,
62
+ "kind": "user",
63
+ "is_login_account": True,
64
+ "hosts": deepcopy(raw.get("hosts", [])),
65
+ "department": str(raw.get("department", "")),
66
+ "role": str(raw.get("role", "")),
67
+ "email": str(raw.get("email", "")),
68
+ "full_name": str(raw.get("full_name", "")),
69
+ }
70
+ )
71
+
72
+ for raw in manifest.get("trust_relationships", []):
73
+ if not isinstance(raw, dict):
74
+ continue
75
+ source = str(raw.get("source") or raw.get("from") or "").strip()
76
+ target = str(raw.get("target") or raw.get("to") or "").strip()
77
+ for principal_name in (source, target):
78
+ if not principal_name:
79
+ continue
80
+ principal = catalog.setdefault(principal_name, {})
81
+ if not principal.get("is_login_account", False):
82
+ trust_only.add(principal_name)
83
+ principal.setdefault("username", principal_name)
84
+ principal.setdefault("kind", "trust_principal")
85
+ principal.setdefault("is_login_account", False)
86
+ principal.setdefault("hosts", [])
87
+ principal.setdefault("department", "")
88
+ principal.setdefault("role", "")
89
+ principal.setdefault("email", "")
90
+ principal.setdefault("full_name", "")
91
+
92
+ return catalog, sorted(trust_only)
93
+
94
+
95
+ def compile_manifest_topology(
96
+ manifest: dict[str, Any],
97
+ topology: dict[str, Any] | None = None,
98
+ ) -> dict[str, Any]:
99
+ """Compile manifest state into graph-friendly topology fields.
100
+
101
+ Existing topology fields are preserved where possible so builder-generated
102
+ details such as passwords or payload-specific knobs survive root hydration.
103
+ """
104
+ compiled = deepcopy(topology) if isinstance(topology, dict) else {}
105
+ company = manifest.get("company", {}) if isinstance(manifest.get("company"), dict) else {}
106
+
107
+ compiled.setdefault("tier", int(manifest.get("tier", compiled.get("tier", 1)) or 1))
108
+ compiled.setdefault("domain", company.get("domain", "acmecorp.local"))
109
+ compiled.setdefault("org_name", company.get("name", "AcmeCorp"))
110
+ compiled.setdefault("manifest_name", manifest.get("name", ""))
111
+ compiled.setdefault("difficulty", deepcopy(manifest.get("difficulty", {})))
112
+ compiled.setdefault(
113
+ "networks",
114
+ deepcopy(manifest.get("topology", {}).get("networks", [])),
115
+ )
116
+ compiled.setdefault(
117
+ "firewall_rules",
118
+ deepcopy(manifest.get("topology", {}).get("firewall_rules", [])),
119
+ )
120
+
121
+ host_catalog = build_host_catalog(manifest)
122
+ compiled["host_catalog"] = host_catalog
123
+ compiled["hosts"] = _merge_hosts(compiled.get("hosts"), host_catalog)
124
+ compiled["zones"] = _merge_zones(compiled.get("zones"), host_catalog)
125
+ compiled["users"] = _merge_users(compiled.get("users"), manifest)
126
+ compiled["host_details"] = _merge_host_details(compiled.get("host_details"), host_catalog)
127
+ compiled["dependency_edges"] = _merge_dependency_edges(
128
+ compiled.get("dependency_edges"),
129
+ host_catalog,
130
+ )
131
+
132
+ principal_catalog, trust_only = build_principal_catalog(
133
+ manifest,
134
+ existing=compiled.get("principal_catalog")
135
+ if isinstance(compiled.get("principal_catalog"), dict)
136
+ else None,
137
+ )
138
+ compiled["principal_catalog"] = principal_catalog
139
+ compiled["trust_edges"] = _merge_trust_edges(compiled.get("trust_edges"), manifest)
140
+ compiled["manifest_normalization"] = {
141
+ "trust_only_principals": trust_only,
142
+ "notes": [
143
+ (
144
+ "Normalized trust principals not present in manifest users into "
145
+ "principal_catalog only"
146
+ )
147
+ ]
148
+ if trust_only
149
+ else [],
150
+ }
151
+ return compiled
152
+
153
+
154
+ def _merge_hosts(
155
+ raw_hosts: object,
156
+ host_catalog: dict[str, dict[str, Any]],
157
+ ) -> list[str]:
158
+ hosts: list[str] = []
159
+ seen: set[str] = set()
160
+ if isinstance(raw_hosts, list):
161
+ for raw in raw_hosts:
162
+ if isinstance(raw, dict):
163
+ name = str(raw.get("name", "")).strip()
164
+ else:
165
+ name = str(raw).strip()
166
+ if not name or name in seen:
167
+ continue
168
+ seen.add(name)
169
+ hosts.append(name)
170
+ for host in host_catalog:
171
+ if host in seen:
172
+ continue
173
+ seen.add(host)
174
+ hosts.append(host)
175
+ return hosts
176
+
177
+
178
+ def _merge_zones(
179
+ raw_zones: object,
180
+ host_catalog: dict[str, dict[str, Any]],
181
+ ) -> dict[str, list[str]]:
182
+ zones: dict[str, list[str]] = {}
183
+ if isinstance(raw_zones, dict):
184
+ for zone, raw_hosts in raw_zones.items():
185
+ zone_name = str(zone).strip()
186
+ if not zone_name:
187
+ continue
188
+ zone_hosts: list[str] = []
189
+ if isinstance(raw_hosts, list):
190
+ for raw_host in raw_hosts:
191
+ host = str(raw_host).strip()
192
+ if host and host not in zone_hosts:
193
+ zone_hosts.append(host)
194
+ zones[zone_name] = zone_hosts
195
+
196
+ for host, raw_catalog in host_catalog.items():
197
+ zone = str(raw_catalog.get("zone", "")).strip() or "default"
198
+ zone_hosts = zones.setdefault(zone, [])
199
+ if host not in zone_hosts:
200
+ zone_hosts.append(host)
201
+ return zones
202
+
203
+
204
+ def _merge_users(raw_users: object, manifest: dict[str, Any]) -> list[dict[str, Any]]:
205
+ existing: dict[str, dict[str, Any]] = {}
206
+ extras: list[dict[str, Any]] = []
207
+ if isinstance(raw_users, list):
208
+ for raw in raw_users:
209
+ if not isinstance(raw, dict):
210
+ continue
211
+ username = str(raw.get("username", "")).strip()
212
+ if not username:
213
+ continue
214
+ existing[username] = deepcopy(raw)
215
+
216
+ merged: list[dict[str, Any]] = []
217
+ seen: set[str] = set()
218
+ for raw in manifest.get("users", []):
219
+ if not isinstance(raw, dict):
220
+ continue
221
+ username = str(raw.get("username", "")).strip()
222
+ if not username:
223
+ continue
224
+ record = existing.pop(username, {})
225
+ record.setdefault("username", username)
226
+ record.setdefault("password", "")
227
+ record.setdefault("groups", [])
228
+ record.setdefault("hosts", deepcopy(raw.get("hosts", [])))
229
+ record.setdefault("email", str(raw.get("email", "")))
230
+ record.setdefault("full_name", str(raw.get("full_name", "")))
231
+ record.setdefault("department", str(raw.get("department", "")))
232
+ record.setdefault("role", str(raw.get("role", "")))
233
+ merged.append(record)
234
+ seen.add(username)
235
+
236
+ for username, record in existing.items():
237
+ if username in seen:
238
+ continue
239
+ extras.append(record)
240
+ merged.extend(extras)
241
+ return merged
242
+
243
+
244
+ def _merge_host_details(
245
+ raw_details: object,
246
+ host_catalog: dict[str, dict[str, Any]],
247
+ ) -> dict[str, dict[str, Any]]:
248
+ host_details: dict[str, dict[str, Any]] = {}
249
+ if isinstance(raw_details, dict):
250
+ for host, raw_detail in raw_details.items():
251
+ host_name = str(host).strip()
252
+ if not host_name or not isinstance(raw_detail, dict):
253
+ continue
254
+ host_details[host_name] = deepcopy(raw_detail)
255
+
256
+ for host, raw_catalog in host_catalog.items():
257
+ detail = host_details.setdefault(host, {})
258
+ detail.setdefault("zone", str(raw_catalog.get("zone", "")))
259
+ detail.setdefault("services", deepcopy(raw_catalog.get("services", [])))
260
+ detail.setdefault("connects_to", deepcopy(raw_catalog.get("connects_to", [])))
261
+ detail.setdefault("purpose", str(raw_catalog.get("purpose", "")))
262
+ detail.setdefault("hostname", str(raw_catalog.get("hostname", "")))
263
+ detail.setdefault("os", str(raw_catalog.get("os", "")))
264
+ detail.setdefault("exposure", deepcopy(raw_catalog.get("exposure", {})))
265
+ return host_details
266
+
267
+
268
+ def _merge_dependency_edges(
269
+ raw_edges: object,
270
+ host_catalog: dict[str, dict[str, Any]],
271
+ ) -> list[dict[str, str]]:
272
+ edges: list[dict[str, str]] = []
273
+ seen: set[tuple[str, str]] = set()
274
+ if isinstance(raw_edges, list):
275
+ for raw in raw_edges:
276
+ if not isinstance(raw, dict):
277
+ continue
278
+ source = str(raw.get("source", "")).strip()
279
+ target = str(raw.get("target", "")).strip()
280
+ if not source or not target or (source, target) in seen:
281
+ continue
282
+ edges.append({"source": source, "target": target})
283
+ seen.add((source, target))
284
+
285
+ for source, raw_catalog in host_catalog.items():
286
+ raw_targets = raw_catalog.get("connects_to", [])
287
+ if not isinstance(raw_targets, list):
288
+ continue
289
+ for raw_target in raw_targets:
290
+ target = str(raw_target).strip()
291
+ if not target or (source, target) in seen:
292
+ continue
293
+ edges.append({"source": source, "target": target})
294
+ seen.add((source, target))
295
+ return edges
296
+
297
+
298
+ def _merge_trust_edges(
299
+ raw_edges: object,
300
+ manifest: dict[str, Any],
301
+ ) -> list[dict[str, str]]:
302
+ edges: list[dict[str, str]] = []
303
+ seen: set[tuple[str, str, str]] = set()
304
+ if isinstance(raw_edges, list):
305
+ for raw in raw_edges:
306
+ if not isinstance(raw, dict):
307
+ continue
308
+ source = str(raw.get("source", "")).strip()
309
+ target = str(raw.get("target", "")).strip()
310
+ edge_type = str(raw.get("type", "")).strip()
311
+ if not source or not target or (source, target, edge_type) in seen:
312
+ continue
313
+ edges.append(
314
+ {
315
+ "source": source,
316
+ "target": target,
317
+ "type": edge_type,
318
+ "context": str(raw.get("context", "")),
319
+ }
320
+ )
321
+ seen.add((source, target, edge_type))
322
+
323
+ for raw in manifest.get("trust_relationships", []):
324
+ if not isinstance(raw, dict):
325
+ continue
326
+ source = str(raw.get("source") or raw.get("from") or "").strip()
327
+ target = str(raw.get("target") or raw.get("to") or "").strip()
328
+ edge_type = str(raw.get("type", "")).strip()
329
+ if not source or not target or (source, target, edge_type) in seen:
330
+ continue
331
+ edges.append(
332
+ {
333
+ "source": source,
334
+ "target": target,
335
+ "type": edge_type,
336
+ "context": str(raw.get("context") or raw.get("description") or ""),
337
+ }
338
+ )
339
+ seen.add((source, target, edge_type))
340
+ return edges
src/open_range/builder/mutation_policy.py ADDED
@@ -0,0 +1,328 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Population-aware parent and mutation selection policy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import random
6
+ from collections import Counter
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+
10
+ from open_range.protocols import BuildContext, MutationOp, SnapshotSpec
11
+ from open_range.validator.graphs import compile_snapshot_graphs
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class ParentPolicyScore:
16
+ snapshot_id: str
17
+ total: float
18
+ components: dict[str, float]
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class MutationChoice:
23
+ op: MutationOp
24
+ total: float
25
+ components: dict[str, float]
26
+
27
+
28
+ class PopulationMutationPolicy:
29
+ """Simple population-guided policy for parent and op selection.
30
+
31
+ This is intentionally heuristic rather than learned. It gives the runtime
32
+ an explicit place to score parents and mutation candidates using curriculum,
33
+ replay, novelty, and lineage signals instead of relying on raw RNG.
34
+ """
35
+
36
+ name = "population_guided_v1"
37
+
38
+ def select_parent(
39
+ self,
40
+ entries: list[Any],
41
+ *,
42
+ context: BuildContext,
43
+ snapshot_stats: dict[str, dict[str, Any]],
44
+ rng: random.Random,
45
+ ) -> tuple[Any, ParentPolicyScore]:
46
+ scores = self.score_parents(
47
+ entries,
48
+ context=context,
49
+ snapshot_stats=snapshot_stats,
50
+ )
51
+ if not scores:
52
+ raise ValueError("No parent candidates available")
53
+ ordered = sorted(scores, key=lambda score: score.total, reverse=True)
54
+ top = ordered[: min(3, len(ordered))]
55
+ weights = [max(score.total, 0.05) for score in top]
56
+ chosen_score = rng.choices(top, weights=weights, k=1)[0]
57
+ chosen_entry = next(
58
+ entry for entry in entries if entry.snapshot_id == chosen_score.snapshot_id
59
+ )
60
+ return chosen_entry, chosen_score
61
+
62
+ def score_parents(
63
+ self,
64
+ entries: list[Any],
65
+ *,
66
+ context: BuildContext,
67
+ snapshot_stats: dict[str, dict[str, Any]],
68
+ ) -> list[ParentPolicyScore]:
69
+ if not entries:
70
+ return []
71
+
72
+ root_counts = Counter(
73
+ entry.snapshot.lineage.root_snapshot_id or entry.snapshot_id
74
+ for entry in entries
75
+ )
76
+ vuln_frequency = Counter()
77
+ for entry in entries:
78
+ vuln_frequency.update(v.type for v in entry.snapshot.truth_graph.vulns if v.type)
79
+
80
+ scores: list[ParentPolicyScore] = []
81
+ for entry in entries:
82
+ snapshot = entry.snapshot
83
+ stat = snapshot_stats.get(entry.snapshot_id, {})
84
+ vuln_types = {v.type for v in snapshot.truth_graph.vulns if v.type}
85
+ compiled = compile_snapshot_graphs(snapshot)
86
+
87
+ plays = float(stat.get("plays", 0))
88
+ red_rate = float(stat.get("red_solve_rate", 0.0))
89
+ blue_rate = float(stat.get("blue_detect_rate", 0.0))
90
+ frontier = (
91
+ 0.4
92
+ if plays == 0
93
+ else (
94
+ self._frontier_score(red_rate)
95
+ + self._frontier_score(blue_rate)
96
+ )
97
+ / 2.0
98
+ )
99
+ replay = 1.0 / (plays + 1.0)
100
+ novelty = 1.0 / (
101
+ 1.0 + sum(vuln_frequency[vuln] for vuln in vuln_types)
102
+ ) if vuln_types else 0.25
103
+ weak_overlap = float(len(vuln_types.intersection(context.weak_areas)))
104
+ root_id = snapshot.lineage.root_snapshot_id or entry.snapshot_id
105
+ lineage_balance = 1.0 / max(root_counts[root_id], 1)
106
+ depth = float(snapshot.lineage.generation_depth)
107
+ depth_balance = 1.0 / (1.0 + max(depth - 3.0, 0.0))
108
+ recency = 1.0 / (1.0 + float(stat.get("plays_recent", 0)))
109
+ complexity = min(
110
+ (
111
+ len(snapshot.truth_graph.vulns) * 0.25
112
+ + len(snapshot.golden_path) * 0.03
113
+ + len(compiled.dependency_edges) * 0.02
114
+ + len(compiled.trust_edges) * 0.02
115
+ ),
116
+ 1.0,
117
+ )
118
+
119
+ components = {
120
+ "frontier": frontier,
121
+ "replay": replay,
122
+ "novelty": novelty,
123
+ "weak_overlap": weak_overlap,
124
+ "lineage_balance": lineage_balance,
125
+ "depth_balance": depth_balance,
126
+ "recency": recency,
127
+ "complexity": complexity,
128
+ }
129
+ total = (
130
+ frontier * 0.28
131
+ + replay * 0.18
132
+ + novelty * 0.16
133
+ + weak_overlap * 0.18
134
+ + lineage_balance * 0.08
135
+ + depth_balance * 0.04
136
+ + recency * 0.04
137
+ + complexity * 0.04
138
+ )
139
+ scores.append(
140
+ ParentPolicyScore(
141
+ snapshot_id=entry.snapshot_id,
142
+ total=round(max(total, 0.05), 4),
143
+ components={key: round(value, 4) for key, value in components.items()},
144
+ )
145
+ )
146
+ return scores
147
+
148
+ def choose_mutations(
149
+ self,
150
+ *,
151
+ structural_candidates: list[MutationOp],
152
+ security_candidates: list[MutationOp],
153
+ snapshot: SnapshotSpec,
154
+ context: BuildContext,
155
+ rng: random.Random,
156
+ ) -> tuple[list[MutationOp], float, dict[str, float]]:
157
+ selected: list[MutationChoice] = []
158
+
159
+ structural = self._select_candidate(
160
+ structural_candidates,
161
+ snapshot=snapshot,
162
+ context=context,
163
+ rng=rng,
164
+ )
165
+ if structural is not None:
166
+ selected.append(structural)
167
+
168
+ security_pool = [
169
+ choice
170
+ for choice in (
171
+ self._select_candidate(
172
+ security_candidates,
173
+ snapshot=snapshot,
174
+ context=context,
175
+ rng=rng,
176
+ ),
177
+ )
178
+ if choice is not None
179
+ ]
180
+ selected.extend(security_pool)
181
+
182
+ if not selected and security_candidates:
183
+ fallback = self._select_candidate(
184
+ security_candidates,
185
+ snapshot=snapshot,
186
+ context=context,
187
+ rng=rng,
188
+ deterministic=True,
189
+ )
190
+ if fallback is not None:
191
+ selected.append(fallback)
192
+
193
+ if not structural and len(security_candidates) > 1:
194
+ ranked = self._rank_candidates(
195
+ security_candidates,
196
+ snapshot=snapshot,
197
+ context=context,
198
+ )
199
+ for choice in ranked:
200
+ if any(choice.op.mutation_id == existing.op.mutation_id for existing in selected):
201
+ continue
202
+ selected.append(choice)
203
+ break
204
+
205
+ ops = [choice.op for choice in selected]
206
+ if not ops:
207
+ return [], 0.0, {}
208
+
209
+ breakdown = {
210
+ "curriculum": round(sum(c.components["curriculum"] for c in selected), 4),
211
+ "novelty": round(sum(c.components["novelty"] for c in selected), 4),
212
+ "structural_gain": round(sum(c.components["structural_gain"] for c in selected), 4),
213
+ "lineage": round(sum(c.components["lineage"] for c in selected), 4),
214
+ }
215
+ total = round(sum(choice.total for choice in selected), 4)
216
+ return ops, total, breakdown
217
+
218
+ def _select_candidate(
219
+ self,
220
+ candidates: list[MutationOp],
221
+ *,
222
+ snapshot: SnapshotSpec,
223
+ context: BuildContext,
224
+ rng: random.Random,
225
+ deterministic: bool = False,
226
+ ) -> MutationChoice | None:
227
+ ranked = self._rank_candidates(
228
+ candidates,
229
+ snapshot=snapshot,
230
+ context=context,
231
+ )
232
+ if not ranked:
233
+ return None
234
+ if deterministic or len(ranked) == 1:
235
+ return ranked[0]
236
+ top = ranked[: min(3, len(ranked))]
237
+ weights = [max(choice.total, 0.05) for choice in top]
238
+ return rng.choices(top, weights=weights, k=1)[0]
239
+
240
+ def _rank_candidates(
241
+ self,
242
+ candidates: list[MutationOp],
243
+ *,
244
+ snapshot: SnapshotSpec,
245
+ context: BuildContext,
246
+ ) -> list[MutationChoice]:
247
+ ranked: list[MutationChoice] = []
248
+ existing_vulns = {v.type for v in snapshot.truth_graph.vulns if v.type}
249
+ for candidate in candidates:
250
+ curriculum = self._curriculum_bonus(candidate, context, existing_vulns)
251
+ novelty = self._novelty_bonus(candidate, context)
252
+ structural_gain = self._structural_gain(candidate)
253
+ lineage = 1.0 / (1.0 + snapshot.lineage.generation_depth)
254
+ components = {
255
+ "curriculum": curriculum,
256
+ "novelty": novelty,
257
+ "structural_gain": structural_gain,
258
+ "lineage": lineage,
259
+ }
260
+ total = (
261
+ curriculum * 0.38
262
+ + novelty * 0.24
263
+ + structural_gain * 0.28
264
+ + lineage * 0.10
265
+ )
266
+ ranked.append(
267
+ MutationChoice(
268
+ op=candidate,
269
+ total=round(max(total, 0.05), 4),
270
+ components={key: round(value, 4) for key, value in components.items()},
271
+ )
272
+ )
273
+ ranked.sort(key=lambda choice: choice.total, reverse=True)
274
+ return ranked
275
+
276
+ @staticmethod
277
+ def _frontier_score(rate: float) -> float:
278
+ return max(0.0, 1.0 - abs(rate - 0.5) * 2.0)
279
+
280
+ @staticmethod
281
+ def _structural_gain(op: MutationOp) -> float:
282
+ mapping = {
283
+ "add_service": 1.0,
284
+ "add_dependency_edge": 0.9,
285
+ "add_trust_edge": 0.85,
286
+ "add_user": 0.8,
287
+ "seed_vuln": 0.7,
288
+ "add_benign_noise": 0.3,
289
+ }
290
+ return mapping.get(op.op_type, 0.2) * max(op.magnitude, 1)
291
+
292
+ @staticmethod
293
+ def _novelty_bonus(op: MutationOp, context: BuildContext) -> float:
294
+ bonus = 0.4
295
+ if op.op_type == "seed_vuln":
296
+ vuln_type = str(op.params.get("vuln_type", "")).strip()
297
+ if vuln_type and vuln_type not in context.previous_vuln_classes:
298
+ bonus += 1.0
299
+ if op.op_type == "add_benign_noise":
300
+ location = str(op.params.get("location", "")).strip()
301
+ if location and location not in context.recent_attack_surfaces:
302
+ bonus += 0.5
303
+ if op.op_type not in {"seed_vuln", "add_benign_noise"}:
304
+ bonus += 0.4
305
+ return bonus
306
+
307
+ @staticmethod
308
+ def _curriculum_bonus(
309
+ op: MutationOp,
310
+ context: BuildContext,
311
+ existing_vulns: set[str],
312
+ ) -> float:
313
+ bonus = 0.35
314
+ if op.op_type == "seed_vuln":
315
+ vuln_type = str(op.params.get("vuln_type", "")).strip()
316
+ if vuln_type in context.weak_areas:
317
+ bonus += 1.5
318
+ if vuln_type and vuln_type not in existing_vulns:
319
+ bonus += 0.4
320
+ if op.op_type in {"add_dependency_edge", "add_trust_edge"} and context.require_chain_length > 1:
321
+ bonus += 0.6
322
+ if context.focus_layer == "identity" and op.op_type in {"add_user", "add_trust_edge"}:
323
+ bonus += 0.5
324
+ if context.focus_layer == "infra" and op.op_type in {"add_service", "add_dependency_edge"}:
325
+ bonus += 0.5
326
+ if context.focus_layer == "process" and op.op_type == "add_benign_noise":
327
+ bonus += 0.4
328
+ return bonus
src/open_range/builder/mutator.py CHANGED
@@ -14,6 +14,8 @@ from copy import deepcopy
14
  from typing import Any
15
 
16
  from open_range.builder.builder import render_template_payloads
 
 
17
  from open_range.protocols import (
18
  BuildContext,
19
  EvidenceItem,
@@ -49,6 +51,7 @@ class Mutator:
49
  self,
50
  builder: SnapshotBuilder,
51
  max_retries: int = 3,
 
52
  ) -> None:
53
  """Initialize the mutator with a builder and retry limit.
54
 
@@ -58,6 +61,7 @@ class Mutator:
58
  """
59
  self.builder = builder
60
  self.max_retries = max_retries
 
61
  self._history: list[str] = [] # recent vuln classes
62
  self._attack_surfaces: list[str] = [] # recent injection points
63
  self._episode_count: int = 0
@@ -230,23 +234,19 @@ class Mutator:
230
  manifest: dict[str, Any],
231
  ) -> SnapshotSpec:
232
  root = snapshot.model_copy(deep=True)
233
- topology = dict(root.topology)
234
- company = manifest.get("company", {}) if isinstance(manifest.get("company"), dict) else {}
235
- topology.setdefault("domain", company.get("domain", "acmecorp.local"))
236
- topology.setdefault("org_name", company.get("name", "AcmeCorp"))
237
- topology.setdefault("manifest_name", manifest.get("name", ""))
238
- topology.setdefault("difficulty", deepcopy(manifest.get("difficulty", {})))
239
- topology.setdefault("host_catalog", _build_host_catalog(manifest))
240
- topology.setdefault("host_details", {})
241
- topology.setdefault("dependency_edges", [])
242
- topology.setdefault("trust_edges", [])
243
- root.topology = topology
244
  root.lineage = LineageMetadata(
245
  manifest_id=str(manifest.get("name", "")),
246
  generation_depth=0,
247
  mutation_summary=["compile_base_snapshot"],
248
  )
249
  root.mutation_plan = None
 
 
 
 
 
 
250
  return root
251
 
252
  def _mutate_parent_snapshot(
@@ -293,7 +293,6 @@ class Mutator:
293
  rng: random.Random,
294
  ) -> MutationPlan:
295
  ops: list[MutationOp] = []
296
- used_ids: set[str] = set()
297
 
298
  structural_candidates = []
299
  op = self._candidate_add_service(manifest, snapshot, rng)
@@ -309,11 +308,6 @@ class Mutator:
309
  if op is not None:
310
  structural_candidates.append(op)
311
 
312
- if structural_candidates:
313
- chosen = rng.choice(structural_candidates)
314
- ops.append(chosen)
315
- used_ids.add(chosen.mutation_id)
316
-
317
  security_candidates = []
318
  op = self._candidate_seed_vuln(manifest, snapshot, context, rng)
319
  if op is not None:
@@ -322,10 +316,13 @@ class Mutator:
322
  if op is not None:
323
  security_candidates.append(op)
324
 
325
- if security_candidates:
326
- chosen = rng.choice(security_candidates)
327
- if chosen.mutation_id not in used_ids:
328
- ops.append(chosen)
 
 
 
329
 
330
  if not ops:
331
  fallback = self._candidate_add_benign_noise(snapshot, rng)
@@ -338,6 +335,9 @@ class Mutator:
338
  predicted_complexity_delta=len(ops),
339
  predicted_chain_delta=sum(1 for op in ops if op.op_type == "seed_vuln"),
340
  predicted_novelty=round(0.2 * len({op.op_type for op in ops}), 2),
 
 
 
341
  )
342
 
343
  def _candidate_add_service(
@@ -559,6 +559,7 @@ class Mutator:
559
  host_details = topology.setdefault("host_details", {})
560
  dependency_edges = topology.setdefault("dependency_edges", [])
561
  trust_edges = topology.setdefault("trust_edges", [])
 
562
  users = topology.setdefault("users", [])
563
 
564
  if not isinstance(host_details, dict):
@@ -570,6 +571,9 @@ class Mutator:
570
  if not isinstance(trust_edges, list):
571
  trust_edges = []
572
  topology["trust_edges"] = trust_edges
 
 
 
573
  if not isinstance(users, list):
574
  users = []
575
  topology["users"] = users
@@ -587,18 +591,28 @@ class Mutator:
587
  services.append(service)
588
 
589
  elif op.op_type == "add_user":
590
- users.append(
591
- {
592
- "username": str(op.params.get("username", "")),
593
- "password": str(op.params.get("password", "")),
594
- "groups": deepcopy(op.params.get("groups", [])),
595
- "hosts": deepcopy(op.params.get("hosts", [])),
596
- "email": str(op.params.get("email", "")),
597
- "full_name": str(op.params.get("full_name", "")),
598
- "department": str(op.params.get("department", "")),
599
- "role": str(op.params.get("role", "")),
600
- }
601
- )
 
 
 
 
 
 
 
 
 
 
602
 
603
  elif op.op_type == "add_dependency_edge":
604
  dependency_edges.append(
@@ -666,34 +680,11 @@ class Mutator:
666
  snapshot.topology = topology
667
 
668
 
669
- def _build_host_catalog(manifest: dict[str, Any]) -> dict[str, dict[str, Any]]:
670
- catalog: dict[str, dict[str, Any]] = {}
671
- for raw in manifest.get("topology", {}).get("hosts", []):
672
- if not isinstance(raw, dict):
673
- continue
674
- name = str(raw.get("name", "")).strip()
675
- if not name:
676
- continue
677
- catalog[name] = {
678
- "zone": str(raw.get("zone", "")),
679
- "services": deepcopy(raw.get("services", [])),
680
- "connects_to": deepcopy(raw.get("connects_to", [])),
681
- }
682
- return catalog
683
-
684
-
685
  def _ensure_mutable_topology(
686
  topology: dict[str, Any],
687
  manifest: dict[str, Any],
688
  ) -> dict[str, Any]:
689
- updated = dict(topology)
690
- updated.setdefault("manifest_name", manifest.get("name", ""))
691
- updated.setdefault("difficulty", deepcopy(manifest.get("difficulty", {})))
692
- updated.setdefault("host_catalog", _build_host_catalog(manifest))
693
- updated.setdefault("host_details", {})
694
- updated.setdefault("dependency_edges", [])
695
- updated.setdefault("trust_edges", [])
696
- return updated
697
 
698
 
699
  def _existing_hosts(snapshot: SnapshotSpec) -> set[str]:
 
14
  from typing import Any
15
 
16
  from open_range.builder.builder import render_template_payloads
17
+ from open_range.builder.manifest_graph import compile_manifest_topology
18
+ from open_range.builder.mutation_policy import PopulationMutationPolicy
19
  from open_range.protocols import (
20
  BuildContext,
21
  EvidenceItem,
 
51
  self,
52
  builder: SnapshotBuilder,
53
  max_retries: int = 3,
54
+ policy: PopulationMutationPolicy | None = None,
55
  ) -> None:
56
  """Initialize the mutator with a builder and retry limit.
57
 
 
61
  """
62
  self.builder = builder
63
  self.max_retries = max_retries
64
+ self.policy = policy or PopulationMutationPolicy()
65
  self._history: list[str] = [] # recent vuln classes
66
  self._attack_surfaces: list[str] = [] # recent injection points
67
  self._episode_count: int = 0
 
234
  manifest: dict[str, Any],
235
  ) -> SnapshotSpec:
236
  root = snapshot.model_copy(deep=True)
237
+ root.topology = compile_manifest_topology(manifest, root.topology)
 
 
 
 
 
 
 
 
 
 
238
  root.lineage = LineageMetadata(
239
  manifest_id=str(manifest.get("name", "")),
240
  generation_depth=0,
241
  mutation_summary=["compile_base_snapshot"],
242
  )
243
  root.mutation_plan = None
244
+ normalization = root.topology.get("manifest_normalization", {})
245
+ if isinstance(normalization, dict):
246
+ notes = normalization.get("notes", [])
247
+ if isinstance(notes, list):
248
+ for note in notes:
249
+ logger.info("Mutator: manifest normalization applied: %s", note)
250
  return root
251
 
252
  def _mutate_parent_snapshot(
 
293
  rng: random.Random,
294
  ) -> MutationPlan:
295
  ops: list[MutationOp] = []
 
296
 
297
  structural_candidates = []
298
  op = self._candidate_add_service(manifest, snapshot, rng)
 
308
  if op is not None:
309
  structural_candidates.append(op)
310
 
 
 
 
 
 
311
  security_candidates = []
312
  op = self._candidate_seed_vuln(manifest, snapshot, context, rng)
313
  if op is not None:
 
316
  if op is not None:
317
  security_candidates.append(op)
318
 
319
+ ops, policy_score, score_breakdown = self.policy.choose_mutations(
320
+ structural_candidates=structural_candidates,
321
+ security_candidates=security_candidates,
322
+ snapshot=snapshot,
323
+ context=context,
324
+ rng=rng,
325
+ )
326
 
327
  if not ops:
328
  fallback = self._candidate_add_benign_noise(snapshot, rng)
 
335
  predicted_complexity_delta=len(ops),
336
  predicted_chain_delta=sum(1 for op in ops if op.op_type == "seed_vuln"),
337
  predicted_novelty=round(0.2 * len({op.op_type for op in ops}), 2),
338
+ policy_name=self.policy.name,
339
+ policy_score=policy_score,
340
+ score_breakdown=score_breakdown,
341
  )
342
 
343
  def _candidate_add_service(
 
559
  host_details = topology.setdefault("host_details", {})
560
  dependency_edges = topology.setdefault("dependency_edges", [])
561
  trust_edges = topology.setdefault("trust_edges", [])
562
+ principal_catalog = topology.setdefault("principal_catalog", {})
563
  users = topology.setdefault("users", [])
564
 
565
  if not isinstance(host_details, dict):
 
571
  if not isinstance(trust_edges, list):
572
  trust_edges = []
573
  topology["trust_edges"] = trust_edges
574
+ if not isinstance(principal_catalog, dict):
575
+ principal_catalog = {}
576
+ topology["principal_catalog"] = principal_catalog
577
  if not isinstance(users, list):
578
  users = []
579
  topology["users"] = users
 
591
  services.append(service)
592
 
593
  elif op.op_type == "add_user":
594
+ username = str(op.params.get("username", ""))
595
+ user_record = {
596
+ "username": username,
597
+ "password": str(op.params.get("password", "")),
598
+ "groups": deepcopy(op.params.get("groups", [])),
599
+ "hosts": deepcopy(op.params.get("hosts", [])),
600
+ "email": str(op.params.get("email", "")),
601
+ "full_name": str(op.params.get("full_name", "")),
602
+ "department": str(op.params.get("department", "")),
603
+ "role": str(op.params.get("role", "")),
604
+ }
605
+ users.append(user_record)
606
+ principal_catalog[username] = {
607
+ "username": username,
608
+ "kind": "user",
609
+ "is_login_account": True,
610
+ "hosts": deepcopy(op.params.get("hosts", [])),
611
+ "department": str(op.params.get("department", "")),
612
+ "role": str(op.params.get("role", "")),
613
+ "email": str(op.params.get("email", "")),
614
+ "full_name": str(op.params.get("full_name", "")),
615
+ }
616
 
617
  elif op.op_type == "add_dependency_edge":
618
  dependency_edges.append(
 
680
  snapshot.topology = topology
681
 
682
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
683
  def _ensure_mutable_topology(
684
  topology: dict[str, Any],
685
  manifest: dict[str, Any],
686
  ) -> dict[str, Any]:
687
+ return compile_manifest_topology(manifest, topology)
 
 
 
 
 
 
 
688
 
689
 
690
  def _existing_hosts(snapshot: SnapshotSpec) -> set[str]:
src/open_range/builder/snapshot_store.py CHANGED
@@ -119,6 +119,19 @@ class SnapshotStore:
119
  snapshot=SnapshotSpec.model_validate(raw),
120
  )
121
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  async def list_snapshots(self) -> list[dict[str, Any]]:
123
  """List all snapshots with their metadata.
124
 
 
119
  snapshot=SnapshotSpec.model_validate(raw),
120
  )
121
 
122
+ async def list_entries(self) -> list[StoredSnapshot]:
123
+ """Return every stored snapshot plus its persisted ID."""
124
+ entries: list[StoredSnapshot] = []
125
+ for spec_path in sorted(self.store_dir.glob("*/spec.json")):
126
+ raw = json.loads(spec_path.read_text(encoding="utf-8"))
127
+ entries.append(
128
+ StoredSnapshot(
129
+ snapshot_id=spec_path.parent.name,
130
+ snapshot=SnapshotSpec.model_validate(raw),
131
+ )
132
+ )
133
+ return entries
134
+
135
  async def list_snapshots(self) -> list[dict[str, Any]]:
136
  """List all snapshots with their metadata.
137
 
src/open_range/lint.py CHANGED
@@ -12,6 +12,7 @@ Usage::
12
  from __future__ import annotations
13
 
14
  import argparse
 
15
  import sys
16
  from pathlib import Path
17
  from typing import Any
@@ -134,22 +135,28 @@ def _check_business_process_flows(manifest: Manifest) -> list[str]:
134
  return errors
135
 
136
 
 
 
 
137
  def _check_trust_relationships(manifest: Manifest) -> list[str]:
138
- """All trust relationships must reference valid usernames."""
139
- user_names = {u.username for u in manifest.users}
 
 
 
 
 
140
  errors: list[str] = []
141
  for rel in manifest.trust_relationships:
142
- if rel.source and rel.source not in user_names:
143
  errors.append(
144
- f"Trust relationship source '{rel.source}' "
145
- f"is not in the users list. "
146
- f"Valid usernames: {sorted(user_names)}"
147
  )
148
- if rel.target and rel.target not in user_names:
149
  errors.append(
150
- f"Trust relationship target '{rel.target}' "
151
- f"is not in the users list. "
152
- f"Valid usernames: {sorted(user_names)}"
153
  )
154
  return errors
155
 
@@ -165,7 +172,7 @@ ALL_CHECKS = [
165
  ("NPC persona usernames", _check_npc_usernames),
166
  ("data inventory hosts", _check_data_inventory_hosts),
167
  ("business process data flows", _check_business_process_flows),
168
- ("trust relationship usernames", _check_trust_relationships),
169
  ]
170
 
171
 
 
12
  from __future__ import annotations
13
 
14
  import argparse
15
+ import re
16
  import sys
17
  from pathlib import Path
18
  from typing import Any
 
135
  return errors
136
 
137
 
138
+ _PRINCIPAL_RE = re.compile(r"^[A-Za-z0-9._@-]+$")
139
+
140
+
141
  def _check_trust_relationships(manifest: Manifest) -> list[str]:
142
+ """Trust principals must be well-formed identifiers.
143
+
144
+ Trust edges may reference people who are not login accounts. Those are
145
+ normalized into the canonical principal catalog at build time, so lint
146
+ should validate identifier quality rather than requiring every principal to
147
+ appear in ``users``.
148
+ """
149
  errors: list[str] = []
150
  for rel in manifest.trust_relationships:
151
+ if rel.source and not _PRINCIPAL_RE.match(rel.source):
152
  errors.append(
153
+ f"Trust relationship source '{rel.source}' is not a valid "
154
+ "principal identifier"
 
155
  )
156
+ if rel.target and not _PRINCIPAL_RE.match(rel.target):
157
  errors.append(
158
+ f"Trust relationship target '{rel.target}' is not a valid "
159
+ "principal identifier"
 
160
  )
161
  return errors
162
 
 
172
  ("NPC persona usernames", _check_npc_usernames),
173
  ("data inventory hosts", _check_data_inventory_hosts),
174
  ("business process data flows", _check_business_process_flows),
175
+ ("trust relationship principals", _check_trust_relationships),
176
  ]
177
 
178
 
src/open_range/protocols.py CHANGED
@@ -66,6 +66,9 @@ class MutationPlan(BaseModel):
66
  predicted_complexity_delta: int = 0
67
  predicted_chain_delta: int = 0
68
  predicted_novelty: float = 0.0
 
 
 
69
 
70
 
71
  class LineageMetadata(BaseModel):
 
66
  predicted_complexity_delta: int = 0
67
  predicted_chain_delta: int = 0
68
  predicted_novelty: float = 0.0
69
+ policy_name: str = ""
70
+ policy_score: float = 0.0
71
+ score_breakdown: dict[str, float] = Field(default_factory=dict)
72
 
73
 
74
  class LineageMetadata(BaseModel):
src/open_range/server/runtime.py CHANGED
@@ -11,6 +11,7 @@ import asyncio
11
  import json
12
  import logging
13
  import os
 
14
  import shlex
15
  import shutil
16
  import subprocess as sp
@@ -25,6 +26,7 @@ from typing import Any
25
  import yaml
26
 
27
  from open_range.builder.builder import LLMSnapshotBuilder, TemplateOnlyBuilder
 
28
  from open_range.builder.mutator import Mutator
29
  from open_range.builder.renderer import PAYLOAD_MANIFEST_NAME, SnapshotRenderer
30
  from open_range.builder.snapshot_store import SnapshotStore
@@ -41,9 +43,14 @@ from open_range.validator.build_boot import BuildBootCheck
41
  from open_range.validator.difficulty import DifficultyCheck
42
  from open_range.validator.evidence import EvidenceCheck
43
  from open_range.validator.exploitability import ExploitabilityCheck
 
 
 
44
  from open_range.validator.isolation import IsolationCheck
 
45
  from open_range.validator.npc_consistency import NPCConsistencyCheck
46
  from open_range.validator.patchability import PatchabilityCheck
 
47
  from open_range.validator.realism_review import RealismReviewCheck
48
  from open_range.validator.reward_grounding import RewardGroundingCheck
49
  from open_range.validator.task_feasibility import TaskFeasibilityCheck
@@ -207,6 +214,43 @@ class CurriculumTracker:
207
  with self._lock:
208
  return list(self._history)
209
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
210
 
211
  @dataclass(frozen=True, slots=True)
212
  class RuntimeSnapshot:
@@ -274,25 +318,38 @@ def _normalize_validator_profile(profile: str | None) -> str:
274
  return normalized
275
 
276
 
277
- def _build_validator(profile: str) -> ValidatorGate:
 
 
 
 
 
 
 
 
 
 
278
  normalized = _normalize_validator_profile(profile)
279
  if normalized == "offline":
280
  return ValidatorGate(
281
- [
 
282
  StructuralSnapshotCheck(),
283
  TaskFeasibilityCheck(),
284
  ]
285
  )
286
 
287
  return ValidatorGate(
288
- [
 
 
 
289
  BuildBootCheck(),
290
  ExploitabilityCheck(),
291
  PatchabilityCheck(),
292
  EvidenceCheck(),
293
  RewardGroundingCheck(),
294
  IsolationCheck(),
295
- TaskFeasibilityCheck(),
296
  DifficultyCheck(),
297
  NPCConsistencyCheck(),
298
  RealismReviewCheck(),
@@ -326,6 +383,7 @@ class ManagedSnapshotRuntime:
326
  validator_profile: str | None = None,
327
  pool_size: int = 3,
328
  selection_strategy: str = "random",
 
329
  refill_enabled: bool = False,
330
  refill_interval_s: float = 2.0,
331
  generation_retries: int = 3,
@@ -334,6 +392,7 @@ class ManagedSnapshotRuntime:
334
  compose_runner: ComposeProjectRunner | None = None,
335
  live_validator: ValidatorGate | None = None,
336
  enable_patch_validation: bool = False,
 
337
  ) -> None:
338
  self.manifest_path = (
339
  Path(manifest_path).resolve()
@@ -344,15 +403,17 @@ class ManagedSnapshotRuntime:
344
  self.store_dir = _resolve_store_dir(store_dir)
345
  self.store = SnapshotStore(str(self.store_dir))
346
  self.builder = builder or _default_builder()
347
- self.mutator = Mutator(self.builder)
 
348
  self.validator_profile = _normalize_validator_profile(
349
  validator_profile or os.getenv("OPENRANGE_RUNTIME_VALIDATOR_PROFILE", "offline")
350
  )
351
- self.validator = validator or _build_validator(self.validator_profile)
352
  self.renderer = SnapshotRenderer()
353
  self.curriculum = CurriculumTracker()
354
  self.pool_size = max(1, pool_size)
355
  self.selection_strategy = selection_strategy
 
356
  self.refill_enabled = refill_enabled
357
  self.refill_interval_s = max(0.25, refill_interval_s)
358
  self.generation_retries = max(1, generation_retries)
@@ -381,6 +442,7 @@ class ManagedSnapshotRuntime:
381
  validator_profile=os.getenv("OPENRANGE_RUNTIME_VALIDATOR_PROFILE", "offline"),
382
  pool_size=_env_int("OPENRANGE_SNAPSHOT_POOL_SIZE", 3),
383
  selection_strategy=os.getenv("OPENRANGE_SNAPSHOT_SELECTION", "random"),
 
384
  refill_enabled=_env_flag("OPENRANGE_ENABLE_MANAGED_REFILL", default=False),
385
  refill_interval_s=float(os.getenv("OPENRANGE_REFILL_INTERVAL_S", "2.0")),
386
  generation_retries=_env_int("OPENRANGE_GENERATION_RETRIES", 3),
@@ -541,6 +603,7 @@ class ManagedSnapshotRuntime:
541
  "store_dir": str(self.store_dir),
542
  "pool_size": self.pool_size,
543
  "selection_strategy": self.selection_strategy,
 
544
  "validator_profile": self.validator_profile,
545
  "refill_enabled": self.refill_enabled,
546
  "live_admission_enabled": self.live_admission_enabled,
@@ -623,7 +686,7 @@ class ManagedSnapshotRuntime:
623
 
624
  for attempt in range(1, self.generation_retries + 1):
625
  context = self._build_context()
626
- parent_entry = self._select_parent_entry()
627
  snapshot = _run_coro_sync(
628
  self.mutator.mutate(
629
  self.manifest,
@@ -930,10 +993,29 @@ class ManagedSnapshotRuntime:
930
  prefix = "snap_" + "_".join(vuln_types[:3]) if vuln_types else "snap_generated"
931
  return f"{prefix}_{int(time.time() * 1000)}"
932
 
933
- def _select_parent_entry(self):
934
  if self.snapshot_count() == 0:
935
  return None
936
- return _run_coro_sync(self.store.select_entry(strategy=self.selection_strategy))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
937
 
938
  def _snapshot_dir(self, snapshot_id: str) -> Path:
939
  return self.store_dir / snapshot_id
 
11
  import json
12
  import logging
13
  import os
14
+ import random
15
  import shlex
16
  import shutil
17
  import subprocess as sp
 
26
  import yaml
27
 
28
  from open_range.builder.builder import LLMSnapshotBuilder, TemplateOnlyBuilder
29
+ from open_range.builder.mutation_policy import PopulationMutationPolicy
30
  from open_range.builder.mutator import Mutator
31
  from open_range.builder.renderer import PAYLOAD_MANIFEST_NAME, SnapshotRenderer
32
  from open_range.builder.snapshot_store import SnapshotStore
 
43
  from open_range.validator.difficulty import DifficultyCheck
44
  from open_range.validator.evidence import EvidenceCheck
45
  from open_range.validator.exploitability import ExploitabilityCheck
46
+ from open_range.validator.graph_consistency import GraphConsistencyCheck
47
+ from open_range.validator.graph_evidence import GraphEvidenceSufficiencyCheck
48
+ from open_range.validator.graph_reward_grounding import GraphRewardGroundingCheck
49
  from open_range.validator.isolation import IsolationCheck
50
+ from open_range.validator.manifest_compliance import ManifestComplianceCheck
51
  from open_range.validator.npc_consistency import NPCConsistencyCheck
52
  from open_range.validator.patchability import PatchabilityCheck
53
+ from open_range.validator.path_solvability import PathSolvabilityCheck
54
  from open_range.validator.realism_review import RealismReviewCheck
55
  from open_range.validator.reward_grounding import RewardGroundingCheck
56
  from open_range.validator.task_feasibility import TaskFeasibilityCheck
 
214
  with self._lock:
215
  return list(self._history)
216
 
217
+ def snapshot_stats(self) -> dict[str, dict[str, Any]]:
218
+ with self._lock:
219
+ history = list(self._history)
220
+
221
+ now = time.time()
222
+ stats: dict[str, dict[str, Any]] = {}
223
+ for outcome in history:
224
+ if not outcome.snapshot_id:
225
+ continue
226
+ stat = stats.setdefault(
227
+ outcome.snapshot_id,
228
+ {
229
+ "plays": 0,
230
+ "completed": 0,
231
+ "red_solved": 0,
232
+ "blue_detected": 0,
233
+ "plays_recent": 0,
234
+ "last_seen_at": 0.0,
235
+ },
236
+ )
237
+ stat["plays"] += 1
238
+ if outcome.completed:
239
+ stat["completed"] += 1
240
+ if outcome.red_solved:
241
+ stat["red_solved"] += 1
242
+ if outcome.blue_detected:
243
+ stat["blue_detected"] += 1
244
+ if now - outcome.recorded_at <= 300:
245
+ stat["plays_recent"] += 1
246
+ stat["last_seen_at"] = max(float(stat["last_seen_at"]), outcome.recorded_at)
247
+
248
+ for stat in stats.values():
249
+ plays = max(int(stat["plays"]), 1)
250
+ stat["red_solve_rate"] = stat["red_solved"] / plays
251
+ stat["blue_detect_rate"] = stat["blue_detected"] / plays
252
+ return stats
253
+
254
 
255
  @dataclass(frozen=True, slots=True)
256
  class RuntimeSnapshot:
 
318
  return normalized
319
 
320
 
321
+ def _graph_checks(manifest: dict[str, Any]) -> list[Any]:
322
+ return [
323
+ ManifestComplianceCheck(manifest),
324
+ GraphConsistencyCheck(),
325
+ PathSolvabilityCheck(),
326
+ GraphEvidenceSufficiencyCheck(),
327
+ GraphRewardGroundingCheck(),
328
+ ]
329
+
330
+
331
+ def _build_validator(profile: str, manifest: dict[str, Any]) -> ValidatorGate:
332
  normalized = _normalize_validator_profile(profile)
333
  if normalized == "offline":
334
  return ValidatorGate(
335
+ _graph_checks(manifest)
336
+ + [
337
  StructuralSnapshotCheck(),
338
  TaskFeasibilityCheck(),
339
  ]
340
  )
341
 
342
  return ValidatorGate(
343
+ _graph_checks(manifest)
344
+ + [
345
+ StructuralSnapshotCheck(),
346
+ TaskFeasibilityCheck(),
347
  BuildBootCheck(),
348
  ExploitabilityCheck(),
349
  PatchabilityCheck(),
350
  EvidenceCheck(),
351
  RewardGroundingCheck(),
352
  IsolationCheck(),
 
353
  DifficultyCheck(),
354
  NPCConsistencyCheck(),
355
  RealismReviewCheck(),
 
383
  validator_profile: str | None = None,
384
  pool_size: int = 3,
385
  selection_strategy: str = "random",
386
+ parent_selection_strategy: str = "policy",
387
  refill_enabled: bool = False,
388
  refill_interval_s: float = 2.0,
389
  generation_retries: int = 3,
 
392
  compose_runner: ComposeProjectRunner | None = None,
393
  live_validator: ValidatorGate | None = None,
394
  enable_patch_validation: bool = False,
395
+ mutation_policy: PopulationMutationPolicy | None = None,
396
  ) -> None:
397
  self.manifest_path = (
398
  Path(manifest_path).resolve()
 
403
  self.store_dir = _resolve_store_dir(store_dir)
404
  self.store = SnapshotStore(str(self.store_dir))
405
  self.builder = builder or _default_builder()
406
+ self.mutation_policy = mutation_policy or PopulationMutationPolicy()
407
+ self.mutator = Mutator(self.builder, policy=self.mutation_policy)
408
  self.validator_profile = _normalize_validator_profile(
409
  validator_profile or os.getenv("OPENRANGE_RUNTIME_VALIDATOR_PROFILE", "offline")
410
  )
411
+ self.validator = validator or _build_validator(self.validator_profile, self.manifest)
412
  self.renderer = SnapshotRenderer()
413
  self.curriculum = CurriculumTracker()
414
  self.pool_size = max(1, pool_size)
415
  self.selection_strategy = selection_strategy
416
+ self.parent_selection_strategy = parent_selection_strategy
417
  self.refill_enabled = refill_enabled
418
  self.refill_interval_s = max(0.25, refill_interval_s)
419
  self.generation_retries = max(1, generation_retries)
 
442
  validator_profile=os.getenv("OPENRANGE_RUNTIME_VALIDATOR_PROFILE", "offline"),
443
  pool_size=_env_int("OPENRANGE_SNAPSHOT_POOL_SIZE", 3),
444
  selection_strategy=os.getenv("OPENRANGE_SNAPSHOT_SELECTION", "random"),
445
+ parent_selection_strategy=os.getenv("OPENRANGE_PARENT_SELECTION", "policy"),
446
  refill_enabled=_env_flag("OPENRANGE_ENABLE_MANAGED_REFILL", default=False),
447
  refill_interval_s=float(os.getenv("OPENRANGE_REFILL_INTERVAL_S", "2.0")),
448
  generation_retries=_env_int("OPENRANGE_GENERATION_RETRIES", 3),
 
603
  "store_dir": str(self.store_dir),
604
  "pool_size": self.pool_size,
605
  "selection_strategy": self.selection_strategy,
606
+ "parent_selection_strategy": self.parent_selection_strategy,
607
  "validator_profile": self.validator_profile,
608
  "refill_enabled": self.refill_enabled,
609
  "live_admission_enabled": self.live_admission_enabled,
 
686
 
687
  for attempt in range(1, self.generation_retries + 1):
688
  context = self._build_context()
689
+ parent_entry = self._select_parent_entry(context)
690
  snapshot = _run_coro_sync(
691
  self.mutator.mutate(
692
  self.manifest,
 
993
  prefix = "snap_" + "_".join(vuln_types[:3]) if vuln_types else "snap_generated"
994
  return f"{prefix}_{int(time.time() * 1000)}"
995
 
996
+ def _select_parent_entry(self, context: BuildContext):
997
  if self.snapshot_count() == 0:
998
  return None
999
+ if self.parent_selection_strategy in {"latest", "random"}:
1000
+ return _run_coro_sync(self.store.select_entry(strategy=self.parent_selection_strategy))
1001
+ entries = _run_coro_sync(self.store.list_entries())
1002
+ if not entries:
1003
+ return None
1004
+ rng = random.Random(context.seed if context.seed is not None else self._generation_counter)
1005
+ selected, score = self.mutation_policy.select_parent(
1006
+ entries,
1007
+ context=context,
1008
+ snapshot_stats=self.curriculum.snapshot_stats(),
1009
+ rng=rng,
1010
+ )
1011
+ logger.info(
1012
+ "ManagedSnapshotRuntime selected parent %s via %s (score=%.3f components=%s)",
1013
+ selected.snapshot_id,
1014
+ self.mutation_policy.name,
1015
+ score.total,
1016
+ score.components,
1017
+ )
1018
+ return selected
1019
 
1020
  def _snapshot_dir(self, snapshot_id: str) -> Path:
1021
  return self.store_dir / snapshot_id
src/open_range/validator/graph_consistency.py CHANGED
@@ -18,8 +18,8 @@ class GraphConsistencyCheck:
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:
@@ -50,13 +50,13 @@ class GraphConsistencyCheck:
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
@@ -66,6 +66,7 @@ class GraphConsistencyCheck:
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
  },
 
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.principals or target not in compiled.principals:
22
+ issues.append(f"trust edge '{source}->{target}' references unknown principal")
23
 
24
  lineage = snapshot.lineage
25
  if lineage.generation_depth == 0 and lineage.parent_snapshot_id:
 
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.principals:
54
  issues.append(
55
+ f"mutation '{op.mutation_id}' source principal '{source}' missing"
56
  )
57
+ if target and target not in compiled.principals:
58
  issues.append(
59
+ f"mutation '{op.mutation_id}' target principal '{target}' missing"
60
  )
61
 
62
  passed = len(issues) == 0
 
66
  details={
67
  "hosts": len(compiled.hosts),
68
  "users": len(compiled.users),
69
+ "principals": len(compiled.principals),
70
  "dependency_edges": len(compiled.dependency_edges),
71
  "trust_edges": len(compiled.trust_edges),
72
  },
src/open_range/validator/graph_evidence.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Graph-native evidence sufficiency checks."""
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 GraphEvidenceSufficiencyCheck:
10
+ """Verify that the compiled world exposes enough evidence for key facts."""
11
+
12
+ async def check(self, snapshot: SnapshotSpec, containers: ContainerSet) -> CheckResult:
13
+ compiled = compile_snapshot_graphs(snapshot)
14
+ evidence_hosts = {
15
+ _location_host(location)
16
+ for location in compiled.evidence_locations
17
+ if _location_host(location)
18
+ }
19
+ issues: list[str] = []
20
+
21
+ if not compiled.evidence_locations:
22
+ return CheckResult(
23
+ name="graph_evidence_sufficiency",
24
+ passed=False,
25
+ error="snapshot has no evidence locations",
26
+ )
27
+
28
+ for vuln in snapshot.truth_graph.vulns:
29
+ supporting_hosts = {vuln.host, "siem"}
30
+ if not evidence_hosts.intersection(supporting_hosts):
31
+ issues.append(
32
+ f"vuln '{vuln.id}' on host '{vuln.host}' has no supporting evidence host"
33
+ )
34
+
35
+ for flag in snapshot.flags:
36
+ supporting_hosts = {flag.host, "siem"}
37
+ if not evidence_hosts.intersection(supporting_hosts):
38
+ issues.append(
39
+ f"flag '{flag.id}' on host '{flag.host}' has no supporting evidence host"
40
+ )
41
+
42
+ passed = len(issues) == 0
43
+ return CheckResult(
44
+ name="graph_evidence_sufficiency",
45
+ passed=passed,
46
+ details={
47
+ "evidence_hosts": sorted(evidence_hosts),
48
+ "issues": issues,
49
+ },
50
+ error="" if passed else "; ".join(issues),
51
+ )
52
+
53
+
54
+ def _location_host(location: str) -> str:
55
+ if ":" not in location:
56
+ return "siem"
57
+ return location.split(":", 1)[0].strip()
src/open_range/validator/graph_reward_grounding.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Graph-native reward grounding checks."""
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
+ from open_range.validator.path_solvability import build_host_adjacency, has_host_path
8
+
9
+
10
+ class GraphRewardGroundingCheck:
11
+ """Verify rewards are grounded by graph facts before live checks run."""
12
+
13
+ async def check(self, snapshot: SnapshotSpec, containers: ContainerSet) -> CheckResult:
14
+ compiled = compile_snapshot_graphs(snapshot)
15
+ issues: list[str] = []
16
+
17
+ if not snapshot.flags:
18
+ return CheckResult(
19
+ name="graph_reward_grounding",
20
+ passed=False,
21
+ error="snapshot has no flags to ground",
22
+ )
23
+
24
+ adjacency = build_host_adjacency(snapshot, compiled)
25
+ vuln_hosts = {v.host for v in snapshot.truth_graph.vulns if v.host}
26
+ for flag in snapshot.flags:
27
+ if flag.host not in compiled.hosts:
28
+ issues.append(f"flag '{flag.id}' references unknown host '{flag.host}'")
29
+ continue
30
+
31
+ if flag.host in vuln_hosts:
32
+ continue
33
+
34
+ if vuln_hosts and not any(
35
+ has_host_path(source, flag.host, adjacency) for source in vuln_hosts
36
+ ):
37
+ issues.append(
38
+ f"flag '{flag.id}' on '{flag.host}' is not reachable from any vuln host"
39
+ )
40
+
41
+ passed = len(issues) == 0
42
+ return CheckResult(
43
+ name="graph_reward_grounding",
44
+ passed=passed,
45
+ details={"issues": issues},
46
+ error="" if passed else "; ".join(issues),
47
+ )
src/open_range/validator/graphs.py CHANGED
@@ -18,6 +18,8 @@ class CompiledGraphs:
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]]
@@ -31,6 +33,8 @@ def compile_snapshot_graphs(snapshot: SnapshotSpec) -> CompiledGraphs:
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)
@@ -40,6 +44,8 @@ def compile_snapshot_graphs(snapshot: SnapshotSpec) -> CompiledGraphs:
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,
@@ -80,6 +86,7 @@ def _compile_services(
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 = {}
@@ -87,6 +94,10 @@ def _compile_services(
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 = []
@@ -104,6 +115,21 @@ def _compile_dependency_edges(topology: dict[str, object]) -> frozenset[tuple[st
104
  target = str(raw.get("target", "")).strip()
105
  if source and target:
106
  edges.add((source, target))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  return frozenset(edges)
108
 
109
 
@@ -119,3 +145,45 @@ def _compile_trust_edges(topology: dict[str, object]) -> frozenset[tuple[str, st
119
  if source and target:
120
  edges.add((source, target, edge_type))
121
  return frozenset(edges)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
 
19
  hosts: frozenset[str]
20
  users: frozenset[str]
21
+ principals: frozenset[str]
22
+ zones_by_host: dict[str, str]
23
  services_by_host: dict[str, frozenset[str]]
24
  dependency_edges: frozenset[tuple[str, str]]
25
  trust_edges: frozenset[tuple[str, str, str]]
 
33
  topology = snapshot.topology or {}
34
  hosts = _compile_hosts(topology)
35
  users = _compile_users(topology)
36
+ principals = _compile_principals(topology, users)
37
+ zones_by_host = _compile_zones(topology, hosts)
38
  services_by_host = _compile_services(topology, hosts)
39
  dependency_edges = _compile_dependency_edges(topology)
40
  trust_edges = _compile_trust_edges(topology)
 
44
  return CompiledGraphs(
45
  hosts=hosts,
46
  users=users,
47
+ principals=principals,
48
+ zones_by_host=zones_by_host,
49
  services_by_host=services_by_host,
50
  dependency_edges=dependency_edges,
51
  trust_edges=trust_edges,
 
86
  hosts: frozenset[str],
87
  ) -> dict[str, frozenset[str]]:
88
  host_details = topology.get("host_details", {})
89
+ host_catalog = topology.get("host_catalog", {})
90
  compiled: dict[str, frozenset[str]] = {}
91
  for host in hosts:
92
  detail = {}
 
94
  raw_detail = host_details.get(host, {})
95
  if isinstance(raw_detail, dict):
96
  detail = raw_detail
97
+ if not detail and isinstance(host_catalog, dict):
98
+ raw_catalog = host_catalog.get(host, {})
99
+ if isinstance(raw_catalog, dict):
100
+ detail = raw_catalog
101
  services = detail.get("services", [])
102
  if not isinstance(services, list):
103
  services = []
 
115
  target = str(raw.get("target", "")).strip()
116
  if source and target:
117
  edges.add((source, target))
118
+ if edges:
119
+ return frozenset(edges)
120
+
121
+ host_details = topology.get("host_details", {})
122
+ if isinstance(host_details, dict):
123
+ for source, raw_detail in host_details.items():
124
+ if not isinstance(raw_detail, dict):
125
+ continue
126
+ raw_targets = raw_detail.get("connects_to", [])
127
+ if not isinstance(raw_targets, list):
128
+ continue
129
+ for raw_target in raw_targets:
130
+ target = str(raw_target).strip()
131
+ if source and target:
132
+ edges.add((str(source).strip(), target))
133
  return frozenset(edges)
134
 
135
 
 
145
  if source and target:
146
  edges.add((source, target, edge_type))
147
  return frozenset(edges)
148
+
149
+
150
+ def _compile_principals(
151
+ topology: dict[str, object],
152
+ users: frozenset[str],
153
+ ) -> frozenset[str]:
154
+ principals = set(users)
155
+ raw_catalog = topology.get("principal_catalog", {})
156
+ if isinstance(raw_catalog, dict):
157
+ for raw_name in raw_catalog:
158
+ name = str(raw_name).strip()
159
+ if name:
160
+ principals.add(name)
161
+ return frozenset(principals)
162
+
163
+
164
+ def _compile_zones(
165
+ topology: dict[str, object],
166
+ hosts: frozenset[str],
167
+ ) -> dict[str, str]:
168
+ zones_by_host: dict[str, str] = {}
169
+ raw_zones = topology.get("zones", {})
170
+ if isinstance(raw_zones, dict):
171
+ for raw_zone, raw_hosts in raw_zones.items():
172
+ zone = str(raw_zone).strip()
173
+ if not zone or not isinstance(raw_hosts, list):
174
+ continue
175
+ for raw_host in raw_hosts:
176
+ host = str(raw_host).strip()
177
+ if host:
178
+ zones_by_host[host] = zone
179
+
180
+ host_details = topology.get("host_details", {})
181
+ if isinstance(host_details, dict):
182
+ for raw_host, raw_detail in host_details.items():
183
+ host = str(raw_host).strip()
184
+ if not host or host not in hosts or not isinstance(raw_detail, dict):
185
+ continue
186
+ zone = str(raw_detail.get("zone", "")).strip()
187
+ if zone and host not in zones_by_host:
188
+ zones_by_host[host] = zone
189
+ return zones_by_host
src/open_range/validator/manifest_compliance.py CHANGED
@@ -32,6 +32,7 @@ class ManifestComplianceCheck:
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)
@@ -55,6 +56,20 @@ class ManifestComplianceCheck:
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}'")
@@ -93,6 +108,14 @@ class ManifestComplianceCheck:
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 "
@@ -139,6 +162,20 @@ def _manifest_users(manifest: dict[str, Any]) -> set[str]:
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", []):
 
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_principals = set(_manifest_principals(self.manifest))
36
  allowed_services = _manifest_services(self.manifest)
37
  allowed_dependency_edges = _manifest_dependency_edges(self.manifest)
38
  allowed_trust_edges = _manifest_trust_edges(self.manifest)
 
56
  f"host '{host}' has services outside manifest family: {sorted(illegal)}"
57
  )
58
 
59
+ illegal_dependency_edges = sorted(
60
+ edge for edge in compiled.dependency_edges if edge not in allowed_dependency_edges
61
+ )
62
+ if illegal_dependency_edges:
63
+ issues.append(
64
+ f"dependency edges outside manifest family: {illegal_dependency_edges}"
65
+ )
66
+
67
+ illegal_trust_edges = sorted(
68
+ edge for edge in compiled.trust_edges if edge not in allowed_trust_edges
69
+ )
70
+ if illegal_trust_edges:
71
+ issues.append(f"trust edges outside manifest family: {illegal_trust_edges}")
72
+
73
  for vuln in snapshot.truth_graph.vulns:
74
  if vuln.type and allowed_bug_families and vuln.type not in allowed_bug_families:
75
  issues.append(f"vuln '{vuln.id}' uses disallowed family '{vuln.type}'")
 
108
  source = op.target_selector.get("source", "")
109
  target = op.target_selector.get("target", "")
110
  edge_type = str(op.params.get("type", "")).strip()
111
+ if source and source not in allowed_principals:
112
+ issues.append(
113
+ f"add_trust_edge introduces unknown principal '{source}'"
114
+ )
115
+ if target and target not in allowed_principals:
116
+ issues.append(
117
+ f"add_trust_edge introduces unknown principal '{target}'"
118
+ )
119
  if (source, target, edge_type) not in allowed_trust_edges:
120
  issues.append(
121
  f"add_trust_edge introduces illegal trust edge "
 
162
  return users
163
 
164
 
165
+ def _manifest_principals(manifest: dict[str, Any]) -> set[str]:
166
+ principals = set(_manifest_users(manifest))
167
+ for raw in manifest.get("trust_relationships", []):
168
+ if not isinstance(raw, dict):
169
+ continue
170
+ source = str(raw.get("source") or raw.get("from") or "").strip()
171
+ target = str(raw.get("target") or raw.get("to") or "").strip()
172
+ if source:
173
+ principals.add(source)
174
+ if target:
175
+ principals.add(target)
176
+ return principals
177
+
178
+
179
  def _manifest_services(manifest: dict[str, Any]) -> dict[str, frozenset[str]]:
180
  services: dict[str, frozenset[str]] = {}
181
  for raw in manifest.get("topology", {}).get("hosts", []):
src/open_range/validator/path_solvability.py ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Graph-native path solvability checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import defaultdict, deque
6
+
7
+ from open_range.protocols import CheckResult, ContainerSet, SnapshotSpec
8
+ from open_range.validator.graphs import CompiledGraphs, compile_snapshot_graphs
9
+
10
+
11
+ class PathSolvabilityCheck:
12
+ """Verify that vuln and flag hosts are reachable in the compiled host graph."""
13
+
14
+ async def check(self, snapshot: SnapshotSpec, containers: ContainerSet) -> CheckResult:
15
+ compiled = compile_snapshot_graphs(snapshot)
16
+ issues: list[str] = []
17
+
18
+ if not compiled.hosts:
19
+ return CheckResult(
20
+ name="path_solvability",
21
+ passed=False,
22
+ error="snapshot has no compiled hosts",
23
+ )
24
+
25
+ start_hosts = _start_hosts(compiled)
26
+ vuln_hosts = {v.host for v in snapshot.truth_graph.vulns if v.host}
27
+ flag_hosts = {flag.host for flag in snapshot.flags if flag.host}
28
+ target_hosts = sorted(vuln_hosts.union(flag_hosts))
29
+ if not target_hosts:
30
+ return CheckResult(
31
+ name="path_solvability",
32
+ passed=False,
33
+ error="snapshot has no vuln or flag hosts to solve toward",
34
+ )
35
+
36
+ adjacency = build_host_adjacency(snapshot, compiled)
37
+ unreachable = [
38
+ host
39
+ for host in target_hosts
40
+ if not _reachable_from_any(host, start_hosts, adjacency)
41
+ ]
42
+ if unreachable:
43
+ issues.append(f"unreachable target hosts from start set {sorted(start_hosts)}: {unreachable}")
44
+
45
+ for flag_host in sorted(flag_hosts):
46
+ if not (
47
+ flag_host in vuln_hosts
48
+ or _reachable_from_any(flag_host, vuln_hosts or start_hosts, adjacency)
49
+ ):
50
+ issues.append(
51
+ f"flag host '{flag_host}' is not grounded by any vuln host or start host"
52
+ )
53
+
54
+ passed = len(issues) == 0
55
+ return CheckResult(
56
+ name="path_solvability",
57
+ passed=passed,
58
+ details={
59
+ "start_hosts": sorted(start_hosts),
60
+ "target_hosts": target_hosts,
61
+ "issues": issues,
62
+ },
63
+ error="" if passed else "; ".join(issues),
64
+ )
65
+
66
+
67
+ def _start_hosts(compiled: CompiledGraphs) -> set[str]:
68
+ starts = {
69
+ host
70
+ for host in compiled.hosts
71
+ if host in {"attacker", "internet"}
72
+ or compiled.zones_by_host.get(host) == "external"
73
+ }
74
+ if starts:
75
+ return starts
76
+ if compiled.hosts:
77
+ return {sorted(compiled.hosts)[0]}
78
+ return set()
79
+
80
+
81
+ def build_host_adjacency(
82
+ snapshot: SnapshotSpec,
83
+ compiled: CompiledGraphs,
84
+ ) -> dict[str, set[str]]:
85
+ adjacency: dict[str, set[str]] = defaultdict(set)
86
+ for source, target in compiled.dependency_edges:
87
+ adjacency[source].add(target)
88
+
89
+ principal_hosts = _principal_hosts(snapshot)
90
+ for source_principal, target_principal, _edge_type in compiled.trust_edges:
91
+ source_hosts = principal_hosts.get(source_principal, set())
92
+ target_hosts = principal_hosts.get(target_principal, set())
93
+ for source_host in source_hosts:
94
+ for target_host in target_hosts:
95
+ if source_host and target_host:
96
+ adjacency[source_host].add(target_host)
97
+ return adjacency
98
+
99
+
100
+ def has_host_path(
101
+ start: str,
102
+ target: str,
103
+ adjacency: dict[str, set[str]],
104
+ ) -> bool:
105
+ return _has_path(start, target, adjacency)
106
+
107
+
108
+ def _principal_hosts(snapshot: SnapshotSpec) -> dict[str, set[str]]:
109
+ topology = snapshot.topology or {}
110
+ mapping: dict[str, set[str]] = defaultdict(set)
111
+
112
+ raw_users = topology.get("users", [])
113
+ if isinstance(raw_users, list):
114
+ for raw in raw_users:
115
+ if not isinstance(raw, dict):
116
+ continue
117
+ username = str(raw.get("username", "")).strip()
118
+ if not username:
119
+ continue
120
+ for raw_host in raw.get("hosts", []):
121
+ host = str(raw_host).strip()
122
+ if host:
123
+ mapping[username].add(host)
124
+
125
+ raw_catalog = topology.get("principal_catalog", {})
126
+ if isinstance(raw_catalog, dict):
127
+ for raw_name, raw_principal in raw_catalog.items():
128
+ name = str(raw_name).strip()
129
+ if not name or not isinstance(raw_principal, dict):
130
+ continue
131
+ for raw_host in raw_principal.get("hosts", []):
132
+ host = str(raw_host).strip()
133
+ if host:
134
+ mapping[name].add(host)
135
+
136
+ return mapping
137
+
138
+
139
+ def _reachable_from_any(
140
+ target: str,
141
+ starts: set[str],
142
+ adjacency: dict[str, set[str]],
143
+ ) -> bool:
144
+ for start in starts:
145
+ if start == target:
146
+ return True
147
+ if _has_path(start, target, adjacency):
148
+ return True
149
+ return False
150
+
151
+
152
+ def _has_path(start: str, target: str, adjacency: dict[str, set[str]]) -> bool:
153
+ queue: deque[str] = deque([start])
154
+ seen = {start}
155
+ while queue:
156
+ current = queue.popleft()
157
+ for neighbor in adjacency.get(current, set()):
158
+ if neighbor == target:
159
+ return True
160
+ if neighbor in seen:
161
+ continue
162
+ seen.add(neighbor)
163
+ queue.append(neighbor)
164
+ return False
tests/test_builder.py CHANGED
@@ -174,6 +174,25 @@ async def test_mutator_builds_child_snapshot_with_lineage(tier1_manifest):
174
  assert child.lineage.mutation_summary
175
 
176
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
177
  @pytest.mark.asyncio
178
  async def test_mutator_rebuilds_child_files_from_mutated_snapshot(tier1_manifest):
179
  from open_range.builder.builder import TemplateOnlyBuilder
 
174
  assert child.lineage.mutation_summary
175
 
176
 
177
+ @pytest.mark.asyncio
178
+ async def test_mutator_compiles_root_snapshot_from_manifest_graph(tier1_manifest):
179
+ from open_range.builder.builder import TemplateOnlyBuilder
180
+ from open_range.builder.mutator import Mutator
181
+
182
+ root = await Mutator(TemplateOnlyBuilder()).mutate(
183
+ tier1_manifest,
184
+ context=BuildContext(seed=1, tier=1),
185
+ )
186
+ topology = root.topology
187
+ assert topology["host_details"]["web"]["services"]
188
+ assert topology["dependency_edges"]
189
+ assert topology["trust_edges"]
190
+ assert "principal_catalog" in topology
191
+ assert "schen" in topology["principal_catalog"]
192
+ assert "schen" not in {user["username"] for user in topology["users"]}
193
+ assert topology["manifest_normalization"]["trust_only_principals"]
194
+
195
+
196
  @pytest.mark.asyncio
197
  async def test_mutator_rebuilds_child_files_from_mutated_snapshot(tier1_manifest):
198
  from open_range.builder.builder import TemplateOnlyBuilder
tests/test_lint.py CHANGED
@@ -106,9 +106,10 @@ class TestValidManifest:
106
  assert errors == [], f"Check '{check_name}' failed: {errors}"
107
 
108
  def test_tier1_manifest_loads(self):
109
- """Tier 1 manifest should at least load without schema error."""
110
  result = lint_file(ROOT / "manifests" / "tier1_basic.yaml")
111
  assert result["schema_error"] is None, result["schema_error"]
 
112
 
113
 
114
  # ---------------------------------------------------------------------------
@@ -177,35 +178,35 @@ class TestInvalidUserRefs:
177
  assert len(errors) == 1
178
  assert "ghost_user" in errors[0]
179
 
180
- def test_trust_relationship_invalid_source(self):
181
  data = _minimal_manifest()
182
  data["trust_relationships"] = [
183
  {
184
  "type": "delegates_access",
185
- "from": "nobody",
186
  "to": "admin",
187
  },
188
  ]
189
  manifest = Manifest(**data)
190
  results = lint_manifest(manifest)
191
- errors = results["trust relationship usernames"]
192
  assert len(errors) == 1
193
- assert "nobody" in errors[0]
194
 
195
- def test_trust_relationship_invalid_target(self):
196
  data = _minimal_manifest()
197
  data["trust_relationships"] = [
198
  {
199
  "type": "delegates_access",
200
  "from": "admin",
201
- "to": "phantom",
202
  },
203
  ]
204
  manifest = Manifest(**data)
205
  results = lint_manifest(manifest)
206
- errors = results["trust relationship usernames"]
207
  assert len(errors) == 1
208
- assert "phantom" in errors[0]
209
 
210
 
211
  # ---------------------------------------------------------------------------
 
106
  assert errors == [], f"Check '{check_name}' failed: {errors}"
107
 
108
  def test_tier1_manifest_loads(self):
109
+ """Tier 1 manifest should load and pass lint checks."""
110
  result = lint_file(ROOT / "manifests" / "tier1_basic.yaml")
111
  assert result["schema_error"] is None, result["schema_error"]
112
+ assert result["valid"] is True, result["checks"]
113
 
114
 
115
  # ---------------------------------------------------------------------------
 
178
  assert len(errors) == 1
179
  assert "ghost_user" in errors[0]
180
 
181
+ def test_trust_relationship_invalid_source_identifier(self):
182
  data = _minimal_manifest()
183
  data["trust_relationships"] = [
184
  {
185
  "type": "delegates_access",
186
+ "from": "bad actor!",
187
  "to": "admin",
188
  },
189
  ]
190
  manifest = Manifest(**data)
191
  results = lint_manifest(manifest)
192
+ errors = results["trust relationship principals"]
193
  assert len(errors) == 1
194
+ assert "bad actor!" in errors[0]
195
 
196
+ def test_trust_relationship_invalid_target_identifier(self):
197
  data = _minimal_manifest()
198
  data["trust_relationships"] = [
199
  {
200
  "type": "delegates_access",
201
  "from": "admin",
202
+ "to": "phantom user",
203
  },
204
  ]
205
  manifest = Manifest(**data)
206
  results = lint_manifest(manifest)
207
+ errors = results["trust relationship principals"]
208
  assert len(errors) == 1
209
+ assert "phantom user" in errors[0]
210
 
211
 
212
  # ---------------------------------------------------------------------------
tests/test_runtime.py CHANGED
@@ -21,6 +21,11 @@ class TestManagedSnapshotRuntime:
21
  )
22
  names = [type(check).__name__ for check in runtime.validator.checks]
23
  assert names == [
 
 
 
 
 
24
  "StructuralSnapshotCheck",
25
  "TaskFeasibilityCheck",
26
  ]
@@ -33,6 +38,13 @@ class TestManagedSnapshotRuntime:
33
  refill_enabled=False,
34
  )
35
  names = [type(check).__name__ for check in runtime.validator.checks]
 
 
 
 
 
 
 
36
  assert "BuildBootCheck" in names
37
  assert "ExploitabilityCheck" in names
38
  assert "PatchabilityCheck" in names
@@ -117,6 +129,7 @@ class TestManagedSnapshotRuntime:
117
  store_dir=tmp_path / "snapshots",
118
  pool_size=2,
119
  selection_strategy="latest",
 
120
  refill_enabled=False,
121
  )
122
 
@@ -131,6 +144,17 @@ class TestManagedSnapshotRuntime:
131
  finally:
132
  runtime.stop()
133
 
 
 
 
 
 
 
 
 
 
 
 
134
  def test_acquire_snapshot_exposes_lineage_metadata(self, tier1_manifest, tmp_path):
135
  runtime = ManagedSnapshotRuntime(
136
  manifest=tier1_manifest,
 
21
  )
22
  names = [type(check).__name__ for check in runtime.validator.checks]
23
  assert names == [
24
+ "ManifestComplianceCheck",
25
+ "GraphConsistencyCheck",
26
+ "PathSolvabilityCheck",
27
+ "GraphEvidenceSufficiencyCheck",
28
+ "GraphRewardGroundingCheck",
29
  "StructuralSnapshotCheck",
30
  "TaskFeasibilityCheck",
31
  ]
 
38
  refill_enabled=False,
39
  )
40
  names = [type(check).__name__ for check in runtime.validator.checks]
41
+ assert names[:5] == [
42
+ "ManifestComplianceCheck",
43
+ "GraphConsistencyCheck",
44
+ "PathSolvabilityCheck",
45
+ "GraphEvidenceSufficiencyCheck",
46
+ "GraphRewardGroundingCheck",
47
+ ]
48
  assert "BuildBootCheck" in names
49
  assert "ExploitabilityCheck" in names
50
  assert "PatchabilityCheck" in names
 
129
  store_dir=tmp_path / "snapshots",
130
  pool_size=2,
131
  selection_strategy="latest",
132
+ parent_selection_strategy="policy",
133
  refill_enabled=False,
134
  )
135
 
 
144
  finally:
145
  runtime.stop()
146
 
147
+ def test_status_reports_parent_selection_strategy(self, tier1_manifest, tmp_path):
148
+ runtime = ManagedSnapshotRuntime(
149
+ manifest=tier1_manifest,
150
+ store_dir=tmp_path / "snapshots",
151
+ pool_size=1,
152
+ parent_selection_strategy="policy",
153
+ refill_enabled=False,
154
+ )
155
+ status = runtime.status()
156
+ assert status["parent_selection_strategy"] == "policy"
157
+
158
  def test_acquire_snapshot_exposes_lineage_metadata(self, tier1_manifest, tmp_path):
159
  runtime = ManagedSnapshotRuntime(
160
  manifest=tier1_manifest,
tests/test_validator.py CHANGED
@@ -73,6 +73,134 @@ async def test_graph_consistency_rejects_missing_parent_lineage(sample_snapshot_
73
  assert "missing parent_snapshot_id" in result.error
74
 
75
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  # ---------------------------------------------------------------------------
77
  # Check 1: BuildBoot
78
  # ---------------------------------------------------------------------------
 
73
  assert "missing parent_snapshot_id" in result.error
74
 
75
 
76
+ @pytest.mark.asyncio
77
+ async def test_path_solvability_passes_for_reachable_flag_host(mock_containers):
78
+ from open_range.protocols import EvidenceItem, TruthGraph, Vulnerability
79
+ from open_range.validator.path_solvability import PathSolvabilityCheck
80
+
81
+ spec = SnapshotSpec(
82
+ topology={
83
+ "hosts": ["attacker", "web", "db"],
84
+ "zones": {"external": ["attacker"], "dmz": ["web"], "internal": ["db"]},
85
+ "dependency_edges": [
86
+ {"source": "attacker", "target": "web"},
87
+ {"source": "web", "target": "db"},
88
+ ],
89
+ "host_details": {
90
+ "attacker": {"services": ["nmap"]},
91
+ "web": {"services": ["nginx"]},
92
+ "db": {"services": ["mysql"]},
93
+ },
94
+ },
95
+ truth_graph=TruthGraph(
96
+ vulns=[Vulnerability(id="v1", type="sqli", host="web", service="nginx")],
97
+ ),
98
+ flags=[FlagSpec(id="f1", value="FLAG{ok}", path="/var/flags/flag1.txt", host="db")],
99
+ evidence_spec=[EvidenceItem(type="log_entry", location="siem:/var/log/siem/all.log")],
100
+ golden_path=[GoldenPathStep(step=1, command="nmap web", expect_in_stdout="80/tcp")],
101
+ task=TaskSpec(red_briefing="go", blue_briefing="watch"),
102
+ )
103
+
104
+ result = await PathSolvabilityCheck().check(spec, mock_containers)
105
+ assert result.passed is True
106
+
107
+
108
+ @pytest.mark.asyncio
109
+ async def test_graph_evidence_sufficiency_fails_without_supporting_hosts(mock_containers):
110
+ from open_range.protocols import TruthGraph, Vulnerability
111
+ from open_range.validator.graph_evidence import GraphEvidenceSufficiencyCheck
112
+
113
+ spec = SnapshotSpec(
114
+ topology={
115
+ "hosts": ["attacker", "web", "db"],
116
+ "zones": {"external": ["attacker"], "dmz": ["web"], "internal": ["db"]},
117
+ "dependency_edges": [{"source": "attacker", "target": "web"}],
118
+ "host_details": {
119
+ "attacker": {"services": ["nmap"]},
120
+ "web": {"services": ["nginx"]},
121
+ "db": {"services": ["mysql"]},
122
+ },
123
+ },
124
+ truth_graph=TruthGraph(
125
+ vulns=[Vulnerability(id="v1", type="sqli", host="db", service="mysql")],
126
+ ),
127
+ flags=[FlagSpec(id="f1", value="FLAG{db}", path="/var/flags/flag1.txt", host="db")],
128
+ evidence_spec=[EvidenceItem(type="log_entry", location="web:/var/log/access.log")],
129
+ golden_path=[GoldenPathStep(step=1, command="scan", expect_in_stdout="ok")],
130
+ task=TaskSpec(red_briefing="go", blue_briefing="watch"),
131
+ )
132
+
133
+ result = await GraphEvidenceSufficiencyCheck().check(spec, mock_containers)
134
+ assert result.passed is False
135
+ assert "no supporting evidence host" in result.error
136
+
137
+
138
+ @pytest.mark.asyncio
139
+ async def test_graph_reward_grounding_fails_when_flag_host_unreachable(mock_containers):
140
+ from open_range.protocols import TruthGraph, Vulnerability
141
+ from open_range.validator.graph_reward_grounding import GraphRewardGroundingCheck
142
+
143
+ spec = SnapshotSpec(
144
+ topology={
145
+ "hosts": ["attacker", "web", "db"],
146
+ "zones": {"external": ["attacker"], "dmz": ["web"], "internal": ["db"]},
147
+ "dependency_edges": [{"source": "attacker", "target": "web"}],
148
+ "host_details": {
149
+ "attacker": {"services": ["nmap"]},
150
+ "web": {"services": ["nginx"]},
151
+ "db": {"services": ["mysql"]},
152
+ },
153
+ },
154
+ truth_graph=TruthGraph(
155
+ vulns=[Vulnerability(id="v1", type="sqli", host="web", service="nginx")],
156
+ ),
157
+ flags=[FlagSpec(id="f1", value="FLAG{db}", path="/var/flags/flag1.txt", host="db")],
158
+ evidence_spec=[EvidenceItem(type="log_entry", location="siem:/var/log/siem/all.log")],
159
+ golden_path=[GoldenPathStep(step=1, command="scan", expect_in_stdout="ok")],
160
+ task=TaskSpec(red_briefing="go", blue_briefing="watch"),
161
+ )
162
+
163
+ result = await GraphRewardGroundingCheck().check(spec, mock_containers)
164
+ assert result.passed is False
165
+ assert "not reachable from any vuln host" in result.error
166
+
167
+
168
+ @pytest.mark.asyncio
169
+ async def test_graph_checks_allow_trust_based_host_pivots(mock_containers):
170
+ from open_range.validator.graph_reward_grounding import GraphRewardGroundingCheck
171
+ from open_range.validator.path_solvability import PathSolvabilityCheck
172
+
173
+ spec = SnapshotSpec(
174
+ topology={
175
+ "hosts": ["attacker", "web", "db"],
176
+ "zones": {"external": ["attacker"], "dmz": ["web"], "internal": ["db"]},
177
+ "dependency_edges": [{"source": "attacker", "target": "web"}],
178
+ "trust_edges": [{"source": "websvc", "target": "dbsvc", "type": "credential_reuse"}],
179
+ "host_details": {
180
+ "attacker": {"services": ["nmap"]},
181
+ "web": {"services": ["nginx"]},
182
+ "db": {"services": ["mysql"]},
183
+ },
184
+ "principal_catalog": {
185
+ "websvc": {"username": "websvc", "hosts": ["web"], "is_login_account": False},
186
+ "dbsvc": {"username": "dbsvc", "hosts": ["db"], "is_login_account": False},
187
+ },
188
+ },
189
+ truth_graph=TruthGraph(
190
+ vulns=[Vulnerability(id="v1", type="credential_reuse", host="web", service="nginx")],
191
+ ),
192
+ flags=[FlagSpec(id="f1", value="FLAG{db}", path="/var/flags/flag1.txt", host="db")],
193
+ evidence_spec=[EvidenceItem(type="log_entry", location="db:/var/log/mysql.log")],
194
+ golden_path=[GoldenPathStep(step=1, command="scan", expect_in_stdout="ok")],
195
+ task=TaskSpec(red_briefing="go", blue_briefing="watch"),
196
+ )
197
+
198
+ path_result = await PathSolvabilityCheck().check(spec, mock_containers)
199
+ reward_result = await GraphRewardGroundingCheck().check(spec, mock_containers)
200
+ assert path_result.passed is True
201
+ assert reward_result.passed is True
202
+
203
+
204
  # ---------------------------------------------------------------------------
205
  # Check 1: BuildBoot
206
  # ---------------------------------------------------------------------------