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,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