Lars Talian commited on
Commit
ecc152d
·
2 Parent(s): 0d7e48bb41adc1

Merge remote-tracking branch 'origin/main' into codex/issue-77-20260308

Browse files

# Conflicts:
# Dockerfile
# README.md
# src/open_range/server/runtime.py

Dockerfile CHANGED
@@ -86,10 +86,8 @@ ENV PYTHONPATH="/app/env/src:/app/env"
86
  ENV OPENRANGE_EXECUTION_MODE=subprocess
87
  # Enable the managed runtime so reset() boots real services from the manifest
88
  ENV OPENRANGE_RUNTIME_MANIFEST=manifests/tier1_basic.yaml
89
- # This all-in-one image runs in subprocess mode (no nested Docker daemon), so
90
- # it opts out of strict live admission explicitly.
91
- ENV OPENRANGE_RUNTIME_VALIDATOR_PROFILE=offline
92
- ENV OPENRANGE_ALLOW_OFFLINE_ADMISSION=1
93
  ENV OPENRANGE_SNAPSHOT_POOL_SIZE=1
94
  # Enable the OpenEnv Gradio web interface at /web
95
  ENV ENABLE_WEB_INTERFACE=true
 
86
  ENV OPENRANGE_EXECUTION_MODE=subprocess
87
  # Enable the managed runtime so reset() boots real services from the manifest
88
  ENV OPENRANGE_RUNTIME_MANIFEST=manifests/tier1_basic.yaml
89
+ ENV OPENRANGE_RUNTIME_VALIDATOR_PROFILE=training
90
+ ENV OPENRANGE_ENABLE_LIVE_ADMISSION=1
 
 
91
  ENV OPENRANGE_SNAPSHOT_POOL_SIZE=1
92
  # Enable the OpenEnv Gradio web interface at /web
93
  ENV ENABLE_WEB_INTERFACE=true
README.md CHANGED
@@ -100,14 +100,19 @@ uv run pytest tests/ -v --tb=short
100
 
101
  The deployed package exposes the standard OpenEnv `reset()`, `step()`, and `state()` contract through `server.app:app`, which is the entrypoint referenced by `openenv.yaml`.
102
 
103
- **Validator** — Admission gate for candidate snapshots. Managed runtime defaults to strict live admission (`OPENRANGE_RUNTIME_VALIDATOR_PROFILE=training`), which runs graph checks plus container-backed build/exploit/patch/evidence/reward/isolation/difficulty/NPC/realism checks before storing a snapshot. Non-live admission (`offline`) is allowed only as an explicit opt-out via `OPENRANGE_ALLOW_OFFLINE_ADMISSION=1`, and the runtime logs a startup warning when this mode is used.
104
 
105
- | Validator Profile | Included Checks | Operational Guarantee | Intended Use |
106
- |-------------------|-----------------|-----------------------|--------------|
107
- | `training` (default) | Graph + structural + task + build/boot + exploitability + patchability + evidence + reward grounding + isolation + difficulty + NPC consistency + realism review | Strict live/container-backed admission | Managed/production runtime |
108
- | `offline` | Graph + structural + task feasibility only | No live exploit/patch/evidence/reward validation | Local fallback only (must set `OPENRANGE_ALLOW_OFFLINE_ADMISSION=1`) |
109
 
110
- `OPENRANGE_ENABLE_LIVE_ADMISSION=1` is an additional artifact-level boot validation pass after a candidate snapshot is admitted.
 
 
 
 
 
 
 
 
111
 
112
  **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.
113
 
 
100
 
101
  The deployed package exposes the standard OpenEnv `reset()`, `step()`, and `state()` contract through `server.app:app`, which is the entrypoint referenced by `openenv.yaml`.
102
 
103
+ **Validator** — Admission gate for candidate snapshots. The shipped runtime enforces manifest compliance plus graph-native checks such as graph consistency, path solvability, evidence sufficiency, and reward grounding before structural/task checks. With the `training` profile, the runtime boots rendered bundles, applies payload files, constructs a real `ContainerSet`, and runs live build/exploit/patch/evidence/reward/isolation/difficulty/NPC/realism checks before admission.
104
 
105
+ Validator profile matrix:
 
 
 
106
 
107
+ | Profile | Checks | Guarantees |
108
+ |---------|--------|------------|
109
+ | `offline` | Graph + structural/task checks only (no live containers) | Fast static admission only; no live exploitability/patchability guarantee |
110
+ | `training` | `offline` checks + live/container-backed checks | Full admission guarantees for managed training/runtime use |
111
+
112
+ Managed runtime defaults and safety behavior:
113
+ - `OPENRANGE_RUNTIME_VALIDATOR_PROFILE` defaults to `training`.
114
+ - `OPENRANGE_ENABLE_LIVE_ADMISSION` defaults to `1`.
115
+ - If managed runtime is configured non-live (`offline` profile and/or live admission disabled), startup raises an error unless you explicitly opt out with `OPENRANGE_ALLOW_NON_LIVE_ADMISSION=1` (legacy alias: `OPENRANGE_ALLOW_OFFLINE_ADMISSION=1`), in which case a warning is emitted.
116
 
117
  **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.
118
 
src/open_range/server/runtime.py CHANGED
@@ -59,7 +59,11 @@ from open_range.validator.validator import ValidationResult, ValidatorGate
59
  logger = logging.getLogger(__name__)
60
 
61
  _DEFAULT_MANIFEST = ("manifests", "tier1_basic.yaml")
62
- _DEFAULT_VALIDATOR_PROFILE = "training"
 
 
 
 
63
  _VALIDATOR_PROFILE_ALIASES = {
64
  "light": "offline",
65
  "static": "offline",
@@ -67,7 +71,6 @@ _VALIDATOR_PROFILE_ALIASES = {
67
  "strict": "training",
68
  }
69
  _LIVE_VALIDATOR_PROFILES = {"training"}
70
- _ALLOW_OFFLINE_ADMISSION_ENV = "OPENRANGE_ALLOW_OFFLINE_ADMISSION"
71
  _PERSISTED_SNAPSHOT_VALIDATION_ALIASES = {
72
  "none": "trust",
73
  "disabled": "trust",
@@ -91,6 +94,21 @@ def _env_int(name: str, default: int) -> int:
91
  return int(raw)
92
 
93
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
  def _candidate_roots() -> list[Path]:
95
  roots: list[Path] = []
96
  cwd = Path.cwd()
@@ -377,6 +395,19 @@ def _build_validator(profile: str, manifest: dict[str, Any]) -> ValidatorGate:
377
  )
378
 
379
 
 
 
 
 
 
 
 
 
 
 
 
 
 
380
  def _default_live_validator(*, include_patchability: bool = False) -> ValidatorGate:
381
  checks = [
382
  BuildBootCheck(),
@@ -428,7 +459,7 @@ class ManagedSnapshotRuntime:
428
  self.mutation_policy = mutation_policy or PopulationMutationPolicy()
429
  self.mutator = Mutator(self.builder, policy=self.mutation_policy)
430
  self.allow_insecure_offline_profile = (
431
- _env_flag(_ALLOW_OFFLINE_ADMISSION_ENV, default=False)
432
  if allow_insecure_offline_profile is None
433
  else bool(allow_insecure_offline_profile)
434
  )
@@ -474,27 +505,42 @@ class ManagedSnapshotRuntime:
474
 
475
  @classmethod
476
  def from_env(cls) -> "ManagedSnapshotRuntime":
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
477
  return cls(
478
  manifest_path=os.getenv("OPENRANGE_RUNTIME_MANIFEST"),
479
  store_dir=os.getenv("OPENRANGE_SNAPSHOT_DIR"),
480
- validator_profile=os.getenv(
481
- "OPENRANGE_RUNTIME_VALIDATOR_PROFILE",
482
- _DEFAULT_VALIDATOR_PROFILE,
483
- ),
484
- allow_insecure_offline_profile=_env_flag(
485
- _ALLOW_OFFLINE_ADMISSION_ENV,
486
- default=False,
487
- ),
488
  pool_size=_env_int("OPENRANGE_SNAPSHOT_POOL_SIZE", 3),
489
  selection_strategy=os.getenv("OPENRANGE_SNAPSHOT_SELECTION", "random"),
490
  parent_selection_strategy=os.getenv("OPENRANGE_PARENT_SELECTION", "policy"),
491
  refill_enabled=_env_flag("OPENRANGE_ENABLE_MANAGED_REFILL", default=False),
492
  refill_interval_s=float(os.getenv("OPENRANGE_REFILL_INTERVAL_S", "2.0")),
493
  generation_retries=_env_int("OPENRANGE_GENERATION_RETRIES", 3),
494
- live_admission_enabled=_env_flag(
495
- "OPENRANGE_ENABLE_LIVE_ADMISSION",
496
- default=False,
497
- ),
498
  teardown_booted_projects=not _env_flag(
499
  "OPENRANGE_KEEP_BOOTED_VALIDATION_STACKS",
500
  default=False,
@@ -523,12 +569,13 @@ class ManagedSnapshotRuntime:
523
  raise RuntimeError(
524
  warning
525
  + " Set OPENRANGE_RUNTIME_VALIDATOR_PROFILE=training for strict admission, "
526
- + f"or set {_ALLOW_OFFLINE_ADMISSION_ENV}=1 to explicitly opt out."
 
 
527
  )
528
  logger.warning(
529
- "%s Running with explicit opt-out (%s=1).",
530
  warning,
531
- _ALLOW_OFFLINE_ADMISSION_ENV,
532
  )
533
 
534
  @staticmethod
 
59
  logger = logging.getLogger(__name__)
60
 
61
  _DEFAULT_MANIFEST = ("manifests", "tier1_basic.yaml")
62
+ _DEFAULT_MANAGED_VALIDATOR_PROFILE = "training"
63
+ _DEFAULT_MANAGED_LIVE_ADMISSION = True
64
+ _ALLOW_NON_LIVE_ADMISSION_ENV = "OPENRANGE_ALLOW_NON_LIVE_ADMISSION"
65
+ _ALLOW_OFFLINE_ADMISSION_ENV = "OPENRANGE_ALLOW_OFFLINE_ADMISSION"
66
+ _DEFAULT_VALIDATOR_PROFILE = _DEFAULT_MANAGED_VALIDATOR_PROFILE
67
  _VALIDATOR_PROFILE_ALIASES = {
68
  "light": "offline",
69
  "static": "offline",
 
71
  "strict": "training",
72
  }
73
  _LIVE_VALIDATOR_PROFILES = {"training"}
 
74
  _PERSISTED_SNAPSHOT_VALIDATION_ALIASES = {
75
  "none": "trust",
76
  "disabled": "trust",
 
94
  return int(raw)
95
 
96
 
97
+ def _non_live_opt_out_enabled() -> bool:
98
+ return _env_flag(_ALLOW_NON_LIVE_ADMISSION_ENV, default=False) or _env_flag(
99
+ _ALLOW_OFFLINE_ADMISSION_ENV,
100
+ default=False,
101
+ )
102
+
103
+
104
+ def _non_live_opt_out_env_name() -> str | None:
105
+ if _env_flag(_ALLOW_NON_LIVE_ADMISSION_ENV, default=False):
106
+ return _ALLOW_NON_LIVE_ADMISSION_ENV
107
+ if _env_flag(_ALLOW_OFFLINE_ADMISSION_ENV, default=False):
108
+ return _ALLOW_OFFLINE_ADMISSION_ENV
109
+ return None
110
+
111
+
112
  def _candidate_roots() -> list[Path]:
113
  roots: list[Path] = []
114
  cwd = Path.cwd()
 
395
  )
396
 
397
 
398
+ def _strict_admission_enabled(profile: str, live_admission_enabled: bool) -> bool:
399
+ return profile in _LIVE_VALIDATOR_PROFILES and live_admission_enabled
400
+
401
+
402
+ def _managed_admission_failure_message(profile: str, live_admission_enabled: bool) -> str:
403
+ return (
404
+ "Managed runtime requires strict live admission "
405
+ f"(validator_profile='training', live_admission_enabled=1). "
406
+ f"Current configuration: validator_profile={profile!r}, "
407
+ f"live_admission_enabled={live_admission_enabled!r}."
408
+ )
409
+
410
+
411
  def _default_live_validator(*, include_patchability: bool = False) -> ValidatorGate:
412
  checks = [
413
  BuildBootCheck(),
 
459
  self.mutation_policy = mutation_policy or PopulationMutationPolicy()
460
  self.mutator = Mutator(self.builder, policy=self.mutation_policy)
461
  self.allow_insecure_offline_profile = (
462
+ _non_live_opt_out_enabled()
463
  if allow_insecure_offline_profile is None
464
  else bool(allow_insecure_offline_profile)
465
  )
 
505
 
506
  @classmethod
507
  def from_env(cls) -> "ManagedSnapshotRuntime":
508
+ profile = _normalize_validator_profile(
509
+ os.getenv(
510
+ "OPENRANGE_RUNTIME_VALIDATOR_PROFILE",
511
+ _DEFAULT_MANAGED_VALIDATOR_PROFILE,
512
+ )
513
+ )
514
+ live_admission_enabled = _env_flag(
515
+ "OPENRANGE_ENABLE_LIVE_ADMISSION",
516
+ default=_DEFAULT_MANAGED_LIVE_ADMISSION,
517
+ )
518
+ if not _strict_admission_enabled(profile, live_admission_enabled):
519
+ message = _managed_admission_failure_message(profile, live_admission_enabled)
520
+ opt_out_env = _non_live_opt_out_env_name()
521
+ if opt_out_env:
522
+ logger.warning(
523
+ "%s Explicit opt-out enabled via %s=1.",
524
+ message,
525
+ opt_out_env,
526
+ )
527
+ else:
528
+ raise RuntimeError(
529
+ f"{message} Set {_ALLOW_NON_LIVE_ADMISSION_ENV}=1 to explicitly opt out."
530
+ )
531
+
532
  return cls(
533
  manifest_path=os.getenv("OPENRANGE_RUNTIME_MANIFEST"),
534
  store_dir=os.getenv("OPENRANGE_SNAPSHOT_DIR"),
535
+ validator_profile=profile,
536
+ allow_insecure_offline_profile=_non_live_opt_out_enabled(),
 
 
 
 
 
 
537
  pool_size=_env_int("OPENRANGE_SNAPSHOT_POOL_SIZE", 3),
538
  selection_strategy=os.getenv("OPENRANGE_SNAPSHOT_SELECTION", "random"),
539
  parent_selection_strategy=os.getenv("OPENRANGE_PARENT_SELECTION", "policy"),
540
  refill_enabled=_env_flag("OPENRANGE_ENABLE_MANAGED_REFILL", default=False),
541
  refill_interval_s=float(os.getenv("OPENRANGE_REFILL_INTERVAL_S", "2.0")),
542
  generation_retries=_env_int("OPENRANGE_GENERATION_RETRIES", 3),
543
+ live_admission_enabled=live_admission_enabled,
 
 
 
544
  teardown_booted_projects=not _env_flag(
545
  "OPENRANGE_KEEP_BOOTED_VALIDATION_STACKS",
546
  default=False,
 
569
  raise RuntimeError(
570
  warning
571
  + " Set OPENRANGE_RUNTIME_VALIDATOR_PROFILE=training for strict admission, "
572
+ + "or set OPENRANGE_ALLOW_NON_LIVE_ADMISSION=1 "
573
+ + "(legacy alias: OPENRANGE_ALLOW_OFFLINE_ADMISSION=1) "
574
+ + "to explicitly opt out."
575
  )
576
  logger.warning(
577
+ "%s Running with explicit opt-out.",
578
  warning,
 
579
  )
580
 
581
  @staticmethod
src/open_range/validator/isolation.py CHANGED
@@ -2,6 +2,8 @@
2
 
3
  from __future__ import annotations
4
 
 
 
5
  from open_range.protocols import CheckResult, ContainerSet, SnapshotSpec
6
 
7
  # Common service ports to probe for zone isolation violations.
@@ -33,10 +35,14 @@ class IsolationCheck:
33
  open_ports: list[int] = []
34
  for port in _PROBE_PORTS:
35
  try:
 
 
 
 
 
36
  output = await containers.exec(
37
  attacker_host,
38
- f"timeout 2 bash -c 'echo > /dev/tcp/{target_name}/{port}' "
39
- f"2>/dev/null && echo OPEN || echo CLOSED",
40
  )
41
  if "OPEN" in output:
42
  open_ports.append(port)
 
2
 
3
  from __future__ import annotations
4
 
5
+ import shlex
6
+
7
  from open_range.protocols import CheckResult, ContainerSet, SnapshotSpec
8
 
9
  # Common service ports to probe for zone isolation violations.
 
35
  open_ports: list[int] = []
36
  for port in _PROBE_PORTS:
37
  try:
38
+ probe_cmd = (
39
+ "timeout 2 bash -lc 'echo > /dev/tcp/\"$1\"/\"$2\"' _ "
40
+ f"{shlex.quote(target_name)} {shlex.quote(str(port))} "
41
+ "2>/dev/null && echo OPEN || echo CLOSED"
42
+ )
43
  output = await containers.exec(
44
  attacker_host,
45
+ probe_cmd,
 
46
  )
47
  if "OPEN" in output:
48
  open_ports.append(port)
tests/test_runtime.py CHANGED
@@ -3,9 +3,11 @@
3
  from __future__ import annotations
4
 
5
  import json
 
6
  from pathlib import Path
7
 
8
  import pytest
 
9
 
10
  from open_range.protocols import CheckResult, ContainerSet, SnapshotSpec
11
  from open_range.server.compose_runner import BootedSnapshotProject
@@ -15,6 +17,62 @@ from open_range.validator.validator import ValidationResult
15
 
16
 
17
  class TestManagedSnapshotRuntime:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  def test_offline_validator_profile_includes_static_checks(self, tier1_manifest, tmp_path):
19
  runtime = ManagedSnapshotRuntime(
20
  manifest=tier1_manifest,
 
3
  from __future__ import annotations
4
 
5
  import json
6
+ import logging
7
  from pathlib import Path
8
 
9
  import pytest
10
+ import yaml
11
 
12
  from open_range.protocols import CheckResult, ContainerSet, SnapshotSpec
13
  from open_range.server.compose_runner import BootedSnapshotProject
 
17
 
18
 
19
  class TestManagedSnapshotRuntime:
20
+ def test_from_env_defaults_to_training_and_live(self, tier1_manifest, tmp_path, monkeypatch):
21
+ manifest_path = tmp_path / "manifest.yaml"
22
+ manifest_path.write_text(yaml.safe_dump(tier1_manifest), encoding="utf-8")
23
+
24
+ monkeypatch.setenv("OPENRANGE_RUNTIME_MANIFEST", str(manifest_path))
25
+ monkeypatch.setenv("OPENRANGE_SNAPSHOT_DIR", str(tmp_path / "snapshots"))
26
+ monkeypatch.delenv("OPENRANGE_RUNTIME_VALIDATOR_PROFILE", raising=False)
27
+ monkeypatch.delenv("OPENRANGE_ENABLE_LIVE_ADMISSION", raising=False)
28
+ monkeypatch.delenv("OPENRANGE_ALLOW_NON_LIVE_ADMISSION", raising=False)
29
+
30
+ runtime = ManagedSnapshotRuntime.from_env()
31
+ assert runtime.validator_profile == "training"
32
+ assert runtime.live_admission_enabled is True
33
+
34
+ def test_from_env_rejects_non_live_without_explicit_opt_out(
35
+ self,
36
+ tier1_manifest,
37
+ tmp_path,
38
+ monkeypatch,
39
+ ):
40
+ manifest_path = tmp_path / "manifest.yaml"
41
+ manifest_path.write_text(yaml.safe_dump(tier1_manifest), encoding="utf-8")
42
+
43
+ monkeypatch.setenv("OPENRANGE_RUNTIME_MANIFEST", str(manifest_path))
44
+ monkeypatch.setenv("OPENRANGE_SNAPSHOT_DIR", str(tmp_path / "snapshots"))
45
+ monkeypatch.setenv("OPENRANGE_RUNTIME_VALIDATOR_PROFILE", "offline")
46
+ monkeypatch.setenv("OPENRANGE_ENABLE_LIVE_ADMISSION", "0")
47
+ monkeypatch.delenv("OPENRANGE_ALLOW_NON_LIVE_ADMISSION", raising=False)
48
+
49
+ with pytest.raises(RuntimeError, match="OPENRANGE_ALLOW_NON_LIVE_ADMISSION=1"):
50
+ ManagedSnapshotRuntime.from_env()
51
+
52
+ def test_from_env_allows_non_live_with_explicit_opt_out_warning(
53
+ self,
54
+ tier1_manifest,
55
+ tmp_path,
56
+ monkeypatch,
57
+ caplog,
58
+ ):
59
+ manifest_path = tmp_path / "manifest.yaml"
60
+ manifest_path.write_text(yaml.safe_dump(tier1_manifest), encoding="utf-8")
61
+
62
+ monkeypatch.setenv("OPENRANGE_RUNTIME_MANIFEST", str(manifest_path))
63
+ monkeypatch.setenv("OPENRANGE_SNAPSHOT_DIR", str(tmp_path / "snapshots"))
64
+ monkeypatch.setenv("OPENRANGE_RUNTIME_VALIDATOR_PROFILE", "offline")
65
+ monkeypatch.setenv("OPENRANGE_ENABLE_LIVE_ADMISSION", "0")
66
+ monkeypatch.setenv("OPENRANGE_ALLOW_NON_LIVE_ADMISSION", "1")
67
+
68
+ with caplog.at_level(logging.WARNING):
69
+ runtime = ManagedSnapshotRuntime.from_env()
70
+
71
+ assert runtime.validator_profile == "offline"
72
+ assert runtime.live_admission_enabled is False
73
+ assert "strict live admission" in caplog.text
74
+ assert "OPENRANGE_ALLOW_NON_LIVE_ADMISSION=1" in caplog.text
75
+
76
  def test_offline_validator_profile_includes_static_checks(self, tier1_manifest, tmp_path):
77
  runtime = ManagedSnapshotRuntime(
78
  manifest=tier1_manifest,
tests/test_validator.py CHANGED
@@ -8,6 +8,7 @@ import pytest
8
  from open_range.protocols import (
9
  CheckResult,
10
  EvidenceItem,
 
11
  ExploitStep,
12
  FlagSpec,
13
  GoldenPathStep,
@@ -795,6 +796,36 @@ async def test_evidence_fails_on_nonzero_exit_even_when_output_present(mock_cont
795
  assert result.details["missing"][0]["location"] == "siem:/var/log/test.log"
796
 
797
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
798
  # ---------------------------------------------------------------------------
799
  # Check 5: Reward grounding
800
  # ---------------------------------------------------------------------------
@@ -1053,7 +1084,7 @@ async def test_isolation_fails_on_non_ssh_port(mock_containers):
1053
  # Only port 3306 is OPEN; everything else CLOSED.
1054
  async def exec_side_effect(container, cmd, **kwargs):
1055
  if container == "attacker" and "/dev/tcp/" in cmd:
1056
- if "/3306'" in cmd or "/3306}" in cmd:
1057
  return "OPEN"
1058
  return "CLOSED"
1059
  return ""
@@ -1065,6 +1096,38 @@ async def test_isolation_fails_on_non_ssh_port(mock_containers):
1065
  assert "db" in result.error
1066
 
1067
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1068
  # ---------------------------------------------------------------------------
1069
  # Check 7: Task feasibility
1070
  # ---------------------------------------------------------------------------
 
8
  from open_range.protocols import (
9
  CheckResult,
10
  EvidenceItem,
11
+ ExecResult,
12
  ExploitStep,
13
  FlagSpec,
14
  GoldenPathStep,
 
796
  assert result.details["missing"][0]["location"] == "siem:/var/log/test.log"
797
 
798
 
799
+ @pytest.mark.asyncio
800
+ async def test_evidence_quotes_pattern_and_location_path():
801
+ """Evidence grep command must quote pattern and path from snapshot content."""
802
+ import shlex
803
+
804
+ from open_range.validator.evidence import EvidenceCheck
805
+
806
+ class RecordingContainers:
807
+ def __init__(self) -> None:
808
+ self.calls: list[tuple[str, str]] = []
809
+
810
+ async def exec_run(self, container: str, cmd: str, **kwargs) -> ExecResult:
811
+ self.calls.append((container, cmd))
812
+ return ExecResult(stdout="1", exit_code=0)
813
+
814
+ containers = RecordingContainers()
815
+ pattern = "ERR'; touch /tmp/pwn #"
816
+ path = "/var/log/app; echo PWNED"
817
+ spec = SnapshotSpec(
818
+ evidence_spec=[
819
+ EvidenceItem(type="log_entry", location=f"siem:{path}", pattern=pattern),
820
+ ],
821
+ )
822
+
823
+ result = await EvidenceCheck().check(spec, containers) # type: ignore[arg-type]
824
+ assert result.passed is True
825
+ assert containers.calls
826
+ assert containers.calls[0][1] == f"grep -c {shlex.quote(pattern)} {shlex.quote(path)}"
827
+
828
+
829
  # ---------------------------------------------------------------------------
830
  # Check 5: Reward grounding
831
  # ---------------------------------------------------------------------------
 
1084
  # Only port 3306 is OPEN; everything else CLOSED.
1085
  async def exec_side_effect(container, cmd, **kwargs):
1086
  if container == "attacker" and "/dev/tcp/" in cmd:
1087
+ if " 3306 " in cmd:
1088
  return "OPEN"
1089
  return "CLOSED"
1090
  return ""
 
1096
  assert "db" in result.error
1097
 
1098
 
1099
+ @pytest.mark.asyncio
1100
+ async def test_isolation_uses_argument_safe_tcp_probe_for_target_name():
1101
+ """Target names are passed as positional args, not interpolated into script."""
1102
+ from open_range.validator.isolation import IsolationCheck
1103
+
1104
+ class RecordingContainers:
1105
+ def __init__(self) -> None:
1106
+ self.calls: list[tuple[str, str]] = []
1107
+
1108
+ async def exec(self, container: str, cmd: str, **kwargs) -> str:
1109
+ self.calls.append((container, cmd))
1110
+ return "CLOSED"
1111
+
1112
+ containers = RecordingContainers()
1113
+ target = "db'; touch /tmp/pwn #"
1114
+ spec = SnapshotSpec(
1115
+ topology={"hosts": ["attacker", "db"], "zones": {"internal": [target]}},
1116
+ flags=[],
1117
+ golden_path=[],
1118
+ task=TaskSpec(red_briefing="Go.", blue_briefing="Watch."),
1119
+ )
1120
+
1121
+ result = await IsolationCheck().check(spec, containers) # type: ignore[arg-type]
1122
+ assert result.passed is True
1123
+ assert containers.calls
1124
+ first_cmd = containers.calls[0][1]
1125
+ script_part, _, arg_part = first_cmd.partition(" _ ")
1126
+ assert "bash -lc 'echo > /dev/tcp/\"$1\"/\"$2\"'" in script_part
1127
+ assert "touch /tmp/pwn" not in script_part
1128
+ assert "touch /tmp/pwn" in arg_part
1129
+
1130
+
1131
  # ---------------------------------------------------------------------------
1132
  # Check 7: Task feasibility
1133
  # ---------------------------------------------------------------------------