[sandbox-ext] Phase 3: parameter surface — schema + project field + verb override + cache-by-features

Migration 072 adds projects.sandbox_extensions (jsonb null): a per-service
extension/module map a venture declares up front (e.g. {"postgres":
["vector","postgis"],"redis":["search"]}). Additive + nullable so
existing opted-in projects stay byte-for-byte bare — no default set, opters
set the extensions they need explicitly (TimescaleDB out unless asked).

Project model validates the map against SANDBOX_ENGINE_FEATURES: unknown
service keys and unallowed features are rejected at the model boundary with
the allowlist named (plpython3u — superuser-RCE — excluded by construction),
empty feature lists drop to bare, order normalized + deduped. The allowlist
is the security containment, not privilege. Mirrors sandbox_services: not on
ProjectCreate, only Project + ProjectUpdate.

request_sandbox gains an extensions arg; _sandbox_features_scope unions a
per-call override with the project's standing set (trusted), bounds it to the
opted set + allowlist, rejects a non-opted service or unallowed feature with
the allowlist named in remediate — scope-first priority preserved by
rej_scope or rej_features. ensure_sandbox threads features through to
provision(); cache-by-features: a cached entry satisfies a new call iff
services are a subset AND every requested feature per service is already
cached — a feature superset re-provisions (rotates creds), mirroring the
services-superset case. available_extensions rides the evidence payload so an
agent doesn't guess what was activated.

Gate: ruff clean, mypy clean (9 modules), 51 tests pass (incl. migration
round-trip).
This commit is contained in:
Renn F
2026-07-13 20:05:45 +02:00
committed by Renzo F
parent 3838d64eaa
commit e7d7311636
13 changed files with 580 additions and 32 deletions
@@ -0,0 +1,36 @@
"""Per-project sandbox extensions/modules opt-in column.
The parameterized sandbox (docs/internal/specs/2026-07-13-sandbox-extensions-
on-the-fly.md) lets a venture declare the extensions/modules its sandboxed dev
DB should activate (e.g. ``{"postgres": ["vector", "postgis"], "redis":
["search"]}``). The provisioner activates them post-ready via ``docker exec``.
Additive and nullable: an unset service gets no extensions (bare), so existing
opted-in projects stay byte-for-byte unchanged on the bare path. Feature names
are allowlist-validated by the Project pydantic model before reaching here
(``SANDBOX_ENGINE_FEATURES``), so a ``plpython3u`` can never be persisted.
Revision ID: 072_project_sandbox_extensions
Revises: 071_review_findings
Create Date: 2026-07-13
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "072_project_sandbox_extensions"
down_revision = "071_review_findings"
branch_labels: dict[str, str] | None = None
depends_on: dict[str, str] | None = None
def upgrade() -> None:
op.add_column(
"projects",
sa.Column("sandbox_extensions", sa.JSONB(), nullable=True),
)
def downgrade() -> None:
op.drop_column("projects", "sandbox_extensions")
+3 -1
View File
@@ -268,7 +268,9 @@ async def do_request_sandbox(
x_agent_id: _AgentIdHeader, x_agent_id: _AgentIdHeader,
actions: _ContentActionsDep, actions: _ContentActionsDep,
) -> dict: ) -> dict:
env = await actions.request_sandbox(agent_id=x_agent_id, services=body.services) env = await actions.request_sandbox(
agent_id=x_agent_id, services=body.services, extensions=body.extensions
)
return envelope_to_response(env, request) return envelope_to_response(env, request)
+3
View File
@@ -51,6 +51,7 @@ class ProjectResponse(BaseModel):
dep_update_command: str | None = None dep_update_command: str | None = None
dep_update_paths: list[str] | None = None dep_update_paths: list[str] | None = None
sandbox_services: list[str] | None = None sandbox_services: list[str] | None = None
sandbox_extensions: dict[str, list[str]] | None = None
# Runtime state # Runtime state
workspace_path: str | None = None workspace_path: str | None = None
@@ -151,6 +152,7 @@ class ProjectUpdateRequest(BaseModel):
dep_update_command: str | None = None dep_update_command: str | None = None
dep_update_paths: list[str] | None = None dep_update_paths: list[str] | None = None
sandbox_services: list[str] | None = None sandbox_services: list[str] | None = None
sandbox_extensions: dict[str, list[str]] | None = None
# State # State
is_active: bool | None = None is_active: bool | None = None
@@ -238,6 +240,7 @@ def project_to_response(project: "ProjectTable") -> ProjectResponse:
dep_update_command=project.dep_update_command, dep_update_command=project.dep_update_command,
dep_update_paths=project.dep_update_paths, dep_update_paths=project.dep_update_paths,
sandbox_services=project.sandbox_services, sandbox_services=project.sandbox_services,
sandbox_extensions=project.sandbox_extensions,
workspace_path=project.workspace_path, workspace_path=project.workspace_path,
last_synced_at=project.last_synced_at, last_synced_at=project.last_synced_at,
head_commit=project.head_commit, head_commit=project.head_commit,
+3 -1
View File
@@ -176,9 +176,11 @@ class EvidenceRequest(BaseModel):
class RequestSandboxRequest(BaseModel): class RequestSandboxRequest(BaseModel):
"""On-demand sandbox DB/Redis/Mongo. Omitted `services` = the project's """On-demand sandbox DB/Redis/Mongo. Omitted `services` = the project's
whole opted-in set.""" whole opted-in set. ``extensions`` (per-service extensions/modules) is an
additive per-call override, allowlist-validated."""
services: list[str] | None = None services: list[str] | None = None
extensions: dict[str, list[str]] | None = None
class ProgressRequest(BaseModel): class ProgressRequest(BaseModel):
+7
View File
@@ -543,6 +543,13 @@ class ProjectTable(Base):
sandbox_services: Mapped[list[str] | None] = mapped_column( sandbox_services: Mapped[list[str] | None] = mapped_column(
ARRAY(String), nullable=True ARRAY(String), nullable=True
) )
# Per-service extensions/modules the sandbox should activate post-ready
# (e.g. {"postgres": ["vector", "postgis"], "redis": ["search"]}). Null or a
# service absent = bare (no enable step). Feature names are allowlist-
# validated by the Project pydantic model (SANDBOX_ENGINE_FEATURES).
sandbox_extensions: Mapped[dict[str, Any] | None] = mapped_column(
JSONB, nullable=True
)
# Access Control # Access Control
assigned_cell: Mapped[Team] = mapped_column(_str_enum(Team), nullable=False) assigned_cell: Mapped[Team] = mapped_column(_str_enum(Team), nullable=False)
+16 -8
View File
@@ -726,22 +726,30 @@ def evidence(task_id: str) -> dict[str, Any]:
return _post("/api/v1/do/evidence", {"task_id": task_id}) return _post("/api/v1/do/evidence", {"task_id": task_id})
def request_sandbox(services: list[str] | None = None) -> dict[str, Any]: def request_sandbox(
services: list[str] | None = None,
extensions: dict[str, list[str]] | None = None,
) -> dict[str, Any]:
"""Provision (or reuse) a throwaway sandbox DB/Redis/Mongo for YOUR active task. """Provision (or reuse) a throwaway sandbox DB/Redis/Mongo for YOUR active task.
On-demand nothing is provisioned at spawn. Omit ``services`` to get the On-demand nothing is provisioned at spawn. Omit ``services`` to get the
project's whole opted-in set; requesting a service the project didn't opt project's whole opted-in set; requesting a service the project didn't opt
into is rejected with the allowed set named. Creds come back in into is rejected with the allowed set named. ``extensions`` (e.g.
``{"postgres": ["vector", "postgis"]}``) is an additive per-call override
unioned with the project's standing ``sandbox_extensions`` and bounded by
the opted set + the allowlist a name outside the allowlist (e.g.
``plpython3u``) is rejected with the allowed set named. Creds come back in
``evidence``, one entry per service: ``{host, port, user, password, ``evidence``, one entry per service: ``{host, port, user, password,
database, env: {ROBOCO_TEST_*: value}}`` export the ``env`` values database, env: {ROBOCO_TEST_*: value}, available_extensions?: [...]}``
verbatim for gate tooling that reads them. The whole opted-in set is export the ``env`` values verbatim for gate tooling that reads them. The
provisioned on first call, so calling this again for any subset or whole opted-in set is provisioned on first call, so calling this again for
superset of it is a cheap no-op (same creds, no re-provisioning); a any subset or superset of it is a cheap no-op (same creds, no
project that never opted into sandbox services will reject this. re-provisioning); a project that never opted into sandbox services will
reject this.
""" """
return _post( return _post(
"/api/v1/do/request_sandbox", "/api/v1/do/request_sandbox",
{"services": services}, {"services": services, "extensions": extensions},
timeout=_SANDBOX_TIMEOUT, timeout=_SANDBOX_TIMEOUT,
) )
+64 -1
View File
@@ -13,7 +13,11 @@ from uuid import UUID, uuid4
from pydantic import Field, field_validator from pydantic import Field, field_validator
from roboco.models.base import RobocoBase, Team, TimestampMixin from roboco.models.base import RobocoBase, Team, TimestampMixin
from roboco.models.sandbox import SANDBOX_ENGINES, VALID_SANDBOX_SERVICES from roboco.models.sandbox import (
SANDBOX_ENGINE_FEATURES,
SANDBOX_ENGINES,
VALID_SANDBOX_SERVICES,
)
class BranchReason(StrEnum): class BranchReason(StrEnum):
@@ -45,6 +49,38 @@ def _normalize_sandbox_services(value: list[str] | None) -> list[str] | None:
return [s for s in SANDBOX_ENGINES if s in value] return [s for s in SANDBOX_ENGINES if s in value]
def _normalize_sandbox_extensions(
value: dict[str, list[str]] | None,
) -> dict[str, list[str]] | None:
"""Allowlist-validate + normalize the per-service extension/module map.
Each key must be a valid sandbox service; each feature must be in that
service's allowlist (``SANDBOX_ENGINE_FEATURES``) — the security containment
that keeps a ``plpython3u`` (superuser-RCE) from ever being persisted. A
service with an empty feature list is dropped (bare == unset). Returns None
for an empty/None input so the column stays null for bare projects.
"""
if value is None:
return None
normalized: dict[str, list[str]] = {}
for svc, feats in value.items():
if svc not in SANDBOX_ENGINES:
raise ValueError(
f"sandbox_extensions key {svc!r} is not a valid service; valid: "
f"{sorted(VALID_SANDBOX_SERVICES)}"
)
allowed = SANDBOX_ENGINE_FEATURES.get(svc, frozenset())
bad = sorted(set(feats or []) - allowed)
if bad:
raise ValueError(
f"unallowed {svc} extension(s) {bad}; allowed: {sorted(allowed)}"
)
ordered = [f for f in sorted(allowed) if f in (feats or [])]
if ordered:
normalized[svc] = ordered
return normalized or None
class Project(TimestampMixin): class Project(TimestampMixin):
""" """
A git repository that agents work on. A git repository that agents work on.
@@ -159,6 +195,25 @@ class Project(TimestampMixin):
def _check_sandbox_services(cls, v: list[str] | None) -> list[str] | None: def _check_sandbox_services(cls, v: list[str] | None) -> list[str] | None:
return _normalize_sandbox_services(v) return _normalize_sandbox_services(v)
# Per-service sandbox extensions/modules to activate post-ready
# (e.g. {"postgres": ["vector", "postgis"]}); null/empty = bare. Allowlist-
# validated — a plpython3u (superuser-RCE) can never be set here.
sandbox_extensions: dict[str, list[str]] | None = Field(
default=None,
description=(
"Per-service extensions/modules the sandbox activates post-ready "
"(e.g. {'postgres': ['vector', 'postgis'], 'redis': ['search']}); "
"null/empty = bare. Allowlist-validated."
),
)
@field_validator("sandbox_extensions")
@classmethod
def _check_sandbox_extensions(
cls, v: dict[str, list[str]] | None
) -> dict[str, list[str]] | None:
return _normalize_sandbox_extensions(v)
# Metadata # Metadata
created_by: UUID = Field(..., description="PM who registered the project") created_by: UUID = Field(..., description="PM who registered the project")
is_active: bool = Field(default=True, description="Whether project is active") is_active: bool = Field(default=True, description="Whether project is active")
@@ -218,8 +273,16 @@ class ProjectUpdate(RobocoBase):
dep_update_command: str | None = None dep_update_command: str | None = None
dep_update_paths: list[str] | None = None dep_update_paths: list[str] | None = None
sandbox_services: list[str] | None = None sandbox_services: list[str] | None = None
sandbox_extensions: dict[str, list[str]] | None = None
@field_validator("sandbox_services") @field_validator("sandbox_services")
@classmethod @classmethod
def _check_sandbox_services(cls, v: list[str] | None) -> list[str] | None: def _check_sandbox_services(cls, v: list[str] | None) -> list[str] | None:
return _normalize_sandbox_services(v) return _normalize_sandbox_services(v)
@field_validator("sandbox_extensions")
@classmethod
def _check_sandbox_extensions(
cls, v: dict[str, list[str]] | None
) -> dict[str, list[str]] | None:
return _normalize_sandbox_extensions(v)
+23 -3
View File
@@ -2438,7 +2438,11 @@ class AgentOrchestrator:
return list(project.sandbox_services or []) if project else [] return list(project.sandbox_services or []) if project else []
async def ensure_sandbox( async def ensure_sandbox(
self, agent_slug: str, requested: list[str], opted: list[str] self,
agent_slug: str,
requested: list[str],
opted: list[str],
features: dict[str, list[str]] | None = None,
) -> SandboxInfo: ) -> SandboxInfo:
"""Idempotent on-demand provision, called by the `request_sandbox` verb. """Idempotent on-demand provision, called by the `request_sandbox` verb.
@@ -2453,6 +2457,13 @@ class AgentOrchestrator:
(rather than trusting the caller to always pass the full set) is (rather than trusting the caller to always pass the full set) is
belt-and-suspenders bounded by the project's own opt-in either way. belt-and-suspenders bounded by the project's own opt-in either way.
``features`` (per-service extensions/modules) is the union the verb
already computed (project standing union per-call, bounded by the opted
set + the allowlist). The cache-hit check extends to it: a cached
entry satisfies a new call iff the services are a subset AND every
requested feature per service is already cached a feature superset
re-provisions (rotates creds), mirroring the services-superset case.
A cache hit is verified live (`SandboxProvisioner.is_live`) before A cache hit is verified live (`SandboxProvisioner.is_live`) before
being trusted: a container OOM-killed or removed out-of-band evicts being trusted: a container OOM-killed or removed out-of-band evicts
the stale entry and falls through to a fresh full-set provision the stale entry and falls through to a fresh full-set provision
@@ -2463,6 +2474,7 @@ class AgentOrchestrator:
race provision()/teardown() on the same containers. race provision()/teardown() on the same containers.
""" """
full = sorted(set(requested) | set(opted)) full = sorted(set(requested) | set(opted))
feat_map = features or {}
# Lazily-allocated (no __init__ statement) to keep AgentOrchestrator's # Lazily-allocated (no __init__ statement) to keep AgentOrchestrator's
# constructor under the statement-count gate; getattr guards bare # constructor under the statement-count gate; getattr guards bare
# __new__() test doubles that never ran __init__ — same convention # __new__() test doubles that never ran __init__ — same convention
@@ -2475,10 +2487,18 @@ class AgentOrchestrator:
async with lock: async with lock:
cached = self._sandbox_info.get(agent_slug) cached = self._sandbox_info.get(agent_slug)
if cached is not None and set(full) <= set(cached.services): if cached is not None and set(full) <= set(cached.services):
if await self._sandbox.is_live(agent_slug, sorted(cached.services)): features_covered = all(
set(feat_map.get(svc, [])) <= set(cached.services[svc].features)
for svc in full
)
if features_covered and await self._sandbox.is_live(
agent_slug, sorted(cached.services)
):
return cached return cached
self._sandbox_info.pop(agent_slug, None) self._sandbox_info.pop(agent_slug, None)
info = await self._sandbox.provision(agent_slug, full) info = await self._sandbox.provision(
agent_slug, full, features=feat_map or None
)
self._sandbox_info[agent_slug] = info self._sandbox_info[agent_slug] = info
return info return info
+78 -11
View File
@@ -1985,29 +1985,90 @@ class ContentActions:
) )
return requested, None return requested, None
@staticmethod
def _sandbox_features_scope(
project: Any,
extensions: dict[str, list[str]] | None,
opted: frozenset[str],
) -> tuple[dict[str, list[str]], Envelope | None]:
"""request_sandbox's extension guard: the per-service feature map to
activate (project standing union per-call, bounded by the opted set +
the allowlist), or a clean invalid_state rejection.
Per-call ``extensions`` is allowlist-validated HERE (not only at the
provisioner) so a ``plpython3u`` gets a remediate naming the allowed
set, mirroring the unknown-service remediate. The project's standing
``sandbox_extensions`` was allowlist-validated at write time, so it is
trusted and unioned in; entries for a service no longer opted into are
dropped (a venture may deactivate a service without clearing its
standing extensions). Returns only services with a non-empty feature
list a service with no features is bare (the provisioner's default).
"""
from roboco.models.sandbox import SANDBOX_ENGINE_FEATURES
standing = (project.sandbox_extensions if project else None) or {}
# Union per service: standing (trusted) + per-call (validated below).
merged: dict[str, set[str]] = {}
for svc, feats in standing.items():
if svc in opted:
merged.setdefault(svc, set()).update(feats or [])
for svc, feats in (extensions or {}).items():
if svc not in opted:
return {}, Envelope.invalid_state(
message=(
f"extensions given for {svc!r}, which this project has "
f"not opted into"
),
remediate=(
f"this project's opted-in set is {sorted(opted)}"
"request extensions only for those services"
),
context_briefing={},
)
allowed = SANDBOX_ENGINE_FEATURES.get(svc, frozenset())
bad = sorted(set(feats or []) - allowed)
if bad:
return {}, Envelope.invalid_state(
message=f"unallowed {svc} extension(s) {bad}",
remediate=(
f"the allowlist for {svc} is {sorted(allowed)}"
"request a subset; plpython3u and other superuser-"
"language extensions are excluded by construction"
),
context_briefing={},
)
merged.setdefault(svc, set()).update(feats or [])
return {svc: sorted(f) for svc, f in merged.items() if f}, None
async def request_sandbox( async def request_sandbox(
self, self,
*, *,
agent_id: UUID, agent_id: UUID,
services: list[str] | None = None, services: list[str] | None = None,
extensions: dict[str, list[str]] | None = None,
) -> Envelope: ) -> Envelope:
"""On-demand sandbox DB/Redis/Mongo (dev + QA only, see role_config). """On-demand sandbox DB/Redis/Mongo (dev + QA only, see role_config).
Replaces eager per-spawn provisioning: a sandbox is created only when Replaces eager per-spawn provisioning: a sandbox is created only when
an agent actually asks for one, keyed off the CALLER's authenticated an agent actually asks for one, keyed off the CALLER's authenticated
slug (never another agent's). ``services`` omitted means the slug (never another agent's). ``services`` omitted means the
project's whole opted-in set. project's whole opted-in set. ``extensions`` (per-service
extensions/modules, e.g. ``{"postgres": ["vector"]}``) is an additive
per-call override unioned with the project's standing
``sandbox_extensions`` and bounded by the opted set + the allowlist
a ``plpython3u`` is rejected here with the allowed set named.
Guards, in order: flag off; caller has no claimed/active, Guards, in order: flag off; caller has no claimed/active,
project-bound task (`_sandbox_active_task`); project not opted into project-bound task (`_sandbox_active_task`); project not opted into
any sandbox service, or a requested service outside its opted set any sandbox service, or a requested service outside its opted set
(`_sandbox_scope`, names the allowed set); orchestrator handle (`_sandbox_scope`, names the allowed set); per-call extensions for a
unavailable (retryable). `ensure_sandbox` always provisions the non-opted service or outside the allowlist (`_sandbox_features_scope`,
project's whole opted-in set regardless of ``services`` (so a later names the allowed set); orchestrator handle unavailable (retryable).
call can never trigger a mid-session teardown of a live container); `ensure_sandbox` always provisions the project's whole opted-in set
the evidence payload here is filtered back down to what THIS call regardless of ``services`` (so a later call can never trigger a
asked for. Creds come back in the evidence payload, never as mid-session teardown of a live container); the evidence payload here
injected env see is filtered back down to what THIS call asked for. Creds come back in
the evidence payload, never as injected env see
``docs/internal/specs/2026-07-08-sandbox-on-demand.md`` §4. ``docs/internal/specs/2026-07-08-sandbox-on-demand.md`` §4.
""" """
if not settings.sandbox_db_enabled: if not settings.sandbox_db_enabled:
@@ -2026,10 +2087,16 @@ class ContentActions:
from roboco.services.project import get_project_service from roboco.services.project import get_project_service
project = await get_project_service(self.task.session).get(t.project_id) project = await get_project_service(self.task.session).get(t.project_id)
requested, rejection = self._sandbox_scope(project, services) requested, rej_scope = self._sandbox_scope(project, services)
opted = frozenset(project.sandbox_services or []) if project else frozenset()
features, rej_features = self._sandbox_features_scope(
project, extensions, opted
)
# Scope before features: an unknown-service rejection wins over a
# per-call extension rejection for the same call.
rejection = rej_scope or rej_features
if rejection is not None: if rejection is not None:
return rejection return rejection
opted = frozenset(project.sandbox_services or []) if project else frozenset()
if self.orchestrator is None: if self.orchestrator is None:
return Envelope.invalid_state( return Envelope.invalid_state(
message="orchestrator handle unavailable — cannot provision a sandbox", message="orchestrator handle unavailable — cannot provision a sandbox",
@@ -2044,7 +2111,7 @@ class ContentActions:
agent_slug = _resolve_to_slug(str(agent_id)) agent_slug = _resolve_to_slug(str(agent_id))
try: try:
info = await self.orchestrator.ensure_sandbox( info = await self.orchestrator.ensure_sandbox(
agent_slug, sorted(requested), sorted(opted) agent_slug, sorted(requested), sorted(opted), features=features or None
) )
except SandboxProvisionError as e: except SandboxProvisionError as e:
return Envelope.invalid_state( return Envelope.invalid_state(
@@ -0,0 +1,77 @@
"""Per-project sandbox extensions opt-in column (migration 072).
Migration 072 adds ``projects.sandbox_extensions`` (jsonb null). The real
upgrade/downgrade chain is verified separately against a throwaway Postgres;
these assertions guard the resulting schema shape and a value round-trip.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from uuid import uuid4
import pytest
from roboco.db.tables import AgentTable, ProjectTable
from roboco.models import AgentRole, AgentStatus, Team
from sqlalchemy import select
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
async def _seed_project(db_session: AsyncSession) -> ProjectTable:
agent = AgentTable(
id=uuid4(),
name="Dev",
slug=f"be-dev-{uuid4().hex[:8]}",
role=AgentRole.DEVELOPER,
team=Team.BACKEND,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="dev",
capabilities=[],
permissions={},
metrics={},
)
db_session.add(agent)
await db_session.flush()
project = ProjectTable(
id=uuid4(),
name="B-Proj",
slug=f"b-proj-{uuid4().hex[:8]}",
git_url="https://example.com/r.git",
assigned_cell=Team.BACKEND,
created_by=agent.id,
)
db_session.add(project)
await db_session.flush()
return project
@pytest.mark.asyncio
async def test_sandbox_extensions_column_default_null(
db_session: AsyncSession,
) -> None:
project = await _seed_project(db_session)
assert project.sandbox_extensions is None
@pytest.mark.asyncio
async def test_sandbox_extensions_column_round_trip(
db_session: AsyncSession,
) -> None:
project = await _seed_project(db_session)
project.sandbox_extensions = {
"postgres": ["vector", "postgis"],
"redis": ["search"],
}
await db_session.flush()
row = (
await db_session.execute(
select(ProjectTable).where(ProjectTable.id == project.id)
)
).scalar_one()
assert row.sandbox_extensions == {
"postgres": ["vector", "postgis"],
"redis": ["search"],
}
+137 -3
View File
@@ -46,8 +46,12 @@ def _task(project_id: object | None = uuid4()) -> MagicMock:
return t return t
def _stub_project(monkeypatch: pytest.MonkeyPatch, services: list[str] | None) -> None: def _stub_project(
project = MagicMock(sandbox_services=services) monkeypatch: pytest.MonkeyPatch,
services: list[str] | None,
extensions: dict[str, list[str]] | None = None,
) -> None:
project = MagicMock(sandbox_services=services, sandbox_extensions=extensions)
project_service = MagicMock() project_service = MagicMock()
project_service.get = AsyncMock(return_value=project) project_service.get = AsyncMock(return_value=project)
monkeypatch.setattr( monkeypatch.setattr(
@@ -55,7 +59,9 @@ def _stub_project(monkeypatch: pytest.MonkeyPatch, services: list[str] | None) -
) )
def _sandbox_info() -> SandboxInfo: def _sandbox_info(
features: tuple[str, ...] = (),
) -> SandboxInfo:
return SandboxInfo( return SandboxInfo(
services={ services={
"postgres": SandboxConnection( "postgres": SandboxConnection(
@@ -64,6 +70,7 @@ def _sandbox_info() -> SandboxInfo:
password="pw", password="pw",
user="sandbox", user="sandbox",
database="sandbox", database="sandbox",
features=features,
) )
} }
) )
@@ -287,3 +294,130 @@ async def test_ensure_sandbox_keyed_off_caller_own_slug(
assert slugs_called[0] != slugs_called[1] assert slugs_called[0] != slugs_called[1]
assert slugs_called[0] == str(agent_a) assert slugs_called[0] == str(agent_a)
assert slugs_called[1] == str(agent_b) assert slugs_called[1] == str(agent_b)
# ---------------------------------------------------------------------------
# Extensions — per-service additive override, allowlist-guarded
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_extensions_additive_unioned_with_project_standing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Per-call extensions union with the project's standing set (bounded by
the opted set + allowlist) and reach ensure_sandbox as the features kwarg."""
monkeypatch.setattr(settings, "sandbox_db_enabled", True)
_stub_project(
monkeypatch,
services=["postgres"],
extensions={"postgres": ["vector"]},
)
orch = AsyncMock()
orch.ensure_sandbox.return_value = _sandbox_info()
actions, _task_svc = _make_actions(task_obj=_task(), orchestrator=orch)
await actions.request_sandbox(
agent_id=uuid4(), extensions={"postgres": ["postgis"]}
)
features = orch.ensure_sandbox.call_args.kwargs["features"]
assert features == {"postgres": ["postgis", "vector"]}
@pytest.mark.asyncio
async def test_standing_extensions_passed_with_no_per_call(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "sandbox_db_enabled", True)
_stub_project(
monkeypatch,
services=["postgres"],
extensions={"postgres": ["vector"]},
)
orch = AsyncMock()
orch.ensure_sandbox.return_value = _sandbox_info()
actions, _task_svc = _make_actions(task_obj=_task(), orchestrator=orch)
await actions.request_sandbox(agent_id=uuid4())
assert orch.ensure_sandbox.call_args.kwargs["features"] == {"postgres": ["vector"]}
@pytest.mark.asyncio
async def test_no_extensions_passes_none_features(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Bare call (no standing, no per-call) → features=None (bare provision)."""
monkeypatch.setattr(settings, "sandbox_db_enabled", True)
_stub_project(monkeypatch, services=["postgres"])
orch = AsyncMock()
orch.ensure_sandbox.return_value = _sandbox_info()
actions, _task_svc = _make_actions(task_obj=_task(), orchestrator=orch)
await actions.request_sandbox(agent_id=uuid4())
assert orch.ensure_sandbox.call_args.kwargs["features"] is None
@pytest.mark.asyncio
async def test_extensions_rejects_plpython_names_allowlist(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""plpython3u is rejected at the verb with the allowlist named in remediate
(not only at the provisioner), mirroring the unknown-service remediate."""
monkeypatch.setattr(settings, "sandbox_db_enabled", True)
_stub_project(monkeypatch, services=["postgres"])
orch = AsyncMock()
orch.ensure_sandbox.return_value = _sandbox_info()
actions, _task_svc = _make_actions(task_obj=_task(), orchestrator=orch)
env = await actions.request_sandbox(
agent_id=uuid4(), extensions={"postgres": ["plpython3u"]}
)
assert env.error == "invalid_state"
remediate = env.remediate or ""
assert "vector" in remediate # the allowlist is named
orch.ensure_sandbox.assert_not_awaited()
@pytest.mark.asyncio
async def test_extensions_for_non_opted_service_rejected(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "sandbox_db_enabled", True)
_stub_project(monkeypatch, services=["postgres"])
orch = AsyncMock()
orch.ensure_sandbox.return_value = _sandbox_info()
actions, _task_svc = _make_actions(task_obj=_task(), orchestrator=orch)
env = await actions.request_sandbox(
agent_id=uuid4(), extensions={"redis": ["search"]}
)
assert env.error == "invalid_state"
orch.ensure_sandbox.assert_not_awaited()
@pytest.mark.asyncio
async def test_available_extensions_surfaced_in_evidence(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The evidence payload carries available_extensions so the agent doesn't
guess what was activated."""
monkeypatch.setattr(settings, "sandbox_db_enabled", True)
_stub_project(
monkeypatch,
services=["postgres"],
extensions={"postgres": ["vector", "postgis"]},
)
orch = AsyncMock()
orch.ensure_sandbox.return_value = _sandbox_info(features=("postgis", "vector"))
actions, _task_svc = _make_actions(task_obj=_task(), orchestrator=orch)
env = await actions.request_sandbox(agent_id=uuid4())
assert env.error is None
assert env.evidence is not None
assert env.evidence["postgres"]["available_extensions"] == ["postgis", "vector"]
@@ -16,7 +16,10 @@ from roboco.models.base import Team
from roboco.models.project import Project, ProjectUpdate from roboco.models.project import Project, ProjectUpdate
def _project(sandbox_services: list[str] | None = None) -> Project: def _project(
sandbox_services: list[str] | None = None,
sandbox_extensions: dict[str, list[str]] | None = None,
) -> Project:
return Project( return Project(
name="P", name="P",
slug="p", slug="p",
@@ -24,6 +27,7 @@ def _project(sandbox_services: list[str] | None = None) -> Project:
assigned_cell=Team.BACKEND, assigned_cell=Team.BACKEND,
created_by=uuid4(), created_by=uuid4(),
sandbox_services=sandbox_services, sandbox_services=sandbox_services,
sandbox_extensions=sandbox_extensions,
) )
@@ -65,3 +69,63 @@ def test_project_update_rejects_unknown_sandbox_service() -> None:
def test_project_update_accepts_empty_list() -> None: def test_project_update_accepts_empty_list() -> None:
update = ProjectUpdate(sandbox_services=[]) update = ProjectUpdate(sandbox_services=[])
assert update.sandbox_services == [] assert update.sandbox_services == []
# ---------------------------------------------------------------------------
# sandbox_extensions — per-service allowlist-validated extension/module map.
# The allowlist is the security containment: a plpython3u (superuser-RCE) must
# be rejected at the model boundary, never persisted.
# ---------------------------------------------------------------------------
def test_project_accepts_valid_sandbox_extensions() -> None:
project = _project(sandbox_extensions={"postgres": ["vector", "postgis"]})
assert project.sandbox_extensions == {"postgres": ["postgis", "vector"]}
def test_project_sandbox_extensions_normalizes_order_and_dedupes() -> None:
project = _project(
sandbox_extensions={"postgres": ["postgis", "vector", "postgis"]}
)
assert project.sandbox_extensions == {"postgres": ["postgis", "vector"]}
def test_project_sandbox_extensions_defaults_to_none() -> None:
assert _project().sandbox_extensions is None
def test_project_sandbox_extensions_rejects_plpython() -> None:
"""plpython3u is a superuser-RCE vector — the allowlist rejects it."""
with pytest.raises(ValidationError):
_project(sandbox_extensions={"postgres": ["plpython3u"]})
def test_project_sandbox_extensions_rejects_unallowed_redis_module() -> None:
with pytest.raises(ValidationError):
_project(sandbox_extensions={"redis": ["not_a_module"]})
def test_project_sandbox_extensions_rejects_feature_for_unknown_service() -> None:
with pytest.raises(ValidationError):
_project(sandbox_extensions={"mysql": ["vector"]})
def test_project_sandbox_extensions_drops_empty_feature_list() -> None:
"""A service with an empty feature list is bare — dropped, not stored."""
project = _project(sandbox_extensions={"postgres": []})
assert project.sandbox_extensions is None
def test_project_sandbox_extensions_drops_bare_keeps_others() -> None:
project = _project(sandbox_extensions={"postgres": [], "redis": ["search"]})
assert project.sandbox_extensions == {"redis": ["search"]}
def test_project_update_accepts_valid_sandbox_extensions() -> None:
update = ProjectUpdate(sandbox_extensions={"redis": ["json", "bloom"]})
assert update.sandbox_extensions == {"redis": ["bloom", "json"]}
def test_project_update_rejects_plpython() -> None:
with pytest.raises(ValidationError):
ProjectUpdate(sandbox_extensions={"postgres": ["plpython3u"]})
@@ -159,7 +159,7 @@ async def test_ensure_sandbox_miss_provisions_and_caches() -> None:
result = await orch.ensure_sandbox("dev-1", ["postgres"], ["postgres"]) result = await orch.ensure_sandbox("dev-1", ["postgres"], ["postgres"])
assert result is info assert result is info
sandbox.provision.assert_awaited_once_with("dev-1", ["postgres"]) sandbox.provision.assert_awaited_once_with("dev-1", ["postgres"], features=None)
assert orch._sandbox_info["dev-1"] is info assert orch._sandbox_info["dev-1"] is info
@@ -198,7 +198,9 @@ async def test_ensure_sandbox_first_subset_request_provisions_full_opted_set() -
) )
assert first is second is info assert first is second is info
sandbox.provision.assert_awaited_once_with("dev-1", ["postgres", "redis"]) sandbox.provision.assert_awaited_once_with(
"dev-1", ["postgres", "redis"], features=None
)
assert orch._sandbox_info["dev-1"] is info assert orch._sandbox_info["dev-1"] is info
@@ -229,7 +231,9 @@ async def test_ensure_sandbox_concurrent_calls_serialize_on_agent_lock() -> None
info = _info({"postgres": SandboxConnection(host="h", port=5432, password="pw")}) info = _info({"postgres": SandboxConnection(host="h", port=5432, password="pw")})
calls = 0 calls = 0
async def _slow_provision(_agent_id: str, _services: list[str]) -> SandboxInfo: async def _slow_provision(
_agent_id: str, _services: list[str], **_kw: object
) -> SandboxInfo:
nonlocal calls nonlocal calls
calls += 1 calls += 1
await asyncio.sleep(0.05) await asyncio.sleep(0.05)
@@ -270,3 +274,64 @@ async def test_ensure_sandbox_cache_hit_with_dead_container_reprovisions() -> No
assert sandbox.provision.await_count == expected_provision_calls assert sandbox.provision.await_count == expected_provision_calls
assert orch._sandbox_info["dev-1"] is fresh_info assert orch._sandbox_info["dev-1"] is fresh_info
sandbox.is_live.assert_awaited_once_with("dev-1", ["postgres"]) sandbox.is_live.assert_awaited_once_with("dev-1", ["postgres"])
# ---------------------------------------------------------------------------
# Cache-by-features: a cached entry satisfies a new call iff the services are
# a subset AND every requested feature per service is already cached. A feature
# superset re-provisions (rotates creds), mirroring the services-superset case.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_ensure_sandbox_features_subset_is_cache_hit() -> None:
orch, sandbox = _make_orchestrator()
info = SandboxInfo(
services={
"postgres": SandboxConnection(
host="h", port=5432, password="pw", features=("postgis", "vector")
)
}
)
sandbox.provision.return_value = info
first = await orch.ensure_sandbox(
"dev-1",
["postgres"],
["postgres"],
features={"postgres": ["postgis", "vector"]},
)
second = await orch.ensure_sandbox(
"dev-1", ["postgres"], ["postgres"], features={"postgres": ["vector"]}
)
assert first is second is info
sandbox.provision.assert_awaited_once()
@pytest.mark.asyncio
async def test_ensure_sandbox_features_superset_reprovisions() -> None:
orch, sandbox = _make_orchestrator()
info = SandboxInfo(
services={
"postgres": SandboxConnection(
host="h", port=5432, password="pw", features=("vector",)
)
}
)
sandbox.provision.return_value = info
await orch.ensure_sandbox(
"dev-1", ["postgres"], ["postgres"], features={"postgres": ["vector"]}
)
await orch.ensure_sandbox(
"dev-1",
["postgres"],
["postgres"],
features={"postgres": ["postgis", "vector"]},
)
expected_provision_calls = 2
assert sandbox.provision.await_count == expected_provision_calls
second_features = sandbox.provision.call_args_list[1].kwargs["features"]
assert second_features == {"postgres": ["postgis", "vector"]}