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:
@@ -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"]})
|
||||
|
||||
Reference in New Issue
Block a user