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:
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user