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
472 lines
16 KiB
Python
472 lines
16 KiB
Python
"""Sandbox Gateway endpoints.
|
|
|
|
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.
|
|
|
|
**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
|
|
|
|
from datetime import datetime
|
|
|
|
from fastapi import APIRouter, Depends, Query, Request, status
|
|
from pydantic import BaseModel, Field
|
|
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.sandbox.models import NetworkPolicy
|
|
from app.interfaces.http.deps import (
|
|
OrgContext,
|
|
client_meta,
|
|
get_org_context,
|
|
get_sandbox_service,
|
|
)
|
|
|
|
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
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class SandboxTemplateOut(BaseModel):
|
|
id: str
|
|
name: str
|
|
provider: str
|
|
image: str
|
|
description: str
|
|
default_cpu: float
|
|
default_memory_mb: int
|
|
default_disk_mb: int
|
|
|
|
|
|
class WorkspaceOut(BaseModel):
|
|
id: str
|
|
project_id: str
|
|
template_id: str
|
|
provider: str
|
|
status: str
|
|
created_at: datetime
|
|
|
|
|
|
class CreateWorkspaceRequest(BaseModel):
|
|
project_id: str
|
|
template_id: str
|
|
|
|
|
|
class SessionOut(BaseModel):
|
|
id: str
|
|
workspace_id: str
|
|
status: str
|
|
started_at: datetime
|
|
last_active_at: datetime
|
|
|
|
|
|
class StartSessionRequest(BaseModel):
|
|
workspace_id: str
|
|
|
|
|
|
class CommandRunOut(BaseModel):
|
|
id: str
|
|
command: str
|
|
exit_code: int | None
|
|
stdout: str
|
|
stderr: str
|
|
duration_ms: int
|
|
truncated: bool
|
|
created_at: datetime
|
|
|
|
|
|
class RunCommandRequest(BaseModel):
|
|
command: str = Field(min_length=1, max_length=4_000)
|
|
|
|
|
|
class FileReadOut(BaseModel):
|
|
path: str
|
|
content: str
|
|
|
|
|
|
class ReadFileRequest(BaseModel):
|
|
path: str = Field(min_length=1, max_length=1_000)
|
|
|
|
|
|
class WriteFileRequest(BaseModel):
|
|
path: str = Field(min_length=1, max_length=1_000)
|
|
content: str = Field(max_length=1_000_000)
|
|
|
|
|
|
class SnapshotOut(BaseModel):
|
|
id: str
|
|
name: str
|
|
created_at: datetime
|
|
|
|
|
|
class CreateSnapshotRequest(BaseModel):
|
|
name: str = Field(min_length=1, max_length=120)
|
|
|
|
|
|
class PreviewUrlOut(BaseModel):
|
|
port: int
|
|
url: str
|
|
|
|
|
|
class ResourceLimitOut(BaseModel):
|
|
cpu: float
|
|
memory_mb: int
|
|
disk_mb: int
|
|
timeout_seconds: int
|
|
max_output_bytes: int
|
|
network_policy: str
|
|
|
|
|
|
class SetLimitsRequest(BaseModel):
|
|
scope: str = "organization"
|
|
scope_id: str | None = None
|
|
cpu: float = Field(default=1.0, gt=0, le=16)
|
|
memory_mb: int = Field(default=1024, ge=128, le=65_536)
|
|
disk_mb: int = Field(default=2048, ge=256, le=1_048_576)
|
|
timeout_seconds: int = Field(default=120, ge=1, le=900)
|
|
max_output_bytes: int = Field(default=200_000, ge=1024, le=2_000_000)
|
|
network_policy: NetworkPolicy = NetworkPolicy.DENY_ALL
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Templates & limits
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.get("/templates", response_model=list[SandboxTemplateOut])
|
|
async def list_templates(
|
|
context: OrgContext = Depends(get_org_context),
|
|
service: SandboxService = Depends(get_sandbox_service),
|
|
) -> list[SandboxTemplateOut]:
|
|
templates = await service.list_templates()
|
|
return [
|
|
SandboxTemplateOut(
|
|
id=t.id, name=t.name, provider=str(t.provider), image=t.image,
|
|
description=t.description, default_cpu=t.default_cpu,
|
|
default_memory_mb=t.default_memory_mb, default_disk_mb=t.default_disk_mb,
|
|
)
|
|
for t in templates
|
|
]
|
|
|
|
|
|
@router.get("/limits", response_model=ResourceLimitOut)
|
|
async def get_limits(
|
|
project_id: str | None = Query(default=None),
|
|
context: OrgContext = Depends(get_org_context),
|
|
service: SandboxService = Depends(get_sandbox_service),
|
|
) -> ResourceLimitOut:
|
|
limit = await service.effective_limits(
|
|
organization_id=context.org_id, project_id=project_id
|
|
)
|
|
return ResourceLimitOut(
|
|
cpu=limit.cpu, memory_mb=limit.memory_mb, disk_mb=limit.disk_mb,
|
|
timeout_seconds=limit.timeout_seconds, max_output_bytes=limit.max_output_bytes,
|
|
network_policy=str(limit.network_policy),
|
|
)
|
|
|
|
|
|
@router.put("/limits", response_model=ResourceLimitOut)
|
|
async def set_limits(
|
|
payload: SetLimitsRequest,
|
|
context: OrgContext = Depends(get_org_context),
|
|
service: SandboxService = Depends(get_sandbox_service),
|
|
) -> ResourceLimitOut:
|
|
_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,
|
|
timeout_seconds=payload.timeout_seconds,
|
|
max_output_bytes=payload.max_output_bytes,
|
|
network_policy=payload.network_policy,
|
|
)
|
|
return ResourceLimitOut(
|
|
cpu=limit.cpu, memory_mb=limit.memory_mb, disk_mb=limit.disk_mb,
|
|
timeout_seconds=limit.timeout_seconds, max_output_bytes=limit.max_output_bytes,
|
|
network_policy=str(limit.network_policy),
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Workspaces
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.post(
|
|
"/workspaces", response_model=WorkspaceOut, status_code=status.HTTP_201_CREATED
|
|
)
|
|
async def create_workspace(
|
|
payload: CreateWorkspaceRequest,
|
|
request: Request,
|
|
context: OrgContext = Depends(get_org_context),
|
|
service: SandboxService = Depends(get_sandbox_service),
|
|
session: AsyncSession = Depends(get_session),
|
|
) -> WorkspaceOut:
|
|
_require_platform_owner(context)
|
|
|
|
workspace = await service.create_workspace(
|
|
organization_id=context.org_id,
|
|
project_id=payload.project_id,
|
|
template_id=payload.template_id,
|
|
)
|
|
|
|
ip, ua = client_meta(request)
|
|
await record_audit(
|
|
session, action="sandbox.workspace_create", organization_id=context.org_id,
|
|
actor_user_id=context.user.id, target_type="workspace", target_id=workspace.id,
|
|
metadata={"project_id": workspace.project_id, "provider": str(workspace.provider)},
|
|
ip=ip, user_agent=ua,
|
|
)
|
|
return WorkspaceOut(
|
|
id=workspace.id, project_id=workspace.project_id,
|
|
template_id=workspace.template_id, provider=str(workspace.provider),
|
|
status=str(workspace.status), created_at=workspace.created_at,
|
|
)
|
|
|
|
|
|
@router.get("/workspaces", response_model=list[WorkspaceOut])
|
|
async def list_workspaces(
|
|
project_id: str = Query(min_length=1),
|
|
context: OrgContext = Depends(get_org_context),
|
|
service: SandboxService = Depends(get_sandbox_service),
|
|
limit: int = Query(default=100, ge=1, le=500),
|
|
offset: int = Query(default=0, ge=0),
|
|
) -> list[WorkspaceOut]:
|
|
workspaces = await service.list_workspaces(
|
|
organization_id=context.org_id,
|
|
project_id=project_id,
|
|
limit=limit,
|
|
offset=offset,
|
|
)
|
|
return [
|
|
WorkspaceOut(
|
|
id=w.id, project_id=w.project_id, template_id=w.template_id,
|
|
provider=str(w.provider), status=str(w.status), created_at=w.created_at,
|
|
)
|
|
for w in workspaces
|
|
]
|
|
|
|
|
|
@router.delete("/workspaces/{workspace_id}", response_model=WorkspaceOut)
|
|
async def destroy_workspace(
|
|
workspace_id: str,
|
|
request: Request,
|
|
context: OrgContext = Depends(get_org_context),
|
|
service: SandboxService = Depends(get_sandbox_service),
|
|
session: AsyncSession = Depends(get_session),
|
|
) -> WorkspaceOut:
|
|
_require_platform_owner(context)
|
|
workspace = await service.destroy_workspace(
|
|
organization_id=context.org_id, workspace_id=workspace_id
|
|
)
|
|
|
|
ip, ua = client_meta(request)
|
|
await record_audit(
|
|
session, action=AuditAction.SANDBOX_DESTROY, organization_id=context.org_id,
|
|
actor_user_id=context.user.id, target_type="workspace",
|
|
target_id=workspace.id, ip=ip, user_agent=ua,
|
|
)
|
|
return WorkspaceOut(
|
|
id=workspace.id, project_id=workspace.project_id,
|
|
template_id=workspace.template_id, provider=str(workspace.provider),
|
|
status=str(workspace.status), created_at=workspace.created_at,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Sessions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.post(
|
|
"/sessions", response_model=SessionOut, status_code=status.HTTP_201_CREATED
|
|
)
|
|
async def start_session(
|
|
payload: StartSessionRequest,
|
|
context: OrgContext = Depends(get_org_context),
|
|
service: SandboxService = Depends(get_sandbox_service),
|
|
) -> SessionOut:
|
|
_require_platform_owner(context)
|
|
sandbox_session = await service.start_session(
|
|
organization_id=context.org_id, workspace_id=payload.workspace_id
|
|
)
|
|
return SessionOut(
|
|
id=sandbox_session.id, workspace_id=sandbox_session.workspace_id,
|
|
status=str(sandbox_session.status), started_at=sandbox_session.started_at,
|
|
last_active_at=sandbox_session.last_active_at,
|
|
)
|
|
|
|
|
|
@router.post("/sessions/{session_id}/stop", response_model=SessionOut)
|
|
async def stop_session(
|
|
session_id: str,
|
|
context: OrgContext = Depends(get_org_context),
|
|
service: SandboxService = Depends(get_sandbox_service),
|
|
) -> SessionOut:
|
|
_require_platform_owner(context)
|
|
sandbox_session = await service.stop_session(
|
|
organization_id=context.org_id, session_id=session_id
|
|
)
|
|
return SessionOut(
|
|
id=sandbox_session.id, workspace_id=sandbox_session.workspace_id,
|
|
status=str(sandbox_session.status), started_at=sandbox_session.started_at,
|
|
last_active_at=sandbox_session.last_active_at,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Commands & files
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.post("/sessions/{session_id}/commands", response_model=CommandRunOut)
|
|
async def run_command(
|
|
session_id: str,
|
|
payload: RunCommandRequest,
|
|
request: Request,
|
|
context: OrgContext = Depends(get_org_context),
|
|
service: SandboxService = Depends(get_sandbox_service),
|
|
session: AsyncSession = Depends(get_session),
|
|
) -> CommandRunOut:
|
|
_require_platform_owner(context)
|
|
|
|
run, _result = await service.run_command(
|
|
organization_id=context.org_id,
|
|
session_id=session_id,
|
|
command=payload.command,
|
|
user_id=context.user.id,
|
|
)
|
|
|
|
ip, ua = client_meta(request)
|
|
await record_audit(
|
|
session, action=AuditAction.SANDBOX_COMMAND, organization_id=context.org_id,
|
|
actor_user_id=context.user.id, target_type="sandbox_session",
|
|
target_id=session_id,
|
|
# The command itself is recorded, but never a secret from the environment.
|
|
metadata={"exit_code": run.exit_code, "duration_ms": run.duration_ms},
|
|
ip=ip, user_agent=ua,
|
|
)
|
|
return CommandRunOut(
|
|
id=run.id, command=run.command, exit_code=run.exit_code,
|
|
stdout=run.stdout_ref, stderr=run.stderr_ref, duration_ms=run.duration_ms,
|
|
truncated=run.truncated, created_at=run.created_at,
|
|
)
|
|
|
|
|
|
@router.get("/sessions/{session_id}/commands", response_model=list[CommandRunOut])
|
|
async def list_commands(
|
|
session_id: str,
|
|
context: OrgContext = Depends(get_org_context),
|
|
service: SandboxService = Depends(get_sandbox_service),
|
|
) -> list[CommandRunOut]:
|
|
await service.get_session_or_404(
|
|
organization_id=context.org_id, session_id=session_id
|
|
)
|
|
runs = await service.list_commands(session_id=session_id)
|
|
return [
|
|
CommandRunOut(
|
|
id=r.id, command=r.command, exit_code=r.exit_code, stdout=r.stdout_ref,
|
|
stderr=r.stderr_ref, duration_ms=r.duration_ms, truncated=r.truncated,
|
|
created_at=r.created_at,
|
|
)
|
|
for r in runs
|
|
]
|
|
|
|
|
|
@router.post("/sessions/{session_id}/files/read", response_model=FileReadOut)
|
|
async def read_file(
|
|
session_id: str,
|
|
payload: ReadFileRequest,
|
|
context: OrgContext = Depends(get_org_context),
|
|
service: SandboxService = Depends(get_sandbox_service),
|
|
) -> FileReadOut:
|
|
_require_platform_owner(context)
|
|
content = await service.read_file(
|
|
organization_id=context.org_id, session_id=session_id, path=payload.path
|
|
)
|
|
return FileReadOut(path=payload.path, content=content)
|
|
|
|
|
|
@router.post("/sessions/{session_id}/files/write", status_code=status.HTTP_204_NO_CONTENT)
|
|
async def write_file(
|
|
session_id: str,
|
|
payload: WriteFileRequest,
|
|
context: OrgContext = Depends(get_org_context),
|
|
service: SandboxService = Depends(get_sandbox_service),
|
|
) -> None:
|
|
_require_platform_owner(context)
|
|
await service.write_file(
|
|
organization_id=context.org_id, session_id=session_id,
|
|
path=payload.path, content=payload.content,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Snapshots & preview
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.post(
|
|
"/sessions/{session_id}/snapshots",
|
|
response_model=SnapshotOut,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
async def create_snapshot(
|
|
session_id: str,
|
|
payload: CreateSnapshotRequest,
|
|
context: OrgContext = Depends(get_org_context),
|
|
service: SandboxService = Depends(get_sandbox_service),
|
|
) -> SnapshotOut:
|
|
_require_platform_owner(context)
|
|
snapshot = await service.snapshot(
|
|
organization_id=context.org_id, session_id=session_id, name=payload.name
|
|
)
|
|
return SnapshotOut(
|
|
id=snapshot.id, name=snapshot.name, created_at=snapshot.created_at
|
|
)
|
|
|
|
|
|
@router.post("/sessions/{session_id}/preview-url", response_model=PreviewUrlOut)
|
|
async def get_preview_url(
|
|
session_id: str,
|
|
port: int = Query(ge=1, le=65535),
|
|
context: OrgContext = Depends(get_org_context),
|
|
service: SandboxService = Depends(get_sandbox_service),
|
|
) -> PreviewUrlOut:
|
|
_require_platform_owner(context)
|
|
preview = await service.get_preview_url(
|
|
organization_id=context.org_id, session_id=session_id, port=port
|
|
)
|
|
return PreviewUrlOut(port=preview.port, url=preview.url)
|