mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
[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:
@@ -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")
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"],
|
||||
}
|
||||
@@ -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"]}
|
||||
|
||||
Reference in New Issue
Block a user