Files
smart-org/apps/api/app/workers/sandbox_gateway.py
T
mjalmoudy94 a0c8473eb8 feat(delivery): docker sandbox adapter + owner-only infrastructure (v2 phase A3-1)
The execution boundary for v2. Adds the local docker/podman sandbox adapter
and the hardened host subprocess runner every other delivery adapter will use.

- adapters/process.py: the one place the platform shells out. argv-only (no
  shell), start_new_session + killpg on timeout, concurrent stdout/stderr
  drains with a byte cap, explicit minimal env (never os.environ), and a
  missing binary reported as spawn_failed rather than an exception.
- adapters/docker_sandbox.py: DockerSandboxAdapter over SandboxProviderPort.
  --user 1000:1000, --cap-drop ALL, no-new-privileges, pids/cpu/memory limits,
  --read-only with tmpfs scratch, one bind mount (the work tree, never the git
  dir), labels, no published ports. Path-safe file access; secrets refused in
  `docker exec -e`. Extras for later phases: start_demo_container (loopback
  publish only), list_managed_containers, remove_container.
- registry: SANDBOX_PROVIDER=docker, cached singleton so the workspace registry
  survives create_workspace -> start_session.
- sandbox_gateway worker: reap_orphans() removes labelled containers no live
  session points at, audited as sandbox.reap; run_pass() does both passes.
- sandbox router: every mutating route now requires the platform owner
  (PLAN-v2 §7.7); read-only status routes stay open to org members.
- sandbox-panel: canManageInfra prop (fails closed) hides the create form,
  the session panel and the action buttons from non-owners.

Tests: 346 API (+37), 67 web (+3); ruff, mypy, tsc and lint clean.
Three existing tests now drive the sandbox as the platform owner, since that
is who may touch it.

Report: docs/plan/reports/agent-3-phase-1.md
2026-10-13 23:57:09 +03:30

209 lines
8.4 KiB
Python

"""Sandbox gateway worker — housekeeping for the only sanctioned execution path.
Two jobs, both about not leaking resources:
1. **Reap idle sessions.** A sandbox session that nobody has touched for hours
is still consuming CPU and disk on the provider. The reaper stops it and
writes an audit event, because "the platform killed my sandbox" must be
answerable from the audit log.
2. **Report the policy in force.** The gateway is the one place where untrusted
code runs, so the worker logs the effective network policy and the absolute
caps at startup. An operator should be able to see the safety envelope
without reading the source.
The worker never executes a command. It only stops sessions — the command path
is ``SandboxService.run_command``, reachable from the API under a tenant check.
"""
from __future__ import annotations
from datetime import timedelta
from app.application.container import build_sandbox_service
from app.application.ports.realtime import emit_sandbox_reaped
from app.core.config import settings
from app.domain.audit.models import (
AuditAction,
AuditEvent,
ErrorSeverity,
ErrorSource,
)
from app.domain.base import utcnow
from app.infrastructure.error_log import record_error
from app.infrastructure.repositories.audit import AuditRepository
from app.infrastructure.repositories.sandbox import (
SandboxSessionRepository,
WorkspaceRepository,
)
from app.workers.base import WorkerOutcome, worker_session
#: Sessions are considered abandoned after this long with no activity.
DEFAULT_IDLE_MINUTES = 120
class SandboxGatewayWorker:
"""Stops idle sandbox sessions and records why."""
name = "sandbox-gateway"
async def run_pass(
self, *, idle_minutes: int = DEFAULT_IDLE_MINUTES, limit: int = 200
) -> WorkerOutcome:
"""One housekeeping pass: idle sessions first, then orphan containers.
Combined into one pass so a cron entry needs a single invocation and an
operator sees one summary line per run.
"""
outcome = await self.reap(idle_minutes=idle_minutes, limit=limit)
orphans = await self.reap_orphans(limit=limit)
outcome.processed += orphans.processed
outcome.failed += orphans.failed
for note in orphans.notes:
outcome.note(note)
return outcome
async def reap(
self, *, idle_minutes: int = DEFAULT_IDLE_MINUTES, limit: int = 200
) -> WorkerOutcome:
outcome = WorkerOutcome(name=self.name)
idle_before = utcnow() - timedelta(minutes=max(1, idle_minutes))
async with worker_session() as session:
sandbox = build_sandbox_service(session)
workspaces = WorkspaceRepository(session)
audit = AuditRepository(session)
stopped = await sandbox.reap_idle_sessions(
idle_before=idle_before, limit=limit
)
for sandbox_session in stopped:
# Resolve the tenant for the audit trail. A session whose
# workspace has vanished is still stopped, just recorded
# without an organization.
workspace = await workspaces.get(sandbox_session.workspace_id)
await audit.record(
event=AuditEvent(
action=AuditAction.SANDBOX_REAP,
organization_id=workspace.organization_id if workspace else None,
target_type="sandbox_session",
target_id=sandbox_session.id,
metadata_json={
"workspace_id": sandbox_session.workspace_id,
"idle_minutes": idle_minutes,
"reason": "idle_timeout",
},
)
)
outcome.processed += 1
outcome.note(f"stopped session {sandbox_session.id}")
# A reaped sandbox is a tenant-visible fact: push it so the
# project page's terminal stops showing a live session.
await emit_sandbox_reaped(
organization_id=workspace.organization_id if workspace else None,
session_id=sandbox_session.id,
workspace_id=sandbox_session.workspace_id,
)
if not stopped:
outcome.skipped += 1
return outcome
def policy_report(self) -> WorkerOutcome:
"""Describe the safety envelope this gateway is running under."""
outcome = WorkerOutcome(name=self.name)
outcome.note(f"sandbox provider = {settings.sandbox_provider}")
outcome.note(
f"command timeout = {settings.sandbox_command_timeout_seconds}s "
f"(absolute cap enforced in the service layer)"
)
outcome.note(f"max output = {settings.sandbox_max_output_bytes} bytes")
outcome.note("egress policy default = deny_all (set per resource limit)")
return outcome
async def reap_orphans(self, *, limit: int = 200) -> WorkerOutcome:
"""Remove labelled containers that no live session row points at.
``reap`` above stops *sessions*; this stops *containers*. The two are
not the same: a session row can be stopped (or the database restored
from a backup) while the container keeps running, and a container can be
started by a process that then died before writing its row. Without this
pass those containers hold CPU, memory and disk forever, because nothing
in the application knows they exist.
Only the container runtime is inspected. A provider without
``list_managed_containers`` (the mock, the remote adapters) has nothing
to reap and is skipped silently — this is not an error condition.
"""
outcome = WorkerOutcome(name=self.name)
from app.infrastructure.adapters.registry import get_sandbox_provider
provider = get_sandbox_provider()
lister = getattr(provider, "list_managed_containers", None)
if lister is None:
outcome.skipped += 1
outcome.note("provider does not expose managed containers; nothing to reap")
return outcome
try:
containers = await lister()
except Exception as exc: # noqa: BLE001 - a CLI outage must not kill the worker
await record_error(
kind="sandbox_orphan_list_failed",
message=str(exc),
exc=exc,
source=ErrorSource.WORKER,
severity=ErrorSeverity.WARNING,
metadata={"worker": self.name},
)
outcome.failed += 1
return outcome
async with worker_session() as session:
live = await SandboxSessionRepository(session).list_active_external_ids()
audit = AuditRepository(session)
reaped = 0
for container in containers:
if reaped >= limit:
break
if container.container_id in live:
continue
# A demo/build container is owned by the delivery pipeline, which
# tracks it in its own table. Until that table exists it is left
# alone rather than guessed at.
if container.role and container.role != "workspace":
outcome.skipped += 1
continue
removed = await provider.remove_container(container.container_id)
if not removed:
outcome.failed += 1
continue
reaped += 1
outcome.processed += 1
outcome.note(f"removed orphan container {container.container_id}")
await audit.record(
event=AuditEvent(
action=AuditAction.SANDBOX_REAP,
organization_id=container.organization_id or None,
target_type="sandbox_container",
target_id=container.container_id,
metadata_json={
"reason": "orphan_container",
"project_id": container.project_id,
"role": container.role,
},
)
)
if not reaped:
outcome.skipped += 1
return outcome
__all__ = ["DEFAULT_IDLE_MINUTES", "SandboxGatewayWorker"]