[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
+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,
)