[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,
actions: _ContentActionsDep,
) -> 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)
+3
View File
@@ -51,6 +51,7 @@ class ProjectResponse(BaseModel):
dep_update_command: str | None = None
dep_update_paths: list[str] | None = None
sandbox_services: list[str] | None = None
sandbox_extensions: dict[str, list[str]] | None = None
# Runtime state
workspace_path: str | None = None
@@ -151,6 +152,7 @@ class ProjectUpdateRequest(BaseModel):
dep_update_command: str | None = None
dep_update_paths: list[str] | None = None
sandbox_services: list[str] | None = None
sandbox_extensions: dict[str, list[str]] | None = None
# State
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_paths=project.dep_update_paths,
sandbox_services=project.sandbox_services,
sandbox_extensions=project.sandbox_extensions,
workspace_path=project.workspace_path,
last_synced_at=project.last_synced_at,
head_commit=project.head_commit,
+3 -1
View File
@@ -176,9 +176,11 @@ class EvidenceRequest(BaseModel):
class RequestSandboxRequest(BaseModel):
"""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
extensions: dict[str, list[str]] | None = None
class ProgressRequest(BaseModel):
+7
View File
@@ -543,6 +543,13 @@ class ProjectTable(Base):
sandbox_services: Mapped[list[str] | None] = mapped_column(
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
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})
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.
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
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,
database, env: {ROBOCO_TEST_*: value}}`` — export the ``env`` values
verbatim for gate tooling that reads them. The whole opted-in set is
provisioned on first call, so calling this again for any subset or
superset of it is a cheap no-op (same creds, no re-provisioning); a
project that never opted into sandbox services will reject this.
database, env: {ROBOCO_TEST_*: value}, available_extensions?: [...]}`` —
export the ``env`` values verbatim for gate tooling that reads them. The
whole opted-in set is provisioned on first call, so calling this again for
any subset or superset of it is a cheap no-op (same creds, no
re-provisioning); a project that never opted into sandbox services will
reject this.
"""
return _post(
"/api/v1/do/request_sandbox",
{"services": services},
{"services": services, "extensions": extensions},
timeout=_SANDBOX_TIMEOUT,
)
+64 -1
View File
@@ -13,7 +13,11 @@ from uuid import UUID, uuid4
from pydantic import Field, field_validator
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):
@@ -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]
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):
"""
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:
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
created_by: UUID = Field(..., description="PM who registered the project")
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_paths: list[str] | None = None
sandbox_services: list[str] | None = None
sandbox_extensions: dict[str, list[str]] | None = None
@field_validator("sandbox_services")
@classmethod
def _check_sandbox_services(cls, v: list[str] | None) -> list[str] | None:
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 []
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:
"""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
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
being trusted: a container OOM-killed or removed out-of-band evicts
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.
"""
full = sorted(set(requested) | set(opted))
feat_map = features or {}
# Lazily-allocated (no __init__ statement) to keep AgentOrchestrator's
# constructor under the statement-count gate; getattr guards bare
# __new__() test doubles that never ran __init__ — same convention
@@ -2475,10 +2487,18 @@ class AgentOrchestrator:
async with lock:
cached = self._sandbox_info.get(agent_slug)
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
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
return info
+78 -11
View File
@@ -1985,29 +1985,90 @@ class ContentActions:
)
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(
self,
*,
agent_id: UUID,
services: list[str] | None = None,
extensions: dict[str, list[str]] | None = None,
) -> Envelope:
"""On-demand sandbox DB/Redis/Mongo (dev + QA only, see role_config).
Replaces eager per-spawn provisioning: a sandbox is created only when
an agent actually asks for one, keyed off the CALLER's authenticated
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,
project-bound task (`_sandbox_active_task`); project not opted into
any sandbox service, or a requested service outside its opted set
(`_sandbox_scope`, names the allowed set); orchestrator handle
unavailable (retryable). `ensure_sandbox` always provisions the
project's whole opted-in set regardless of ``services`` (so a later
call can never trigger a mid-session teardown of a live container);
the evidence payload here is filtered back down to what THIS call
asked for. Creds come back in the evidence payload, never as
injected env — see
(`_sandbox_scope`, names the allowed set); per-call extensions for a
non-opted service or outside the allowlist (`_sandbox_features_scope`,
names the allowed set); orchestrator handle unavailable (retryable).
`ensure_sandbox` always provisions the project's whole opted-in set
regardless of ``services`` (so a later call can never trigger a
mid-session teardown of a live container); the evidence payload here
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.
"""
if not settings.sandbox_db_enabled:
@@ -2026,10 +2087,16 @@ class ContentActions:
from roboco.services.project import get_project_service
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:
return rejection
opted = frozenset(project.sandbox_services or []) if project else frozenset()
if self.orchestrator is None:
return Envelope.invalid_state(
message="orchestrator handle unavailable — cannot provision a sandbox",
@@ -2044,7 +2111,7 @@ class ContentActions:
agent_slug = _resolve_to_slug(str(agent_id))
try:
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:
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
def _stub_project(monkeypatch: pytest.MonkeyPatch, services: list[str] | None) -> None:
project = MagicMock(sandbox_services=services)
def _stub_project(
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.get = AsyncMock(return_value=project)
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(
services={
"postgres": SandboxConnection(
@@ -64,6 +70,7 @@ def _sandbox_info() -> SandboxInfo:
password="pw",
user="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] == str(agent_a)
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
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(
name="P",
slug="p",
@@ -24,6 +27,7 @@ def _project(sandbox_services: list[str] | None = None) -> Project:
assigned_cell=Team.BACKEND,
created_by=uuid4(),
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:
update = ProjectUpdate(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"])
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
@@ -198,7 +198,9 @@ async def test_ensure_sandbox_first_subset_request_provisions_full_opted_set() -
)
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
@@ -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")})
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
calls += 1
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 orch._sandbox_info["dev-1"] is fresh_info
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"]}