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
This commit is contained in:
mjalmoudy94
2026-10-13 23:57:09 +03:30
parent 0cd9194b64
commit a0c8473eb8
15 changed files with 2047 additions and 79 deletions
+35 -13
View File
@@ -4,8 +4,17 @@ Note what is **absent**: there is no route that accepts a shell command and runs
it on the host. Every execution goes to ``SandboxProviderPort``, is bounded by a
policy timeout and output cap, and is metered into the usage ledger.
Running a command requires ``sandbox.use``; tearing a workspace down requires
``projects.manage`` because it destroys work.
**Who may call what (v2).** Containers, ports and command output are
*infrastructure*, and v2 gives infrastructure to the platform owner alone
(PLAN-v2 §7.7): a customer only ever gives orders to the AI. So every mutating
route here — create/start/stop a workspace or session, run a command, read or
write a file, snapshot, mint a preview URL, set limits — requires the platform
owner. The read-only status routes (templates, effective limits, workspace list,
the command log) stay open to org members, because the project page shows them.
The org-scoped prefix is kept even for owner-only routes: it is what scopes the
audit trail and the tenancy check, and it means the platform owner must
*deliberately* be a member of the org they are operating on.
"""
from __future__ import annotations
@@ -19,8 +28,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.application.services.audit_service import record_audit
from app.application.services.sandbox_service import SandboxService
from app.core.database import get_session
from app.core.errors import PermissionDeniedError
from app.domain.audit.models import AuditAction
from app.domain.identity.models import CAN_MANAGE_PROJECTS, CAN_USE_SANDBOX
from app.domain.sandbox.models import NetworkPolicy
from app.interfaces.http.deps import (
OrgContext,
@@ -32,6 +41,19 @@ from app.interfaces.http.deps import (
router = APIRouter(prefix="/organizations/{org_id}/sandbox", tags=["sandbox"])
def _require_platform_owner(context: OrgContext) -> None:
"""Infrastructure belongs to the platform owner (PLAN-v2 §7.7).
Deliberately *not* an org-capability check: an org admin running a command
in a container is exactly the "users touch infrastructure" behaviour v2
removes.
"""
if not context.user.is_platform_owner:
raise PermissionDeniedError(
"دسترسی به زیرساخت فقط برای مالک سامانه امکان‌پذیر است."
)
# ---------------------------------------------------------------------------
# Schemas
# ---------------------------------------------------------------------------
@@ -181,7 +203,7 @@ async def set_limits(
context: OrgContext = Depends(get_org_context),
service: SandboxService = Depends(get_sandbox_service),
) -> ResourceLimitOut:
context.require(CAN_MANAGE_PROJECTS)
_require_platform_owner(context)
limit = await service.set_limits(
scope=payload.scope, scope_id=payload.scope_id or context.org_id,
cpu=payload.cpu, memory_mb=payload.memory_mb, disk_mb=payload.disk_mb,
@@ -211,7 +233,7 @@ async def create_workspace(
service: SandboxService = Depends(get_sandbox_service),
session: AsyncSession = Depends(get_session),
) -> WorkspaceOut:
context.require(CAN_USE_SANDBOX)
_require_platform_owner(context)
workspace = await service.create_workspace(
organization_id=context.org_id,
@@ -264,7 +286,7 @@ async def destroy_workspace(
service: SandboxService = Depends(get_sandbox_service),
session: AsyncSession = Depends(get_session),
) -> WorkspaceOut:
context.require(CAN_MANAGE_PROJECTS)
_require_platform_owner(context)
workspace = await service.destroy_workspace(
organization_id=context.org_id, workspace_id=workspace_id
)
@@ -295,7 +317,7 @@ async def start_session(
context: OrgContext = Depends(get_org_context),
service: SandboxService = Depends(get_sandbox_service),
) -> SessionOut:
context.require(CAN_USE_SANDBOX)
_require_platform_owner(context)
sandbox_session = await service.start_session(
organization_id=context.org_id, workspace_id=payload.workspace_id
)
@@ -312,7 +334,7 @@ async def stop_session(
context: OrgContext = Depends(get_org_context),
service: SandboxService = Depends(get_sandbox_service),
) -> SessionOut:
context.require(CAN_USE_SANDBOX)
_require_platform_owner(context)
sandbox_session = await service.stop_session(
organization_id=context.org_id, session_id=session_id
)
@@ -337,7 +359,7 @@ async def run_command(
service: SandboxService = Depends(get_sandbox_service),
session: AsyncSession = Depends(get_session),
) -> CommandRunOut:
context.require(CAN_USE_SANDBOX)
_require_platform_owner(context)
run, _result = await service.run_command(
organization_id=context.org_id,
@@ -389,7 +411,7 @@ async def read_file(
context: OrgContext = Depends(get_org_context),
service: SandboxService = Depends(get_sandbox_service),
) -> FileReadOut:
context.require(CAN_USE_SANDBOX)
_require_platform_owner(context)
content = await service.read_file(
organization_id=context.org_id, session_id=session_id, path=payload.path
)
@@ -403,7 +425,7 @@ async def write_file(
context: OrgContext = Depends(get_org_context),
service: SandboxService = Depends(get_sandbox_service),
) -> None:
context.require(CAN_USE_SANDBOX)
_require_platform_owner(context)
await service.write_file(
organization_id=context.org_id, session_id=session_id,
path=payload.path, content=payload.content,
@@ -426,7 +448,7 @@ async def create_snapshot(
context: OrgContext = Depends(get_org_context),
service: SandboxService = Depends(get_sandbox_service),
) -> SnapshotOut:
context.require(CAN_USE_SANDBOX)
_require_platform_owner(context)
snapshot = await service.snapshot(
organization_id=context.org_id, session_id=session_id, name=payload.name
)
@@ -442,7 +464,7 @@ async def get_preview_url(
context: OrgContext = Depends(get_org_context),
service: SandboxService = Depends(get_sandbox_service),
) -> PreviewUrlOut:
context.require(CAN_USE_SANDBOX)
_require_platform_owner(context)
preview = await service.get_preview_url(
organization_id=context.org_id, session_id=session_id, port=port
)