sandbox: post-ready extension/module activation + allowlist (Phase 1)

Parameterized sandbox dev DBs — groundwork for 'extensions on the fly'
(docs/internal/specs/2026-07-13-sandbox-extensions-on-the-fly.md). A
venture declares the extensions/modules it needs; the provisioner activates
them post-ready via docker exec, never via bind-mounts or initdb scripts.

Phase 1 (behavior-preserving scaffolding — no image, no schema, no caller
passes features yet):

- Allowlists SANDBOX_PG_EXTENSIONS / SANDBOX_REDIS_MODULES are the ONLY
  extensions/modules the system will ever activate — the security
  containment, not privilege. plpython3u & co. (superuser-RCE vectors)
  are excluded by construction.
- SandboxEngine ABC gains enable_step / verify_step / verify_ok. pg:
  CREATE EXTENSION IF NOT EXISTS via psql, verified by a pg_extension
  count. redis: MODULE LOAD per module, verified by MODULE LIST. mongo:
  no-op (server is batteries-included).
- SandboxProvisioner.provision takes features={service: [names]},
  allowlist-validates before any container runs, runs enable then verify
  after the base readiness probe; a failed enable or a short verify (image
  missing the extension files) is fatal — an agent never receives creds
  for a db missing what it asked for. Empty features = bare = the
  existing path, byte-for-byte unchanged.
- SandboxConnection gains features; as_payload surfaces
  available_extensions / available_modules so the agent doesn't guess.

13 new unit tests (fake docker runner): enable/verify argv per engine,
allowlist rejection of plpython3u before any run, failed-enable + failed-
verify fatality, bare-provision unchanged, payload surfacing.
This commit is contained in:
Renn F
2026-07-13 20:05:45 +02:00
committed by Renzo F
parent a3524da5f8
commit b015cde9ad
3 changed files with 444 additions and 16 deletions
+183 -10
View File
@@ -5,6 +5,13 @@ registry and runs the containers. Lives in the models layer so
``roboco/models/project.py`` can derive the valid-service allowlist from it
without importing the runtime layer (no cycle). Adding an engine = one class +
one registry line — no branch to edit in the provisioner or the env emitter.
Extensions/modules are activated *post-ready* by a ``docker exec`` enable step
(the provisioner runs it after the base readiness probe passes), never via
bind-mounts or initdb scripts. The allowlists below are the *only* extensions /
modules the system will ever activate — they are the security containment (an
agent must not be able to ``CREATE EXTENSION plpython3u``, a superuser-RCE
vector). See ``docs/internal/specs/2026-07-13-sandbox-extensions-on-the-fly.md``.
"""
from __future__ import annotations
@@ -13,6 +20,35 @@ from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any
# Per-family allowlists — the ONLY extensions/modules a sandbox may activate.
# Adding one = add it here + ship it in the kitchen-sink image. An untrusted /
# superuser-language extension (plpython3u, plperlu, …) must NEVER appear here.
SANDBOX_PG_EXTENSIONS: frozenset[str] = frozenset(
{"vector", "postgis", "pg_trgm", "citext", "uuid-ossp"}
)
SANDBOX_REDIS_MODULES: frozenset[str] = frozenset({"search", "json", "bloom"})
# Friendly module key -> the .so path inside the redis-stack image.
SANDBOX_REDIS_MODULE_SO: dict[str, str] = {
"search": "/opt/redis-stack/lib/redisearch.so",
"json": "/opt/redis-stack/lib/rejson.so",
"bloom": "/opt/redis-stack/lib/redisbloom.so",
}
# Friendly module key -> the name redis reports in MODULE LIST (for verification).
SANDBOX_REDIS_MODULE_NAME: dict[str, str] = {
"search": "search",
"json": "ReJSON",
"bloom": "bf",
}
# The allowed features per engine family, for the provisioner's allowlist guard
# and for project-field validation. An engine with no activatable features omits
# itself from the map (mongo).
SANDBOX_ENGINE_FEATURES: dict[str, frozenset[str]] = {
"postgres": SANDBOX_PG_EXTENSIONS,
"redis": SANDBOX_REDIS_MODULES,
}
@dataclass(frozen=True)
class SandboxConnection:
@@ -20,7 +56,8 @@ class SandboxConnection:
``user`` / ``database`` are ``None`` for engines that don't expose them
(redis). For postgres ``database`` is the admin db; for mongo it is the
auth db (``admin``).
auth db (``admin``). ``features`` records the extensions/modules activated
in this container — for the cache-subset check and the evidence payload.
"""
host: str
@@ -28,6 +65,7 @@ class SandboxConnection:
password: str
user: str | None = None
database: str | None = None
features: tuple[str, ...] = ()
@dataclass(frozen=True)
@@ -51,12 +89,14 @@ class SandboxInfo:
Same variable names as ``emit_env`` (docs/env parity) but keyed for
direct agent consumption (JSON) rather than ``docker run -e`` args.
``available_extensions``/``available_modules`` surfaces what was
activated so the agent doesn't guess.
"""
out: dict[str, dict[str, Any]] = {}
for name, conn in self.services.items():
args = SANDBOX_ENGINES[name].emit_env(conn)
env = dict(pair.split("=", 1) for pair in args[1::2])
out[name] = {
payload: dict[str, Any] = {
"host": conn.host,
"port": conn.port,
"user": conn.user,
@@ -64,6 +104,14 @@ class SandboxInfo:
"database": conn.database,
"env": env,
}
if conn.features:
key = (
"available_extensions"
if name == "postgres"
else "available_modules"
)
payload[key] = list(conn.features)
out[name] = payload
return out
@@ -95,14 +143,41 @@ class SandboxEngine(ABC):
@abstractmethod
def ready_probe(self, password: str) -> list[str]:
"""``docker exec`` probe cmd; rc 0 means ready.
"""``docker exec`` probe cmd; rc 0 means the base service is up.
Some engines ignore ``password`` (probe without auth); see ``run_command``.
Runs BEFORE ``enable_step``. Some engines ignore ``password`` (probe
without auth); see ``run_command``.
"""
@abstractmethod
def connection(self, host: str, password: str) -> SandboxConnection:
"""Connection info for the agent, given the container host + password."""
def enable_step(self, password: str, features: list[str]) -> list[list[str]] | None:
"""``docker exec`` argvs that activate the requested features in a
running container (one inner argv per exec; the provisioner runs each).
``None`` when there is nothing to enable (no features, or an engine
like mongo with no activatable features). The provisioner has already
allowlist-validated ``features`` before this runs, so every name here
is a known-safe identifier — the allowlist is the injection guard.
"""
@abstractmethod
def verify_step(self, password: str, features: list[str]) -> list[str] | None:
"""One ``docker exec`` argv whose output confirms the features are
actually present, or ``None`` when there is nothing to verify. Runs
after ``enable_step``; interpreted by ``verify_ok``."""
@abstractmethod
def verify_ok(self, features: list[str], stdout: bytes) -> bool:
"""Interpret ``verify_step``'s stdout: True iff every feature is
confirmed present. A failed ``CREATE EXTENSION`` / ``MODULE LOAD``
(e.g. the image is missing the extension files) surfaces here, not
three steps later as a confusing query error."""
@abstractmethod
def connection(
self, host: str, password: str, features: tuple[str, ...] = ()
) -> SandboxConnection:
"""Connection info for the agent, given the container host, password,
and the features activated in this container."""
@abstractmethod
def emit_env(self, conn: SandboxConnection) -> list[str]:
@@ -133,13 +208,61 @@ class _PostgresEngine(SandboxEngine):
def ready_probe(self, _password: str) -> list[str]:
return ["pg_isready", "-U", "sandbox"]
def connection(self, host: str, password: str) -> SandboxConnection:
def enable_step(
self, _password: str, features: list[str]
) -> list[list[str]] | None:
if not features:
return None
# Every name is allowlist-validated upstream; safe to interpolate as an
# identifier. IF NOT EXISTS so a re-provision of a warm container is a
# no-op rather than a not-error-but-noisy notice.
stmts = "; ".join(f"CREATE EXTENSION IF NOT EXISTS {f}" for f in features)
return [
[
"psql",
"-U",
"sandbox",
"-d",
"sandbox",
"-v",
"ON_ERROR_STOP=1",
"-c",
stmts,
]
]
def verify_step(self, _password: str, features: list[str]) -> list[str] | None:
if not features:
return None
names = ",".join(f"'{f}'" for f in features)
return [
"psql",
"-U",
"sandbox",
"-d",
"sandbox",
"-tAc",
f"SELECT count(*) FROM pg_extension WHERE extname = ANY(ARRAY[{names}])",
]
def verify_ok(self, features: list[str], stdout: bytes) -> bool:
if not features:
return True
try:
return int(stdout.decode().strip()) == len(features)
except (ValueError, AttributeError):
return False
def connection(
self, host: str, password: str, features: tuple[str, ...] = ()
) -> SandboxConnection:
return SandboxConnection(
host=host,
port=self.container_port,
password=password,
user="sandbox",
database="sandbox",
features=features,
)
def emit_env(self, conn: SandboxConnection) -> list[str]:
@@ -174,8 +297,42 @@ class _RedisEngine(SandboxEngine):
def ready_probe(self, password: str) -> list[str]:
return ["redis-cli", "-a", password, "ping"]
def connection(self, host: str, password: str) -> SandboxConnection:
return SandboxConnection(host=host, port=self.container_port, password=password)
def enable_step(self, password: str, features: list[str]) -> list[list[str]] | None:
if not features:
return None
# One MODULE LOAD per module — redis-cli loads one module per call. The
# password is a per-sandbox ephemeral token (not a real secret), mirroring
# run_command's own --requirepass usage.
return [
["redis-cli", "-a", password, "--no-auth-warning", "MODULE", "LOAD", so]
for so in (SANDBOX_REDIS_MODULE_SO[f] for f in features)
]
def verify_step(self, password: str, features: list[str]) -> list[str] | None:
if not features:
return None
return [
"redis-cli",
"-a",
password,
"--no-auth-warning",
"--raw",
"MODULE",
"LIST",
]
def verify_ok(self, features: list[str], stdout: bytes) -> bool:
if not features:
return True
tokens = set(stdout.decode(errors="replace").split())
return all(SANDBOX_REDIS_MODULE_NAME[f] in tokens for f in features)
def connection(
self, host: str, password: str, features: tuple[str, ...] = ()
) -> SandboxConnection:
return SandboxConnection(
host=host, port=self.container_port, password=password, features=features
)
def emit_env(self, conn: SandboxConnection) -> list[str]:
return [
@@ -221,13 +378,29 @@ class _MongoEngine(SandboxEngine):
"db.runCommand({ping:1}).ok",
]
def connection(self, host: str, password: str) -> SandboxConnection:
def enable_step(
self, _password: str, _features: list[str]
) -> list[list[str]] | None:
# The mongo server is batteries-included (text search, change streams
# built in); nothing to activate post-ready.
return None
def verify_step(self, _password: str, _features: list[str]) -> list[str] | None:
return None
def verify_ok(self, features: list[str], _stdout: bytes) -> bool:
return not features
def connection(
self, host: str, password: str, features: tuple[str, ...] = ()
) -> SandboxConnection:
return SandboxConnection(
host=host,
port=self.container_port,
password=password,
user="sandbox",
database="admin",
features=features,
)
def emit_env(self, conn: SandboxConnection) -> list[str]:
+74 -5
View File
@@ -22,6 +22,7 @@ from dataclasses import dataclass
from typing import TYPE_CHECKING
from roboco.models.sandbox import (
SANDBOX_ENGINE_FEATURES,
SANDBOX_ENGINES,
VALID_SANDBOX_SERVICES,
SandboxConnection,
@@ -126,14 +127,40 @@ class SandboxProvisioner:
f"{stderr.decode(errors='replace')}"
)
async def provision(self, agent_id: str, services: list[str]) -> SandboxInfo:
"""Provision the requested services; on any failure, tear down + raise."""
async def provision(
self,
agent_id: str,
services: list[str],
features: dict[str, list[str]] | None = None,
) -> SandboxInfo:
"""Provision the requested services; on any failure, tear down + raise.
``features`` maps a service name to the extensions/modules to activate
in it post-ready (e.g. ``{"postgres": ["vector", "postgis"]}``). Every
feature name is allowlist-validated here against
``SANDBOX_ENGINE_FEATURES`` — an unknown or untrusted name (e.g.
``plpython3u``) is rejected before any container runs. ``None`` or an
empty list per service = bare (no enable step), the existing behavior.
"""
unknown = sorted(set(services) - VALID_SANDBOX_SERVICES)
if unknown:
raise SandboxProvisionError(
f"unknown sandbox service(s) {unknown}; valid: "
f"{sorted(VALID_SANDBOX_SERVICES)}"
)
feat_map = features or {}
for svc, feats in feat_map.items():
if svc not in SANDBOX_ENGINES:
raise SandboxProvisionError(
f"features given for unknown service {svc!r}; valid: "
f"{sorted(VALID_SANDBOX_SERVICES)}"
)
allowed = SANDBOX_ENGINE_FEATURES.get(svc, frozenset())
bad = sorted(set(feats) - allowed)
if bad:
raise SandboxProvisionError(
f"unallowed {svc} feature(s) {bad}; allowed: {sorted(allowed)}"
)
# Pre-clear: a same-named sandbox left by a crash-missed teardown
# would otherwise fail `docker run` on the name conflict and burn a
# spawn attempt (and a respawn-tracker strike).
@@ -143,14 +170,16 @@ class SandboxProvisioner:
try:
for service in services:
engine = SANDBOX_ENGINES[service]
connections[service] = await self._provision_engine(agent_id, engine)
connections[service] = await self._provision_engine(
agent_id, engine, feat_map.get(service, [])
)
except Exception:
await self.teardown(agent_id)
raise
return SandboxInfo(services=connections)
async def _provision_engine(
self, agent_id: str, engine: SandboxEngine
self, agent_id: str, engine: SandboxEngine, features: list[str]
) -> SandboxConnection:
name = engine.container_name(agent_id)
password = secrets.token_hex(16)
@@ -187,7 +216,47 @@ class SandboxProvisioner:
raise SandboxProvisionError(
f"{engine.name} sandbox {name} did not become ready in time"
)
return engine.connection(name, password)
await self._enable_features(name, engine, password, features)
return engine.connection(name, password, tuple(features))
async def _enable_features(
self,
container: str,
engine: SandboxEngine,
password: str,
features: list[str],
) -> None:
"""Run the engine's post-ready enable + verify execs, or raise.
A failed enable (e.g. a typo'd module path) or a failed verify (the
image is missing the extension files) is fatal — the caller tears down
and re-raises — so an agent never receives creds for a db missing what
it asked for.
"""
if not features:
return
run = self._run()
enable_argv = engine.enable_step(password, features)
if enable_argv:
for argv in enable_argv:
rc, _, stderr = await run(
["exec", container, *argv], _DOCKER_EXEC_TIMEOUT_SECONDS
)
if rc != 0:
raise SandboxProvisionError(
f"{engine.name} sandbox {container}: enable step failed "
f"for {features}: {stderr.decode(errors='replace')}"
)
verify_argv = engine.verify_step(password, features)
if verify_argv:
rc, stdout, _ = await run(
["exec", container, *verify_argv], _DOCKER_EXEC_TIMEOUT_SECONDS
)
if rc != 0 or not engine.verify_ok(features, stdout):
raise SandboxProvisionError(
f"{engine.name} sandbox {container} did not confirm features "
f"{features} (image may be missing the extension/module files)"
)
async def _wait_ready(
self, container: str, probe_cmd: list[str], deadline_seconds: float
+187 -1
View File
@@ -33,6 +33,9 @@ class _FakeRunner:
self.calls: list[list[str]] = []
self.run_rc = run_rc
self.exec_rc = exec_rc
# exec stdout — set post-construction (mirrors inspect_rc/pull_rc) by
# tests that exercise the verify step's stdout interpretation.
self.exec_out: bytes = b""
self.teardown_rc = teardown_rc
self.ps_output = ps_output
self.ps_live_output = ps_live_output
@@ -54,7 +57,7 @@ class _FakeRunner:
if verb == "run":
rc, out, err = self.run_rc, b"container-id\n", b""
elif verb == "exec":
rc, out, err = self.exec_rc, b"", b""
rc, out, err = self.exec_rc, self.exec_out, b""
elif verb in ("stop", "kill", "rm"):
rc, out, err = self.teardown_rc, b"", b""
elif verb == "image":
@@ -352,3 +355,186 @@ async def test_is_live_short_circuits_on_first_dead_service() -> None:
assert await provisioner.is_live("dev-13", ["postgres", "redis"]) is False
inspects = [c for c in runner.calls if c[0] == "inspect"]
assert len(inspects) == 1
# ---------------------------------------------------------------------------
# Post-ready feature activation: enable_step + verify_step + allowlist guard.
# All docker calls mocked; the enable/verify loop is exercised end-to-end.
# ---------------------------------------------------------------------------
def _exec_calls(runner: _FakeRunner, containish: str | None = None) -> list[list[str]]:
calls = [c for c in runner.calls if c[0] == "exec"]
if containish is None:
return calls
return [c for c in calls if containish in " ".join(c)]
@pytest.mark.asyncio
async def test_provision_pg_features_runs_enable_then_verify() -> None:
# exec_out = "2\n" satisfies the pg verify (count == len(features)).
runner = _FakeRunner(run_rc=0, exec_rc=0)
runner.exec_out = b"2\n"
provisioner = SandboxProvisioner(network=_NETWORK, runner=runner)
info = await provisioner.provision(
"dev-feat", ["postgres"], features={"postgres": ["vector", "postgis"]}
)
pg = info.services["postgres"]
assert pg.features == ("vector", "postgis")
# The enable exec creates both extensions; the verify exec counts them —
# assert each by content rather than a magic count.
execs = _exec_calls(runner, "psql")
enable = next(c for c in execs if "CREATE EXTENSION" in " ".join(c))
verify = next(c for c in execs if "pg_extension" in " ".join(c))
assert "CREATE EXTENSION IF NOT EXISTS vector" in " ".join(enable)
assert "CREATE EXTENSION IF NOT EXISTS postgis" in " ".join(enable)
assert "ON_ERROR_STOP=1" in enable
assert "vector" in " ".join(verify)
assert "postgis" in " ".join(verify)
@pytest.mark.asyncio
async def test_provision_no_features_skips_enable_and_verify() -> None:
"""Bare provision (no features) is byte-for-byte unchanged: only the base
readiness probe exec runs, no enable/verify."""
runner = _FakeRunner(run_rc=0, exec_rc=0)
provisioner = SandboxProvisioner(network=_NETWORK, runner=runner)
info = await provisioner.provision("dev-bare", ["postgres"])
assert info.services["postgres"].features == ()
pg_execs = _exec_calls(runner, "pg_isready")
assert len(pg_execs) == 1 # readiness only
assert _exec_calls(runner, "psql") == []
@pytest.mark.asyncio
async def test_provision_redis_features_loads_each_module_then_lists() -> None:
# MODULE LIST raw output carrying both module names.
runner = _FakeRunner(run_rc=0, exec_rc=0)
runner.exec_out = b"search\n99999\nReJSON\n99999\n"
provisioner = SandboxProvisioner(network=_NETWORK, runner=runner)
info = await provisioner.provision(
"dev-redis", ["redis"], features={"redis": ["search", "json"]}
)
assert info.services["redis"].features == ("search", "json")
loads = [
c
for c in _exec_calls(runner)
if "MODULE" in " ".join(c) and "LOAD" in " ".join(c)
]
# one MODULE LOAD per module — assert each .so is loaded, not a magic count.
joined = " ".join(" ".join(c) for c in loads)
assert "/opt/redis-stack/lib/redisearch.so" in joined
assert "/opt/redis-stack/lib/rejson.so" in joined
# verify is a single MODULE LIST
lists = [
c
for c in _exec_calls(runner)
if "MODULE" in " ".join(c) and "LIST" in " ".join(c)
]
assert len(lists) == 1
@pytest.mark.asyncio
async def test_provision_mongo_features_are_a_noop() -> None:
"""Mongo is batteries-included; features for it are accepted (allowlist is
empty so only [] passes) and no enable/verify exec runs."""
runner = _FakeRunner(run_rc=0, exec_rc=0)
provisioner = SandboxProvisioner(network=_NETWORK, runner=runner)
info = await provisioner.provision("dev-mongo-f", ["mongo"], features={"mongo": []})
assert info.services["mongo"].features == ()
# Only the mongosh readiness probe — no enable/verify.
assert all(
"MODULE" not in " ".join(c) and "pg_extension" not in " ".join(c)
for c in _exec_calls(runner)
)
@pytest.mark.asyncio
async def test_provision_rejects_unallowed_pg_extension() -> None:
"""plpython3u is a superuser-RCE vector — the allowlist rejects it before
any container runs (no docker calls at all)."""
runner = _FakeRunner(run_rc=0, exec_rc=0)
provisioner = SandboxProvisioner(network=_NETWORK, runner=runner)
with pytest.raises(SandboxProvisionError, match="plpython3u"):
await provisioner.provision(
"dev-bad", ["postgres"], features={"postgres": ["vector", "plpython3u"]}
)
assert not any(c[0] == "run" for c in runner.calls)
@pytest.mark.asyncio
async def test_provision_rejects_feature_for_unknown_service() -> None:
runner = _FakeRunner(run_rc=0, exec_rc=0)
provisioner = SandboxProvisioner(network=_NETWORK, runner=runner)
with pytest.raises(SandboxProvisionError, match="unknown service"):
await provisioner.provision(
"dev-x", ["postgres"], features={"mysql": ["vector"]}
)
@pytest.mark.asyncio
async def test_provision_failed_enable_tears_down_and_raises() -> None:
"""A failed enable exec (e.g. typo'd module path) is fatal — the agent
never receives creds for a db missing what it asked for."""
runner = _FakeRunner(run_rc=0, exec_rc=1) # every exec fails (incl. ready probe)
provisioner = SandboxProvisioner(network=_NETWORK, runner=runner)
with pytest.raises(SandboxProvisionError):
await provisioner.provision(
"dev-fail", ["postgres"], features={"postgres": ["vector"]}
)
# Teardown attempted on the failed container.
assert any(c[0] in ("stop", "kill", "rm") for c in runner.calls)
@pytest.mark.asyncio
async def test_provision_failed_verify_raises_with_image_hint() -> None:
"""A successful enable but a verify count that's short (the image is missing
the extension files) is fatal with the 'image may be missing' hint."""
# 2 features requested but verify reports only 1 present.
runner = _FakeRunner(run_rc=0, exec_rc=0)
runner.exec_out = b"1\n"
provisioner = SandboxProvisioner(network=_NETWORK, runner=runner)
with pytest.raises(SandboxProvisionError, match="missing the extension"):
await provisioner.provision(
"dev-verify", ["postgres"], features={"postgres": ["vector", "postgis"]}
)
@pytest.mark.asyncio
async def test_as_payload_surfaces_available_extensions() -> None:
runner = _FakeRunner(run_rc=0, exec_rc=0)
runner.exec_out = b"1\n"
provisioner = SandboxProvisioner(network=_NETWORK, runner=runner)
info = await provisioner.provision(
"dev-payload", ["postgres"], features={"postgres": ["vector"]}
)
payload = info.as_payload()
assert payload["postgres"]["available_extensions"] == ["vector"]
assert "available_modules" not in payload["postgres"]
@pytest.mark.asyncio
async def test_as_payload_no_extensions_key_when_bare() -> None:
runner = _FakeRunner(run_rc=0, exec_rc=0)
provisioner = SandboxProvisioner(network=_NETWORK, runner=runner)
info = await provisioner.provision("dev-bare2", ["postgres"])
payload = info.as_payload()
assert "available_extensions" not in payload["postgres"]