Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ jobs:
if: steps.docker_check.outputs.available == 'true'
env:
SHIELDCLAW_ATTACKER_IMAGE: shieldclaw-attacker:ci
SHIELDCLAW_COMPOSE_START_TIMEOUT: "240"
run: >-
pytest
-m integration
Expand Down
29 changes: 26 additions & 3 deletions shield-claw/src/shieldclaw/sandbox/docker_orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
_DOCKER_INFO_TIMEOUT = 15.0
_COMPOSE_UP_TIMEOUT = 120.0
_START_POLL_INTERVAL = 2.0
_START_WAIT_SECONDS = 60.0
_START_WAIT_SECONDS = 120.0

# Default tag for the pre-built attacker image. Override via
# SHIELDCLAW_ATTACKER_IMAGE to pin a different version or registry.
Expand All @@ -41,6 +41,24 @@ def _detonate_image() -> str:
return os.environ.get("SHIELDCLAW_ATTACKER_IMAGE", _DETONATE_IMAGE_DEFAULT)


def _compose_start_timeout() -> float:
"""Return the compose startup wait timeout from ``SHIELDCLAW_COMPOSE_START_TIMEOUT``.

Falls back to ``_START_WAIT_SECONDS`` when the variable is unset or invalid.
"""
raw = os.environ.get("SHIELDCLAW_COMPOSE_START_TIMEOUT", "")
if raw:
try:
return float(raw)
except ValueError:
_LOG.warning(
"SHIELDCLAW_COMPOSE_START_TIMEOUT=%r is not a valid float; using default %s s",
raw,
_START_WAIT_SECONDS,
)
return _START_WAIT_SECONDS
Comment on lines +44 to +59


def compose_project_name(result_id: str) -> str:
"""Return a deterministic Compose project slug derived from ``result_id``.

Expand Down Expand Up @@ -77,19 +95,24 @@ class DockerOrchestrator:
def __init__(
self,
*,
start_wait_seconds: float = _START_WAIT_SECONDS,
start_wait_seconds: float | None = None,
start_poll_interval: float = _START_POLL_INTERVAL,
post_up_grace_seconds: float = 2.0,
) -> None:
"""Create an orchestrator with configurable startup polling.

Args:
start_wait_seconds: Maximum time to wait for compose services after ``up``.
When ``None`` (the default), the value is read from the
``SHIELDCLAW_COMPOSE_START_TIMEOUT`` environment variable, falling back
to ``120`` seconds when the variable is unset.
start_poll_interval: Sleep interval between readiness probes.
post_up_grace_seconds: Extra sleep after healthcheck gating completes.
Reduced from 10 s to 2 s now that readiness is healthcheck-gated.
"""
self._start_wait = start_wait_seconds
self._start_wait = (
_compose_start_timeout() if start_wait_seconds is None else start_wait_seconds
)
Comment on lines +98 to +115
self._poll_interval = start_poll_interval
self._post_up_grace = post_up_grace_seconds

Expand Down
35 changes: 35 additions & 0 deletions shield-claw/tests/test_docker_orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from shieldclaw.exceptions import DetonationError, DockerNotAvailableError, SandboxStartError
from shieldclaw.models import ExploitPayload
from shieldclaw.sandbox.docker_orchestrator import (
_START_WAIT_SECONDS,
DockerOrchestrator,
compose_default_network,
compose_project_name,
Expand All @@ -31,6 +32,40 @@ def test_compose_default_network_matches_project() -> None:
assert compose_default_network(rid) == f"{compose_project_name(rid)}_default"


def test_orchestrator_default_start_wait_uses_module_default(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""When no env var is set, ``start_wait_seconds`` defaults to ``_START_WAIT_SECONDS``."""
monkeypatch.delenv("SHIELDCLAW_COMPOSE_START_TIMEOUT", raising=False)
orch = DockerOrchestrator()
assert orch._start_wait == _START_WAIT_SECONDS


def test_orchestrator_start_wait_reads_env_var(monkeypatch: pytest.MonkeyPatch) -> None:
"""``SHIELDCLAW_COMPOSE_START_TIMEOUT`` overrides the default when ``start_wait_seconds`` is unset."""
monkeypatch.setenv("SHIELDCLAW_COMPOSE_START_TIMEOUT", "240")
orch = DockerOrchestrator()
assert orch._start_wait == 240.0


def test_orchestrator_start_wait_explicit_overrides_env_var(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""An explicit ``start_wait_seconds`` argument takes precedence over the env var."""
monkeypatch.setenv("SHIELDCLAW_COMPOSE_START_TIMEOUT", "999")
orch = DockerOrchestrator(start_wait_seconds=30.0)
assert orch._start_wait == 30.0


def test_orchestrator_start_wait_invalid_env_var_uses_default(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""An invalid ``SHIELDCLAW_COMPOSE_START_TIMEOUT`` value falls back to the module default."""
monkeypatch.setenv("SHIELDCLAW_COMPOSE_START_TIMEOUT", "not-a-number")
Comment on lines +60 to +64
orch = DockerOrchestrator()
assert orch._start_wait == _START_WAIT_SECONDS


def test_ensure_docker_raises_when_docker_missing(mocker: MockerFixture) -> None:
"""Missing Docker CLI should map to ``DockerNotAvailableError``."""
mocker.patch(
Expand Down
2 changes: 0 additions & 2 deletions shield-claw/tests/test_docker_orchestrator_concurrency.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,10 @@ def test_concurrent_runs_do_not_interfere(tmp_path: Path) -> None:
result_id_b = str(uuid.uuid4())

orch_a = DockerOrchestrator(
start_wait_seconds=120.0,
start_poll_interval=1.0,
post_up_grace_seconds=0.0,
)
orch_b = DockerOrchestrator(
start_wait_seconds=120.0,
start_poll_interval=1.0,
post_up_grace_seconds=0.0,
)
Comment on lines 71 to 78
Expand Down
2 changes: 1 addition & 1 deletion shield-claw/tests/test_docker_orchestrator_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ def _docker_available() -> bool:
return False


@pytest.mark.integration
@pytest.mark.skipif(not _COMPOSE_SRC.is_file(), reason="vulnerable-flask-app compose file missing")
@pytest.mark.skipif(not _docker_available(), reason="Docker engine not available")
def test_full_stack_detonate_and_teardown(integration_compose: Path) -> None:
Expand Down Expand Up @@ -88,7 +89,6 @@ def test_full_stack_detonate_and_teardown(integration_compose: Path) -> None:
pytest.skip(f"docker compose build failed: {build.stderr}")

orchestrator = DockerOrchestrator(
start_wait_seconds=120.0,
start_poll_interval=2.0,
post_up_grace_seconds=0.0,
)
Expand Down