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
209 lines
8.4 KiB
Python
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"]
|