Files
cleveragents-core/features/steps/sandbox_boundary_algebra_steps.py
T
freemo 5935940276
CI / lint (pull_request) Successful in 16s
CI / typecheck (pull_request) Successful in 31s
CI / security (pull_request) Successful in 29s
CI / benchmark-publish (pull_request) Has been skipped
CI / build (pull_request) Successful in 1m21s
CI / quality (pull_request) Successful in 2m33s
CI / unit_tests (pull_request) Successful in 3m10s
CI / integration_tests (pull_request) Successful in 5m17s
CI / coverage (pull_request) Successful in 4m25s
CI / docker (pull_request) Successful in 40s
CI / lint (push) Successful in 12s
CI / typecheck (push) Successful in 31s
CI / quality (push) Successful in 15s
CI / security (push) Successful in 32s
CI / build (push) Successful in 21s
CI / integration_tests (push) Successful in 3m6s
CI / unit_tests (push) Successful in 3m30s
CI / benchmark-regression (push) Has been skipped
CI / docker (push) Successful in 1m43s
CI / coverage (push) Successful in 5m21s
CI / benchmark-regression (pull_request) Successful in 31m11s
CI / benchmark-publish (push) Successful in 17m24s
feat(sandbox): implement sandbox boundary algebra and domain computation
Implement sandbox_boundary(r) function that walks up containment edges
in the resource DAG to the nearest sandboxable ancestor, enabling
resources sharing a boundary to share one sandbox instance.

Changes:
- Add boundary.py: is_sandbox_boundary(), sandbox_boundary(),
  compute_sandbox_domains(), BoundaryCache (thread-safe, per-execution)
- Update SandboxManager: resolve_sandbox_key() and
  get_or_create_sandbox_for_resource() key by (plan_id, boundary_id)
  instead of (plan_id, resource_id); boundary cache lifecycle methods
- Define "sandboxable" via ResourceCapabilities.sandboxable + non-none
  sandbox_strategy as per specification section 24659-24674
- Export new symbols from sandbox __init__.py
- Add vulture whitelist entries for new public API

Tests:
- 26 Behave BDD scenarios (features/sandbox_boundary_algebra.feature)
- 5 Robot Framework integration tests (robot/sandbox_boundary_algebra.robot)
- ASV benchmarks for boundary walk, domain grouping, and cache performance

ISSUES CLOSED: #548
2026-03-04 15:59:15 +00:00

535 lines
19 KiB
Python

"""Step definitions for sandbox boundary algebra BDD tests.
Covers:
- ``sandbox_boundary(r)`` function
- ``is_sandbox_boundary(r)`` predicate
- ``compute_sandbox_domains()`` grouping
- ``BoundaryCache`` caching behaviour
- ``SandboxManager`` boundary-aware keying
"""
from __future__ import annotations
from datetime import datetime
from typing import Any
from unittest.mock import MagicMock
from behave import given, then, when
from cleveragents.domain.models.core.resource import (
PhysVirt,
Resource,
ResourceCapabilities,
SandboxStrategy,
)
from cleveragents.infrastructure.sandbox.boundary import (
BoundaryCache,
NoSandboxBoundaryError,
compute_sandbox_domains,
is_sandbox_boundary,
sandbox_boundary,
)
from cleveragents.infrastructure.sandbox.factory import SandboxFactory
from cleveragents.infrastructure.sandbox.manager import SandboxManager
from cleveragents.infrastructure.sandbox.protocol import (
CommitResult,
SandboxContext,
SandboxStatus,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
# Counter for generating unique ULIDs in tests.
_ULID_COUNTER: int = 0
def _make_ulid(name: str) -> str:
"""Generate a deterministic ULID-like ID from a name.
Uses Crockford base-32 charset (0-9, A-H, J-K, M-N, P-T, V-Z)
to satisfy the ULID pattern ``^[0-9A-HJKMNP-TV-Z]{26}$``.
"""
global _ULID_COUNTER
_ULID_COUNTER += 1
# Pad the counter + hash of name into 26 chars of Crockford base32.
base = abs(hash(name)) % (10**20)
raw = f"{_ULID_COUNTER:06d}{base:020d}"
# Map digits to Crockford charset.
charset = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
result = ""
for ch in raw:
result += charset[int(ch)]
# Pad/truncate to 26.
return (result + "0" * 26)[:26]
def _make_resource(
name: str,
sandboxable: bool = False,
strategy: str = "none",
parents: list[str] | None = None,
location: str | None = None,
) -> Resource:
"""Create a test resource with the given properties."""
rid = _make_ulid(name)
return Resource(
resource_id=rid,
name=name,
resource_type_name="fs-file" if not sandboxable else "git-checkout",
classification=PhysVirt.PHYSICAL,
sandbox_strategy=SandboxStrategy(strategy),
capabilities=ResourceCapabilities(
readable=True,
writable=True,
sandboxable=sandboxable,
checkpointable=False,
),
parents=parents or [],
location=location,
)
# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------
@given("a resource registry for boundary algebra tests")
def step_given_resource_registry(context: Any) -> None:
"""Initialise a fresh resource registry (dict) for this scenario."""
context.resource_registry = {}
context.resources_by_name = {}
context.boundary_result = None
context.boundary_error = None
context.is_boundary_result = None
context.domain_result = None
context.boundary_cache = None
context.sandbox_key_result = None
context.boundary_sandbox_results = []
context.error = None
# ---------------------------------------------------------------------------
# Given steps - resource construction
# ---------------------------------------------------------------------------
@given('a sandboxable resource "{name}" with strategy "{strategy}"')
def step_given_sandboxable_resource(context: Any, name: str, strategy: str) -> None:
res = _make_resource(name, sandboxable=True, strategy=strategy)
context.resource_registry[res.resource_id] = res
context.resources_by_name[name] = res
@given('a located sandboxable resource "{name}" strategy "{strategy}" location "{loc}"')
def step_given_sandboxable_resource_with_location(
context: Any, name: str, strategy: str, loc: str
) -> None:
res = _make_resource(name, sandboxable=True, strategy=strategy, location=loc)
context.resource_registry[res.resource_id] = res
context.resources_by_name[name] = res
@given('a non-sandboxable resource "{name}" with parent "{parent_name}"')
def step_given_nonsandboxable_with_parent(
context: Any, name: str, parent_name: str
) -> None:
parent = context.resources_by_name[parent_name]
res = _make_resource(name, sandboxable=False, parents=[parent.resource_id])
context.resource_registry[res.resource_id] = res
context.resources_by_name[name] = res
@given('a non-sandboxable resource "{name}" with no parents')
def step_given_nonsandboxable_orphan(context: Any, name: str) -> None:
res = _make_resource(name, sandboxable=False)
context.resource_registry[res.resource_id] = res
context.resources_by_name[name] = res
@given('a non-sandboxable resource "{name}" with missing parent "{missing_parent}"')
def step_given_nonsandboxable_dangling(
context: Any, name: str, missing_parent: str
) -> None:
# Parent ID is not registered in the registry.
fake_parent_id = _make_ulid(missing_parent)
res = _make_resource(name, sandboxable=False, parents=[fake_parent_id])
context.resource_registry[res.resource_id] = res
context.resources_by_name[name] = res
@given('a resource "{name}" with sandboxable true and strategy "{strategy}"')
def step_given_sandboxable_with_strategy(
context: Any, name: str, strategy: str
) -> None:
res = _make_resource(name, sandboxable=True, strategy=strategy)
context.resource_registry[res.resource_id] = res
context.resources_by_name[name] = res
@given("a fresh boundary cache")
def step_given_boundary_cache(context: Any) -> None:
context.boundary_cache = BoundaryCache()
def _make_mock_sandbox(
resource_id: str = "res-mock",
plan_id: str = "plan-mock",
original_path: str = "/tmp/mock",
) -> MagicMock:
"""Create a mock sandbox satisfying the Sandbox protocol."""
sb = MagicMock()
sandbox_id = f"sb-{resource_id}"
sb.sandbox_id = sandbox_id
sb.status = SandboxStatus.CREATED
sb.context = SandboxContext(
sandbox_id=sandbox_id,
sandbox_path=original_path,
original_path=original_path,
resource_id=resource_id,
plan_id=plan_id,
created_at=datetime.now(),
)
sb.create.return_value = sb.context
sb.commit.return_value = CommitResult(
sandbox_id=sandbox_id,
success=True,
timestamp=datetime.now(),
)
sb.rollback.return_value = None
sb.cleanup.return_value = None
return sb
@given("a mock sandbox factory")
def step_given_mock_factory(context: Any) -> None:
"""Create a mock factory that returns mock sandboxes.
Mocks are cached by resource_id so the same mock is returned for
repeated calls with the same resource_id (mimics real dedup).
"""
factory = MagicMock(spec=SandboxFactory)
mock_cache: dict[str, MagicMock] = {}
def _create_sandbox(
resource_id: str,
original_path: str,
sandbox_strategy: str,
) -> MagicMock:
if resource_id not in mock_cache:
mock_cache[resource_id] = _make_mock_sandbox(
resource_id=resource_id,
original_path=original_path,
)
return mock_cache[resource_id]
factory.create_sandbox.side_effect = _create_sandbox
context.factory = factory
@given("a sandbox manager with the mock factory")
def step_given_manager_mock_factory(context: Any) -> None:
context.manager = SandboxManager(factory=context.factory, cleanup_on_exit=False)
context.error = None
# ---------------------------------------------------------------------------
# When steps
# ---------------------------------------------------------------------------
@when('I compute the sandbox boundary for "{name}"')
def step_when_compute_boundary(context: Any, name: str) -> None:
res = context.resources_by_name[name]
context.boundary_result = sandbox_boundary(res, context.resource_registry)
@when('I check if "{name}" is a sandbox boundary')
def step_when_check_is_boundary(context: Any, name: str) -> None:
res = context.resources_by_name[name]
context.is_boundary_result = is_sandbox_boundary(res)
@when("I check is_sandbox_boundary with None resource")
def step_when_check_is_boundary_none(context: Any) -> None:
try:
is_sandbox_boundary(None) # type: ignore[arg-type]
except ValueError as exc:
context.error = exc
@when('I compute sandbox domains for "{name_a}" and "{name_b}"')
def step_when_compute_domains(context: Any, name_a: str, name_b: str) -> None:
res_a = context.resources_by_name[name_a]
res_b = context.resources_by_name[name_b]
context.domain_result = compute_sandbox_domains(
[res_a, res_b], context.resource_registry
)
@when('I look up the boundary for "{name}" via the cache')
def step_when_cache_lookup(context: Any, name: str) -> None:
res = context.resources_by_name[name]
context.boundary_result = context.boundary_cache.get_boundary(
res, context.resource_registry
)
@when('I look up the boundary for "{name}" via the cache again')
def step_when_cache_lookup_again(context: Any, name: str) -> None:
# Same as above; exercises cache hit path.
res = context.resources_by_name[name]
context.boundary_result = context.boundary_cache.get_boundary(
res, context.resource_registry
)
@when("I clear the boundary cache")
def step_when_clear_cache(context: Any) -> None:
if context.boundary_cache is not None:
context.boundary_cache.clear()
else:
context.manager.clear_boundary_cache()
@when('I resolve the sandbox key for plan "{plan_id}" resource "{name}"')
def step_when_resolve_sandbox_key(context: Any, plan_id: str, name: str) -> None:
res = context.resources_by_name[name]
context.sandbox_key_result = context.manager.resolve_sandbox_key(
res, context.resource_registry
)
@when('I try to resolve the sandbox key for plan "{plan_id}" resource "{name}"')
def step_when_try_resolve_sandbox_key(context: Any, plan_id: str, name: str) -> None:
res = context.resources_by_name[name]
try:
context.manager.resolve_sandbox_key(res, context.resource_registry)
except NoSandboxBoundaryError as exc:
context.error = exc
@when('I get or create a boundary sandbox for plan "{plan_id}" resource "{name}"')
def step_when_get_or_create_boundary_sandbox(
context: Any, plan_id: str, name: str
) -> None:
res = context.resources_by_name[name]
sb = context.manager.get_or_create_sandbox_for_resource(
plan_id=plan_id,
resource=res,
resource_registry=context.resource_registry,
)
context.boundary_sandbox_results.append(sb)
@when("I clear the manager boundary cache")
def step_when_clear_manager_cache(context: Any) -> None:
context.manager.clear_boundary_cache()
@given('a non-sandboxable resource "{name}" with parents "{parent_a}" and "{parent_b}"')
def step_given_nonsandboxable_multi_parent(
context: Any, name: str, parent_a: str, parent_b: str
) -> None:
pa = context.resources_by_name[parent_a]
pb = context.resources_by_name[parent_b]
res = _make_resource(
name, sandboxable=False, parents=[pa.resource_id, pb.resource_id]
)
context.resource_registry[res.resource_id] = res
context.resources_by_name[name] = res
@when("I call boundary cache get_boundary with None resource")
def step_when_cache_none_resource(context: Any) -> None:
try:
context.boundary_cache.get_boundary(None, context.resource_registry) # type: ignore[arg-type]
except ValueError as exc:
context.error = exc
@when('I call boundary cache get_boundary with None registry for "{name}"')
def step_when_cache_none_registry(context: Any, name: str) -> None:
res = context.resources_by_name[name]
try:
context.boundary_cache.get_boundary(res, None) # type: ignore[arg-type]
except ValueError as exc:
context.error = exc
@when("I call sandbox_boundary with None resource")
def step_when_sandbox_boundary_none_resource(context: Any) -> None:
try:
sandbox_boundary(None, context.resource_registry) # type: ignore[arg-type]
except ValueError as exc:
context.error = exc
@when('I call sandbox_boundary with None registry for "{name}"')
def step_when_sandbox_boundary_none_registry(context: Any, name: str) -> None:
res = context.resources_by_name[name]
try:
sandbox_boundary(res, None) # type: ignore[arg-type]
except ValueError as exc:
context.error = exc
@when("I call compute_sandbox_domains with None resources")
def step_when_domains_none_resources(context: Any) -> None:
try:
compute_sandbox_domains(None, context.resource_registry) # type: ignore[arg-type]
except ValueError as exc:
context.error = exc
@when("I call compute_sandbox_domains with None registry")
def step_when_domains_none_registry(context: Any) -> None:
res = context.resources_by_name.get("dummy")
try:
compute_sandbox_domains([res] if res else [], None) # type: ignore[arg-type]
except ValueError as exc:
context.error = exc
# ---------------------------------------------------------------------------
# Then steps
# ---------------------------------------------------------------------------
@then('the boundary should be "{expected_name}"')
def step_then_boundary_is(context: Any, expected_name: str) -> None:
expected = context.resources_by_name[expected_name]
assert context.boundary_result is not None, (
f"Expected boundary '{expected_name}' but got None"
)
assert context.boundary_result.resource_id == expected.resource_id, (
f"Expected boundary {expected.resource_id}, "
f"got {context.boundary_result.resource_id}"
)
@then("the boundary should be None")
def step_then_boundary_is_none(context: Any) -> None:
assert context.boundary_result is None, (
f"Expected None boundary, got {context.boundary_result}"
)
@then("it should be a sandbox boundary")
def step_then_is_boundary(context: Any) -> None:
assert context.is_boundary_result is True, (
"Expected resource to be a sandbox boundary"
)
@then("it should not be a sandbox boundary")
def step_then_is_not_boundary(context: Any) -> None:
assert context.is_boundary_result is False, (
"Expected resource NOT to be a sandbox boundary"
)
@then('the error should be a ValueError with text "{msg}"')
def step_then_value_error_containing(context: Any, msg: str) -> None:
assert context.error is not None, "Expected a ValueError but no error was raised"
assert isinstance(context.error, ValueError), (
f"Expected ValueError, got {type(context.error).__name__}: {context.error}"
)
assert msg in str(context.error), f"Expected '{msg}' in '{context.error}'"
assert msg in str(context.error), f"Expected '{msg}' in '{context.error}'"
@then('both resources should be in the domain of "{boundary_name}"')
def step_then_both_in_domain(context: Any, boundary_name: str) -> None:
boundary = context.resources_by_name[boundary_name]
domain = context.domain_result.get(boundary.resource_id, [])
assert len(domain) == 2, (
f"Expected 2 resources in domain of '{boundary_name}', got {len(domain)}"
)
@then('"{name}" should be in the domain of "{boundary_name}"')
def step_then_resource_in_domain(context: Any, name: str, boundary_name: str) -> None:
boundary = context.resources_by_name[boundary_name]
res = context.resources_by_name[name]
domain = context.domain_result.get(boundary.resource_id, [])
ids = [r.resource_id for r in domain]
assert res.resource_id in ids, (
f"Resource '{name}' not found in domain of '{boundary_name}'"
)
@then("both resources should be in the unsandboxable domain")
def step_then_both_unsandboxable(context: Any) -> None:
domain = context.domain_result.get("__unsandboxable__", [])
assert len(domain) == 2, f"Expected 2 unsandboxable resources, got {len(domain)}"
@then("the cache should contain {count:d} entry")
def step_then_cache_size_singular(context: Any, count: int) -> None:
cache = context.boundary_cache or context.manager._boundary_cache
assert cache.size == count, f"Expected cache size {count}, got {cache.size}"
@then("the cache should contain {count:d} entries")
def step_then_cache_size(context: Any, count: int) -> None:
cache = context.boundary_cache or context.manager._boundary_cache
assert cache.size == count, f"Expected cache size {count}, got {cache.size}"
@then('the cached boundary for "{name}" should be "{boundary_name}"')
def step_then_cached_boundary(context: Any, name: str, boundary_name: str) -> None:
res = context.resources_by_name[name]
boundary = context.resources_by_name[boundary_name]
cache = context.boundary_cache
result = cache.get_boundary(res, context.resource_registry)
assert result is not None, f"Cached boundary for '{name}' is None"
assert result.resource_id == boundary.resource_id
@then("the cached boundary result should be None")
def step_then_cached_boundary_none(context: Any) -> None:
assert context.boundary_result is None
@then('the sandbox key should be "{expected_name}"')
def step_then_sandbox_key(context: Any, expected_name: str) -> None:
expected = context.resources_by_name[expected_name]
assert context.sandbox_key_result == expected.resource_id, (
f"Expected key {expected.resource_id}, got {context.sandbox_key_result}"
)
@then("both resources should share the same sandbox instance")
def step_then_same_sandbox_instance(context: Any) -> None:
assert len(context.boundary_sandbox_results) == 2
assert context.boundary_sandbox_results[0] is context.boundary_sandbox_results[1], (
"Expected both resources to share the same sandbox instance"
)
@then("a NoSandboxBoundaryError should be raised")
def step_then_no_boundary_error(context: Any) -> None:
assert context.error is not None, (
"Expected NoSandboxBoundaryError but no error was raised"
)
assert isinstance(context.error, NoSandboxBoundaryError), (
f"Expected NoSandboxBoundaryError, got {type(context.error).__name__}"
)
@then("the manager boundary cache should have {count:d} entry")
def step_then_manager_cache_singular(context: Any, count: int) -> None:
assert context.manager.boundary_cache_size == count, (
f"Expected {count} entry, got {context.manager.boundary_cache_size}"
)
@then("the manager boundary cache should have {count:d} entries")
def step_then_manager_cache(context: Any, count: int) -> None:
assert context.manager.boundary_cache_size == count, (
f"Expected {count} entries, got {context.manager.boundary_cache_size}"
)