From a0c8473eb891bc6dfd1f098676389e9dc4c43a67 Mon Sep 17 00:00:00 2001 From: mjalmoudy94 Date: Tue, 13 Oct 2026 23:57:09 +0330 Subject: [PATCH] feat(delivery): docker sandbox adapter + owner-only infrastructure (v2 phase A3-1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 3 + .../infrastructure/adapters/docker_sandbox.py | 641 ++++++++++++++++++ .../app/infrastructure/adapters/process.py | 274 ++++++++ .../app/infrastructure/adapters/registry.py | 10 +- .../infrastructure/repositories/sandbox.py | 23 + .../app/interfaces/http/routers/sandbox.py | 48 +- apps/api/app/workers/__main__.py | 2 +- apps/api/app/workers/sandbox_gateway.py | 110 ++- apps/api/tests/test_docker_sandbox.py | 588 ++++++++++++++++ apps/api/tests/test_e2e_smoke.py | 10 +- apps/api/tests/test_workers.py | 20 +- apps/web/src/app/(app)/projects/[id]/page.tsx | 8 +- apps/web/src/components/sandbox-panel.tsx | 133 ++-- .../src/test/delivery-sandbox-panel.test.tsx | 114 ++++ docs/plan/reports/agent-3-phase-1.md | 142 ++++ 15 files changed, 2047 insertions(+), 79 deletions(-) create mode 100644 apps/api/app/infrastructure/adapters/docker_sandbox.py create mode 100644 apps/api/app/infrastructure/adapters/process.py create mode 100644 apps/api/tests/test_docker_sandbox.py create mode 100644 apps/web/src/test/delivery-sandbox-panel.test.tsx create mode 100644 docs/plan/reports/agent-3-phase-1.md diff --git a/.gitignore b/.gitignore index 7f18480..ec0fd73 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,6 @@ htmlcov/ # v2 runtime data (workspaces, artifacts) .data/ + +# pytest scratch root, used when the system temp dir is not writable +.pytest-tmp/ diff --git a/apps/api/app/infrastructure/adapters/docker_sandbox.py b/apps/api/app/infrastructure/adapters/docker_sandbox.py new file mode 100644 index 0000000..40754b3 --- /dev/null +++ b/apps/api/app/infrastructure/adapters/docker_sandbox.py @@ -0,0 +1,641 @@ +"""Local Docker/podman sandbox adapter — the hardened execution boundary (D5). + +One *workspace* container per application is where the AI employees edit files +and run commands; a separate *demo* container (created by the delivery service) +runs the latest green build. Both are created by this adapter. + +The hardening is not decoration — it is the only thing standing between an LLM +that writes arbitrary shell and the host: + +* non-root (``--user 1000:1000``) and ``--cap-drop ALL`` + ``no-new-privileges`` + so a setuid binary cannot be used to escalate; +* ``--read-only`` root filesystem with two ``tmpfs`` scratch mounts, so a + compromised process cannot persist anything outside the bind-mounted work dir; +* ``--pids-limit`` / ``--cpus`` / ``--memory`` so a fork bomb or a runaway build + cannot take the host down; +* the **git dir is never mounted** — only the work tree. The platform runs git + on the host (see ``git_workspace.py``), so repository credentials never enter + the container; +* ports are published on ``127.0.0.1`` only, never ``0.0.0.0``. + +The provider binary is configurable (``SANDBOX_DOCKER_BIN``) and the flags used +here are the common subset of ``docker`` and ``podman``, so podman works as a +drop-in. + +``create_workspace``/``start_session`` are split by the port, but the workspace +identity is *encoded in the external id* (``ws--``) so a restart +can rebuild the host paths without a database round-trip. Only the image is +kept in a process-local registry; a cold registry falls back to the profile +default for the language, which is what a fresh process would want anyway. +""" + +from __future__ import annotations + +import contextlib +import logging +import os +import re +import shutil +from collections.abc import Awaitable, Callable, Sequence +from dataclasses import dataclass, field + +from app.application.ports import ( + CommandResult, + SandboxProviderPort, # noqa: F401 - documented as the implemented protocol + SessionInfo, + SnapshotInfo, + WorkspaceInfo, +) +from app.core.config import settings +from app.core.errors import ValidationError +from app.domain.platform.rules import assert_not_protected_port +from app.infrastructure.adapters.process import ProcessResult, run_process + +logger = logging.getLogger("agy.sandbox.docker") + +#: Language → base image. The plan fixes these names; the mapping lives here so +#: the delivery service can ask for an image without knowing the table. +LANGUAGE_IMAGES: dict[str, str] = { + "node": "node:20-bookworm", + "javascript": "node:20-bookworm", + "typescript": "node:20-bookworm", + "python": "python:3.13-bookworm", + "php": "php:8.3-cli", + "go": "golang:1.23", + "golang": "golang:1.23", + "static": "debian:bookworm", + "html": "debian:bookworm", +} +FALLBACK_IMAGE = "debian:bookworm" + +#: Role → label value. The reaper and the admin console filter on these. +ROLE_WORKSPACE = "workspace" +ROLE_DEMO = "demo" +ROLE_BUILD = "build" + +LABEL_ORG = "smart-org.org" +LABEL_PROJECT = "smart-org.project" +LABEL_ROLE = "smart-org.role" + +#: In-container mount point of the working tree. +SANDBOX_WORKSPACE_PATH = "/workspace" + +_SAFE_ID = re.compile(r"^[A-Za-z0-9_.-]+$") +_SNAPSHOT_NAME = re.compile(r"[^a-z0-9-]+") + +#: Command used to keep a container alive. ``sleep infinity`` is present in +#: every image we use (coreutils in debian, busybox in alpine variants). +_KEEPALIVE = "sleep infinity" + + +class PathEscapeError(ValidationError): + """A requested path resolves outside the sandbox working directory.""" + + +def image_for_language(language: str | None) -> str: + """Base image for an app profile language, with a safe fallback.""" + return LANGUAGE_IMAGES.get((language or "").strip().lower(), FALLBACK_IMAGE) + + +def container_name(role: str, organization_id: str, project_id: str) -> str: + """Deterministic container name so a restart can find its own containers.""" + safe = f"smartorg-{role}-{organization_id}-{project_id}".lower() + return re.sub(r"[^a-z0-9_.-]", "-", safe)[:200] + + +def _check_id(value: str, label: str) -> str: + clean = (value or "").strip() + if not clean or not _SAFE_ID.match(clean): + raise ValidationError(f"شناسهٔ {label} نامعتبر است.") + return clean + + +def safe_join(root: str, path: str) -> str: + """Resolve ``path`` under ``root``, refusing any escape. + + ``..`` segments, absolute paths and symlinks that point outside the root are + all rejected after ``realpath``, so a symlink created inside the sandbox + cannot be used to read ``/etc/shadow`` on the host. + """ + root_real = os.path.realpath(root) + relative = (path or "").lstrip("/") + candidate = os.path.realpath(os.path.join(root_real, relative)) + if candidate != root_real and not candidate.startswith(root_real + os.sep): + raise PathEscapeError("مسیر درخواستی خارج از محیط اجرا است.") + return candidate + + +@dataclass(slots=True) +class ManagedContainer: + """One container carrying our labels, as reported by the CLI.""" + + container_id: str + name: str = "" + organization_id: str = "" + project_id: str = "" + role: str = "" + status: str = "" + + +@dataclass(slots=True) +class _WorkspaceState: + organization_id: str + project_id: str + host_dir: str + image: str + session_ids: list[str] = field(default_factory=list) + + +class DockerSandboxAdapter: + """``SandboxProviderPort`` over the docker/podman CLI. + + ``runner`` is injectable so unit tests assert on the exact argv without a + container runtime present. + """ + + def __init__( + self, + *, + provider_bin: str | None = None, + workspaces_root: str | None = None, + default_cpus: float | None = None, + default_memory_mb: int | None = None, + max_output_bytes: int | None = None, + runner: Callable[..., Awaitable[ProcessResult]] = run_process, + ) -> None: + self.bin = (provider_bin or settings.sandbox_docker_bin or "docker").strip() + self.workspaces_root = os.path.abspath( + workspaces_root or settings.sandbox_workspaces_root + ) + self.default_cpus = float( + settings.sandbox_default_cpus if default_cpus is None else default_cpus + ) + self.default_memory_mb = int( + settings.sandbox_default_memory_mb if default_memory_mb is None else default_memory_mb + ) + self.max_output_bytes = int( + settings.sandbox_max_output_bytes if max_output_bytes is None else max_output_bytes + ) + self._runner = runner + self._workspaces: dict[str, _WorkspaceState] = {} + #: container id → the workspace state it belongs to (filled by start_session). + self._sessions: dict[str, _WorkspaceState] = {} + + # -- helpers ------------------------------------------------------------- + + def _workspace_dir(self, organization_id: str, project_id: str) -> str: + return os.path.join(self.workspaces_root, organization_id, project_id, "work") + + def _external_id(self, organization_id: str, project_id: str) -> str: + return f"ws-{organization_id}-{project_id}" + + @staticmethod + def _parse_external_id(external_id: str) -> tuple[str, str] | None: + if not external_id.startswith("ws-"): + return None + parts = external_id[3:].split("-", 1) + if len(parts) != 2: + return None + return parts[0], parts[1] + + def _state_for(self, external_id: str, image: str | None = None) -> _WorkspaceState: + """Look up (or rebuild) the workspace state behind an external id.""" + state = self._workspaces.get(external_id) + if state is not None: + return state + parsed = self._parse_external_id(external_id) + if parsed is None: + raise ValidationError("شناسهٔ محیط اجرا نامعتبر است.") + organization_id, project_id = parsed + state = _WorkspaceState( + organization_id=organization_id, + project_id=project_id, + host_dir=self._workspace_dir(organization_id, project_id), + image=image or FALLBACK_IMAGE, + ) + self._workspaces[external_id] = state + return state + + def _state_for_session(self, session_external_id: str) -> _WorkspaceState | None: + return self._sessions.get(session_external_id) + + async def _docker( + self, + args: Sequence[str], + *, + timeout_seconds: float = 120.0, + on_output: Callable[[str], Awaitable[None]] | None = None, + ) -> ProcessResult: + return await self._runner( + [self.bin, *args], + timeout_seconds=timeout_seconds, + max_output_bytes=self.max_output_bytes, + on_output=on_output, + env={}, + ) + + # -- workspaces ---------------------------------------------------------- + + async def create_workspace( + self, *, organization_id: str, project_id: str, template_id: str, image: str + ) -> WorkspaceInfo: + org = _check_id(organization_id, "سازمان") + project = _check_id(project_id, "پروژه") + + host_dir = self._workspace_dir(org, project) + # 0700: the workspace holds the tenant's source code. The platform user + # owns it; nothing else on the host may read it. + os.makedirs(host_dir, exist_ok=True) + with contextlib.suppress(OSError): + os.chmod(self.workspaces_root, 0o700) + os.chmod(os.path.dirname(host_dir), 0o700) + os.chmod(host_dir, 0o700) + + external_id = self._external_id(org, project) + self._workspaces[external_id] = _WorkspaceState( + organization_id=org, + project_id=project, + host_dir=host_dir, + image=image or FALLBACK_IMAGE, + ) + return WorkspaceInfo( + external_id=external_id, + status="ready", + raw={"host_dir": host_dir, "image": image}, + ) + + async def start_session(self, *, workspace_external_id: str) -> SessionInfo: + state = self._state_for(workspace_external_id) + name = container_name(ROLE_WORKSPACE, state.organization_id, state.project_id) + + # A restart leaves the old container behind; reuse it rather than + # stacking duplicates, and start it if it was stopped. + existing = await self._container_id_by_name(name) + if existing: + await self._docker(["start", existing], timeout_seconds=60) + self._sessions[existing] = state + state.session_ids.append(existing) + return SessionInfo(external_id=existing, status="running") + + result = await self._docker( + [ + "run", + "-d", + "--name", + name, + # --- isolation ------------------------------------------------- + "--user", + "1000:1000", + "--cap-drop", + "ALL", + "--security-opt", + "no-new-privileges", + "--pids-limit", + "512", + "--cpus", + str(self.default_cpus), + "--memory", + f"{self.default_memory_mb}m", + # --- immutable root, scratch in tmpfs -------------------------- + "--read-only", + "--tmpfs", + "/tmp", + "--tmpfs", + "/home/app", + # --- only the work tree, never the git dir --------------------- + "-v", + f"{state.host_dir}:{SANDBOX_WORKSPACE_PATH}", + "-w", + SANDBOX_WORKSPACE_PATH, + "--label", + f"{LABEL_ORG}={state.organization_id}", + "--label", + f"{LABEL_PROJECT}={state.project_id}", + "--label", + f"{LABEL_ROLE}={ROLE_WORKSPACE}", + # Default bridge network: package installs need egress. No + # published ports — the container is reached with ``exec``. + state.image, + "sh", + "-lc", + _KEEPALIVE, + ], + timeout_seconds=180, + ) + if not result.ok: + raise ValidationError("راه‌اندازی محیط اجرا ناموفق بود.") + + container_id = result.stdout.strip().splitlines()[-1].strip() if result.stdout else "" + if not container_id: + raise ValidationError("راه‌اندازی محیط اجرا ناموفق بود.") + self._sessions[container_id] = state + state.session_ids.append(container_id) + return SessionInfo( + external_id=container_id, + status="running", + raw={"name": name, "image": state.image}, + ) + + async def _container_id_by_name(self, name: str) -> str | None: + result = await self._docker( + ["ps", "-a", "--filter", f"name=^{name}$", "--format", "{{.ID}}"], + timeout_seconds=30, + ) + if not result.ok or not result.stdout.strip(): + return None + return result.stdout.strip().splitlines()[0].strip() + + # -- commands ------------------------------------------------------------ + + async def run_command( + self, + *, + session_external_id: str, + command: str, + timeout_seconds: int, + max_output_bytes: int, + env: dict[str, str] | None = None, + cwd: str | None = None, + on_output: Callable[[str], Awaitable[None]] | None = None, + ) -> CommandResult: + """``docker exec`` the command inside the container. + + ``cwd``/``on_output`` are adapter-level extras used by the engine; the + port signature is unchanged. + + ``env`` is passed with ``-e``. Keys that *look* like secrets are refused + outright: argv is visible in ``ps`` and in the audit log, and the whole + point of the design is that credentials never travel that way. + """ + cleaned = (command or "").strip() + if not cleaned: + raise ValidationError("دستور نمی‌تواند خالی باشد.") + + env_flags: list[str] = [] + for key, value in (env or {}).items(): + if _looks_like_secret(key): + raise ValidationError( + "متغیرهای محیطی حاوی اطلاعات محرمانه مجاز نیستند." + ) + env_flags.extend(["-e", f"{key}={value}"]) + + workdir = cwd or SANDBOX_WORKSPACE_PATH + args = [ + "exec", + "-w", + workdir, + *env_flags, + session_external_id, + "sh", + "-lc", + cleaned, + ] + result = await self._runner( + [self.bin, *args], + timeout_seconds=float(timeout_seconds), + max_output_bytes=int(max_output_bytes), + on_output=on_output, + env={}, + ) + return CommandResult( + exit_code=result.exit_code, + stdout=result.stdout, + stderr=result.stderr, + duration_ms=result.duration_ms, + truncated=result.truncated, + ) + + # -- files --------------------------------------------------------------- + + def _host_path(self, session_external_id: str, path: str) -> str: + state = self._state_for_session(session_external_id) + if state is None: + raise ValidationError("نشست اجرا یافت نشد.") + # Container paths under /workspace map 1:1 onto the bind-mounted host + # dir; anything else is not reachable through this adapter. + relative = (path or "").lstrip("/") + if relative.startswith("workspace"): + relative = relative[len("workspace") :].lstrip("/") + return safe_join(state.host_dir, relative) + + async def read_file(self, *, session_external_id: str, path: str) -> str: + host_path = self._host_path(session_external_id, path) + if not os.path.isfile(host_path): + return "" + with open(host_path, encoding="utf-8", errors="replace") as handle: + return handle.read() + + async def write_file(self, *, session_external_id: str, path: str, content: str) -> None: + host_path = self._host_path(session_external_id, path) + os.makedirs(os.path.dirname(host_path) or ".", exist_ok=True) + with open(host_path, "w", encoding="utf-8") as handle: + handle.write(content) + + # -- lifecycle ----------------------------------------------------------- + + async def snapshot(self, *, session_external_id: str, name: str) -> SnapshotInfo: + """``docker commit`` the container. + + Supported, but documented as a heavy operation: it captures the whole + root filesystem, and the work tree lives on a bind mount that is *not* + included. Snapshots are therefore useful for the base image state only. + """ + state = self._state_for_session(session_external_id) + slug = _SNAPSHOT_NAME.sub("-", (name or "snapshot").lower()).strip("-") or "snapshot" + org = state.organization_id if state else "unknown" + tag = f"smartorg-snapshot-{org}-{slug}"[:120] + result = await self._docker( + ["commit", session_external_id, tag], timeout_seconds=300 + ) + if not result.ok: + raise ValidationError("تهیهٔ اسنپ‌شات ناموفق بود.") + return SnapshotInfo(external_id=tag, name=name) + + async def pause(self, *, session_external_id: str) -> None: + await self._docker(["stop", session_external_id], timeout_seconds=60) + + async def resume(self, *, session_external_id: str) -> SessionInfo: + result = await self._docker(["start", session_external_id], timeout_seconds=60) + return SessionInfo( + external_id=session_external_id, + status="running" if result.ok else "error", + ) + + async def destroy(self, *, session_external_id: str) -> None: + # ``-f`` covers running and stopped; ``-v`` removes anonymous volumes so + # a destroyed workspace does not leave data behind on the host. + await self._docker(["rm", "-f", "-v", session_external_id], timeout_seconds=60) + self._sessions.pop(session_external_id, None) + + async def get_preview_url(self, *, session_external_id: str, port: int) -> str: + # Loopback only, and never an internal platform port. The public hostname + # is a domain-layer concern (Phase 4), not the sandbox's. + assert_not_protected_port(int(port)) + return f"http://127.0.0.1:{int(port)}" + + # -- demo / build containers (used by the delivery service) -------------- + + async def start_demo_container( + self, + *, + organization_id: str, + project_id: str, + image: str, + host_port: int, + app_port: int, + command: str, + work_dir: str, + cpus: float | None = None, + memory_mb: int | None = None, + name_suffix: str = "", + ) -> str: + """Start a demo container and return its id. + + Blue/green is implemented by the caller: start on a temporary port, wait + for the healthcheck, switch the route, then stop the previous container. + Publishing on ``127.0.0.1`` means only Caddy on this host can reach it. + """ + org = _check_id(organization_id, "سازمان") + project = _check_id(project_id, "پروژه") + assert_not_protected_port(int(host_port)) + name = container_name(ROLE_DEMO, org, project) + if name_suffix: + name = f"{name}-{_SNAPSHOT_NAME.sub('-', name_suffix.lower()).strip('-')}"[:200] + + result = await self._docker( + [ + "run", + "-d", + "--name", + name, + "--user", + "1000:1000", + "--cap-drop", + "ALL", + "--security-opt", + "no-new-privileges", + "--pids-limit", + "512", + "--cpus", + str(cpus if cpus is not None else self.default_cpus), + "--memory", + f"{int(memory_mb if memory_mb is not None else self.default_memory_mb)}m", + "--read-only", + "--tmpfs", + "/tmp", + "--tmpfs", + "/home/app", + "-v", + f"{work_dir}:{SANDBOX_WORKSPACE_PATH}", + "-w", + SANDBOX_WORKSPACE_PATH, + # Loopback publish only — never 0.0.0.0. + "-p", + f"127.0.0.1:{int(host_port)}:{int(app_port)}", + "--label", + f"{LABEL_ORG}={org}", + "--label", + f"{LABEL_PROJECT}={project}", + "--label", + f"{LABEL_ROLE}={ROLE_DEMO}", + image, + "sh", + "-lc", + command or _KEEPALIVE, + ], + timeout_seconds=180, + ) + if not result.ok or not result.stdout.strip(): + raise ValidationError("راه‌اندازی نسخهٔ نمایشی ناموفق بود.") + return result.stdout.strip().splitlines()[-1].strip() + + # -- maintenance --------------------------------------------------------- + + async def list_managed_containers(self) -> list[ManagedContainer]: + """Every container carrying our labels, running or not. + + Used by the sandbox-gateway worker to find orphans: a container whose + session row is gone is still holding CPU and disk. + """ + result = await self._docker( + [ + "ps", + "-a", + "--filter", + f"label={LABEL_ROLE}", + "--format", + "{{.ID}}\t{{.Names}}\t{{.Status}}\t{{.Labels}}", + ], + timeout_seconds=30, + ) + if not result.ok: + return [] + + containers: list[ManagedContainer] = [] + for line in result.stdout.splitlines(): + parts = line.split("\t") + if len(parts) < 4: + continue + labels = _parse_labels(parts[3]) + containers.append( + ManagedContainer( + container_id=parts[0].strip(), + name=parts[1].strip(), + status=parts[2].strip(), + organization_id=labels.get(LABEL_ORG, ""), + project_id=labels.get(LABEL_PROJECT, ""), + role=labels.get(LABEL_ROLE, ""), + ) + ) + return containers + + async def remove_container(self, container_id: str) -> bool: + result = await self._docker(["rm", "-f", "-v", container_id], timeout_seconds=60) + self._sessions.pop(container_id, None) + return result.ok + + def host_dir_for(self, organization_id: str, project_id: str) -> str: + """Absolute host path of a project's working tree.""" + return self._workspace_dir( + _check_id(organization_id, "سازمان"), _check_id(project_id, "پروژه") + ) + + @staticmethod + def binary_available(binary: str | None = None) -> bool: + """True when the container runtime CLI is on PATH.""" + return shutil.which((binary or settings.sandbox_docker_bin or "docker").strip()) is not None + + +def _parse_labels(raw: str) -> dict[str, str]: + labels: dict[str, str] = {} + for item in (raw or "").split(","): + if "=" not in item: + continue + key, _, value = item.partition("=") + labels[key.strip()] = value.strip() + return labels + + +def _looks_like_secret(key: str) -> bool: + from app.core.redact import is_sensitive_key + + return is_sensitive_key(key) + + +__all__ = [ + "FALLBACK_IMAGE", + "LABEL_ORG", + "LABEL_PROJECT", + "LABEL_ROLE", + "LANGUAGE_IMAGES", + "ROLE_BUILD", + "ROLE_DEMO", + "ROLE_WORKSPACE", + "SANDBOX_WORKSPACE_PATH", + "DockerSandboxAdapter", + "ManagedContainer", + "PathEscapeError", + "container_name", + "image_for_language", + "safe_join", +] diff --git a/apps/api/app/infrastructure/adapters/process.py b/apps/api/app/infrastructure/adapters/process.py new file mode 100644 index 0000000..0906746 --- /dev/null +++ b/apps/api/app/infrastructure/adapters/process.py @@ -0,0 +1,274 @@ +"""The one place the platform shells out to a host binary. + +Nothing else in the codebase may call ``subprocess`` or ``os.system``. Every +adapter that needs an external binary (docker/podman, git, caddy reload) goes +through :func:`run_process`, which exists to make four failure modes impossible +rather than merely unlikely: + +**Shell injection.** The command is an ``argv`` sequence handed straight to +``asyncio.create_subprocess_exec``. There is no ``shell=True`` path and no +string interpolation into a shell, so a task id or a branch name can never +become a second command. + +**Orphaned children.** The child starts in its own session +(``start_new_session=True``), so a timeout kills the whole *process group*. A +``docker build`` that spawns helpers, or a ``git`` that runs a credential +helper, dies with its parent instead of lingering as a stray process holding a +lock. + +**Pipe deadlock.** stdout and stderr are drained concurrently by two tasks. +Reading one to completion before touching the other is the classic 64 KB +deadlock: the child blocks writing stderr, we block reading stdout, and both +wait forever. ``communicate()`` is not used because we want a byte cap and a +streaming callback. + +**Secret inheritance.** The child gets an explicit, minimal environment built +from :func:`base_env`, never ``os.environ`` wholesale. The platform process +holds the JWT secret, the database DSN and every provider key; a subprocess that +inherits them can leak them through a stack trace, a build log or a core dump. + +Output is capped in bytes while streaming, so a runaway build cannot exhaust +memory. The cap is applied *after* the callback sees the chunk, so live logs +still show everything that arrived. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import os +import signal +import time +from collections.abc import Awaitable, Callable, Mapping, Sequence +from dataclasses import dataclass, field + +#: Environment variables a child may inherit from the platform process. Anything +#: not listed is dropped. Deliberately tiny. +_BASE_ENV_KEYS: tuple[str, ...] = ("PATH", "LANG", "LC_ALL", "LC_CTYPE", "TZ", "TMPDIR") + +#: Fallback PATH when the parent has none (systemd units often do not). +_FALLBACK_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + +#: Read size per pipe chunk. 64 KiB is the pipe buffer size, so this drains +#: promptly without spinning. +_CHUNK_BYTES = 65_536 + +#: How long a child gets to die politely after SIGTERM before SIGKILL. +_TERM_GRACE_SECONDS = 5.0 + +#: An argv element longer than this is almost certainly a mistake (a base64 blob +#: pasted into a command line). Refused rather than silently truncated. +MAX_ARGV_ELEMENT_CHARS = 100_000 + + +class ProcessError(RuntimeError): + """A host command could not be started at all (binary missing, not executable).""" + + +@dataclass(slots=True) +class ProcessResult: + """Outcome of one host command. + + ``argv`` is retained so a test can assert on the *exact* argument vector — + which is how the hardening flags and the "no credential in argv" rule are + verified. + """ + + argv: tuple[str, ...] + exit_code: int + stdout: str = "" + stderr: str = "" + duration_ms: int = 0 + timed_out: bool = False + truncated: bool = False + #: True when the binary itself could not be executed (ENOENT/EACCES). + spawn_failed: bool = False + spawn_error: str = "" + + @property + def ok(self) -> bool: + return self.exit_code == 0 and not self.timed_out and not self.spawn_failed + + +@dataclass(slots=True) +class _DrainResult: + data: bytes = b"" + truncated: bool = False + lines: list[str] = field(default_factory=list) + + +def base_env(extra: Mapping[str, str] | None = None) -> dict[str, str]: + """Minimal child environment: a few safe keys plus explicit overrides. + + Callers add what they need (``HOME`` for git, ``GIT_CONFIG_NOSYSTEM=1``) and + nothing else is inherited. + """ + env = {key: os.environ[key] for key in _BASE_ENV_KEYS if key in os.environ} + env.setdefault("PATH", _FALLBACK_PATH) + if extra: + env.update({str(k): str(v) for k, v in extra.items()}) + return env + + +def _validate_argv(argv: Sequence[str]) -> tuple[str, ...]: + if not argv: + raise ValueError("argv must not be empty") + cleaned: list[str] = [] + for index, element in enumerate(argv): + if not isinstance(element, str): + raise TypeError(f"argv[{index}] must be str, got {type(element).__name__}") + if "\x00" in element: + raise ValueError(f"argv[{index}] contains a NUL byte") + if len(element) > MAX_ARGV_ELEMENT_CHARS: + raise ValueError( + f"argv[{index}] is {len(element)} chars; " + f"the limit is {MAX_ARGV_ELEMENT_CHARS}" + ) + cleaned.append(element) + return tuple(cleaned) + + +async def _drain( + stream: asyncio.StreamReader | None, + *, + cap_bytes: int, + on_output: Callable[[str], Awaitable[None]] | None, +) -> _DrainResult: + """Read a pipe to EOF, keeping at most ``cap_bytes`` and streaming every chunk.""" + result = _DrainResult() + if stream is None: + return result + + buffer = bytearray() + truncated = False + while True: + chunk = await stream.read(_CHUNK_BYTES) + if not chunk: + break + if len(buffer) < cap_bytes: + remaining = cap_bytes - len(buffer) + buffer.extend(chunk[:remaining]) + if len(chunk) > remaining: + truncated = True + else: + truncated = True + if on_output is not None: + # The callback sees the *raw* chunk even past the cap: a live log + # should not stop scrolling just because we stopped storing. + with contextlib.suppress(Exception): + await on_output(chunk.decode("utf-8", "replace")) + + result.data = bytes(buffer) + result.truncated = truncated + return result + + +async def _terminate(proc: asyncio.subprocess.Process) -> None: + """SIGTERM the child's process group, then SIGKILL if it will not go.""" + if proc.returncode is not None: + return + + def _signal_group(sig: int) -> None: + # The child is a session leader (``start_new_session=True``), so its pid + # *is* its process-group id. Fall back to the pid on platforms without + # process groups. + with contextlib.suppress(ProcessLookupError, PermissionError, OSError): + if hasattr(os, "killpg"): + os.killpg(proc.pid, sig) + else: # pragma: no cover - POSIX is the only supported target + proc.send_signal(sig) + + _signal_group(signal.SIGTERM) + try: + await asyncio.wait_for(proc.wait(), timeout=_TERM_GRACE_SECONDS) + return + except TimeoutError: + pass + _signal_group(signal.SIGKILL) + with contextlib.suppress(Exception): + await proc.wait() + + +async def run_process( + argv: Sequence[str], + *, + cwd: str | None = None, + env: Mapping[str, str] | None = None, + timeout_seconds: float = 120.0, + max_output_bytes: int = 200_000, + on_output: Callable[[str], Awaitable[None]] | None = None, + stdin_data: bytes | None = None, +) -> ProcessResult: + """Run ``argv`` and return its bounded, timed result. + + Never raises for a non-zero exit or a timeout — those are ordinary outcomes + the caller inspects. It *does* raise :class:`ProcessError` when the binary + cannot be started, because that is a deployment fault, not a command result. + + ``stdin_data`` is written and closed immediately; the platform never hands a + child an interactive terminal. + """ + cleaned = _validate_argv(argv) + started = time.perf_counter() + + try: + proc = await asyncio.create_subprocess_exec( + *cleaned, + cwd=cwd, + env=base_env(env), + stdin=asyncio.subprocess.PIPE if stdin_data is not None else asyncio.subprocess.DEVNULL, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + start_new_session=True, + ) + except (FileNotFoundError, NotADirectoryError, PermissionError, OSError) as exc: + return ProcessResult( + argv=cleaned, + exit_code=127, + stderr=str(exc), + duration_ms=int((time.perf_counter() - started) * 1000), + spawn_failed=True, + spawn_error=type(exc).__name__, + ) + + if stdin_data is not None and proc.stdin is not None: + with contextlib.suppress(BrokenPipeError, ConnectionResetError): + proc.stdin.write(stdin_data) + await proc.stdin.drain() + with contextlib.suppress(Exception): + proc.stdin.close() + + stdout_task = asyncio.create_task( + _drain(proc.stdout, cap_bytes=max_output_bytes, on_output=on_output) + ) + stderr_task = asyncio.create_task( + _drain(proc.stderr, cap_bytes=max_output_bytes, on_output=None) + ) + + timed_out = False + try: + await asyncio.wait_for(proc.wait(), timeout=max(1.0, float(timeout_seconds))) + except TimeoutError: + timed_out = True + await _terminate(proc) + + stdout_result, stderr_result = await asyncio.gather(stdout_task, stderr_task) + + return ProcessResult( + argv=cleaned, + exit_code=proc.returncode if proc.returncode is not None else -1, + stdout=stdout_result.data.decode("utf-8", "replace"), + stderr=stderr_result.data.decode("utf-8", "replace"), + duration_ms=int((time.perf_counter() - started) * 1000), + timed_out=timed_out, + truncated=stdout_result.truncated or stderr_result.truncated, + ) + + +__all__ = [ + "MAX_ARGV_ELEMENT_CHARS", + "ProcessError", + "ProcessResult", + "base_env", + "run_process", +] diff --git a/apps/api/app/infrastructure/adapters/registry.py b/apps/api/app/infrastructure/adapters/registry.py index 15470e6..34552d3 100644 --- a/apps/api/app/infrastructure/adapters/registry.py +++ b/apps/api/app/infrastructure/adapters/registry.py @@ -89,6 +89,13 @@ def get_sandbox_provider(): provider = settings.sandbox_provider.lower() if provider == "mock": return mock.LocalMockSandboxAdapter(max_output_bytes=settings.sandbox_max_output_bytes) + if provider == "docker": + # Local docker/podman. One instance per process so the workspace + # registry (external id → host dir) stays consistent between + # ``create_workspace`` and the ``start_session`` that follows it. + from app.infrastructure.adapters.docker_sandbox import DockerSandboxAdapter + + return DockerSandboxAdapter() if provider in {"daytona", "e2b"}: from app.infrastructure.adapters.sandbox_remote import build_remote_sandbox_adapter @@ -99,7 +106,8 @@ def get_sandbox_provider(): e2b_api_key=settings.e2b_api_key, ) raise ProviderUnavailableError( - f"SANDBOX_PROVIDER='{provider}' پشتیبانی نمی‌شود. مقادیر مجاز: mock | daytona | e2b" + f"SANDBOX_PROVIDER='{provider}' پشتیبانی نمی‌شود. " + "مقادیر مجاز: mock | docker | daytona | e2b" ) diff --git a/apps/api/app/infrastructure/repositories/sandbox.py b/apps/api/app/infrastructure/repositories/sandbox.py index a06eb31..cd42ec4 100644 --- a/apps/api/app/infrastructure/repositories/sandbox.py +++ b/apps/api/app/infrastructure/repositories/sandbox.py @@ -198,6 +198,29 @@ class SandboxSessionRepository: ) return [_session(r) for r in (await self.session.execute(stmt)).scalars()] + async def list_active_external_ids(self, *, limit: int = 5_000) -> set[str]: + """External ids of every session that is not stopped. + + The orphan reaper's query: a container whose id is not in this set has + no live row pointing at it, so it is holding resources for nobody. + """ + stmt = ( + select(SandboxSessionRow.external_session_id) + .where( + SandboxSessionRow.status.in_( + [ + str(SessionStatus.STARTING), + str(SessionStatus.RUNNING), + str(SessionStatus.PAUSED), + ] + ), + SandboxSessionRow.external_session_id.is_not(None), + ) + .limit(min(limit, 20_000)) + ) + rows = (await self.session.execute(stmt)).scalars().all() + return {str(value) for value in rows if value} + async def create(self, sandbox_session: SandboxSession) -> SandboxSession: row = SandboxSessionRow( id=sandbox_session.id, workspace_id=sandbox_session.workspace_id, diff --git a/apps/api/app/interfaces/http/routers/sandbox.py b/apps/api/app/interfaces/http/routers/sandbox.py index 21cd8d6..d8cacc7 100644 --- a/apps/api/app/interfaces/http/routers/sandbox.py +++ b/apps/api/app/interfaces/http/routers/sandbox.py @@ -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 ) diff --git a/apps/api/app/workers/__main__.py b/apps/api/app/workers/__main__.py index 865782b..8a00807 100644 --- a/apps/api/app/workers/__main__.py +++ b/apps/api/app/workers/__main__.py @@ -86,7 +86,7 @@ def _sandbox_gateway_pass(args: argparse.Namespace): gateway = SandboxGatewayWorker() async def one_pass() -> WorkerOutcome: - return await gateway.reap( + return await gateway.run_pass( idle_minutes=args.idle_minutes, limit=max(args.limit, 200) ) diff --git a/apps/api/app/workers/sandbox_gateway.py b/apps/api/app/workers/sandbox_gateway.py index 06a6b19..c07fc62 100644 --- a/apps/api/app/workers/sandbox_gateway.py +++ b/apps/api/app/workers/sandbox_gateway.py @@ -23,10 +23,19 @@ 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 +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 WorkspaceRepository +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. @@ -38,6 +47,22 @@ class SandboxGatewayWorker: 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: @@ -98,5 +123,86 @@ class SandboxGatewayWorker: 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"] diff --git a/apps/api/tests/test_docker_sandbox.py b/apps/api/tests/test_docker_sandbox.py new file mode 100644 index 0000000..a1bc262 --- /dev/null +++ b/apps/api/tests/test_docker_sandbox.py @@ -0,0 +1,588 @@ +"""§v2-A3.1 — the docker sandbox adapter is the hardened execution boundary. + +The tests here are almost entirely about *arguments*: which flags are passed, +which paths are mounted, which strings appear in ``argv``. That is deliberate. +The adapter's job is not to be clever, it is to be exactly right about a small +number of security-critical decisions, and the only way to hold that line +against future edits is to assert on the literal argument vector. + +A fake runner stands in for the container runtime, so the suite needs no docker +installed. The integration tests that *do* need it are marked ``docker`` and +skipped unless ``SANDBOX_DOCKER_IT=1``. +""" + +from __future__ import annotations + +import os + +import pytest +import pytest_asyncio +from httpx import AsyncClient + +from app.core.errors import PermissionDeniedError, ValidationError +from app.infrastructure.adapters.docker_sandbox import ( + LABEL_ORG, + LABEL_PROJECT, + LABEL_ROLE, + DockerSandboxAdapter, + PathEscapeError, + container_name, + image_for_language, + safe_join, +) +from app.infrastructure.adapters.process import ProcessResult, run_process +from tests.conftest import auth_header + +# --------------------------------------------------------------------------- +# A fake container runtime +# --------------------------------------------------------------------------- + + +class FakeRunner: + """Records every argv and replays scripted results in order. + + Responses are command-aware by default, because the adapter's flow depends + on the subcommand: ``ps`` decides whether a container already exists, + ``run`` reports the new container id, everything else is inert. A test that + needs something else queues an explicit response in ``responses``. + """ + + def __init__( + self, + *, + container_id: str = "cid123", + stdout: str = "", + ps_stdout: str = "", + exit_code: int = 0, + ) -> None: + self.calls: list[tuple[tuple[str, ...], dict]] = [] + self.responses: list[ProcessResult] = [] + self.container_id = container_id + self.stdout = stdout + self.ps_stdout = ps_stdout + self.exit_code = exit_code + + async def __call__(self, argv, **kwargs) -> ProcessResult: # noqa: ANN001, ANN003 + self.calls.append((tuple(argv), kwargs)) + if self.responses: + return self.responses.pop(0) + subcommand = argv[1] if len(argv) > 1 else "" + if subcommand == "ps": + out = self.ps_stdout + elif subcommand == "run": + out = f"{self.container_id}\n" + else: + out = self.stdout + return ProcessResult(argv=tuple(argv), exit_code=self.exit_code, stdout=out) + + @property + def last_argv(self) -> tuple[str, ...]: + return self.calls[-1][0] + + +def _adapter(tmp_path, runner: FakeRunner, **overrides) -> DockerSandboxAdapter: + kwargs = { + "provider_bin": "docker", + "workspaces_root": str(tmp_path), + "default_cpus": 2.0, + "default_memory_mb": 512, + } + kwargs.update(overrides) + return DockerSandboxAdapter(runner=runner, **kwargs) + + +# --------------------------------------------------------------------------- +# Image mapping +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("language", "expected"), + [ + ("node", "node:20-bookworm"), + ("python", "python:3.13-bookworm"), + ("php", "php:8.3-cli"), + ("go", "golang:1.23"), + ("static", "debian:bookworm"), + ], +) +def test_image_for_language_maps_the_known_languages(language: str, expected: str): + assert image_for_language(language) == expected + + +def test_image_for_an_unknown_language_falls_back(): + assert image_for_language("cobol") == "debian:bookworm" + assert image_for_language(None) == "debian:bookworm" + + +def test_container_name_is_deterministic_and_dns_safe(): + name = container_name("workspace", "org123", "proj456") + assert name == "smartorg-workspace-org123-proj456" + assert container_name("workspace", "org123", "proj456") == name + + +# --------------------------------------------------------------------------- +# Path safety +# --------------------------------------------------------------------------- + + +def test_safe_join_accepts_paths_inside_the_root(tmp_path): + root = tmp_path / "work" + root.mkdir() + assert safe_join(str(root), "src/main.py") == str(root / "src/main.py") + assert safe_join(str(root), "/src/main.py") == str(root / "src/main.py") + + +def test_safe_join_refuses_a_parent_escape(tmp_path): + root = tmp_path / "work" + root.mkdir() + with pytest.raises(PathEscapeError): + safe_join(str(root), "../secrets.env") + with pytest.raises(PathEscapeError): + safe_join(str(root), "a/b/../../../etc/passwd") + + +def test_safe_join_refuses_a_symlink_that_escapes(tmp_path): + """A symlink planted inside the work dir must not become a host read.""" + root = tmp_path / "work" + root.mkdir() + outside = tmp_path / "outside.txt" + outside.write_text("secret") + os.symlink(outside, root / "escape.txt") + + with pytest.raises(PathEscapeError): + safe_join(str(root), "escape.txt") + + +# --------------------------------------------------------------------------- +# create_workspace +# --------------------------------------------------------------------------- + + +async def test_create_workspace_makes_a_private_host_dir(tmp_path): + runner = FakeRunner() + adapter = _adapter(tmp_path, runner) + + info = await adapter.create_workspace( + organization_id="org1", project_id="proj1", template_id="t1", image="python:3.13-bookworm" + ) + + host_dir = tmp_path / "org1" / "proj1" / "work" + assert host_dir.is_dir(), "the bind-mount source must exist before `docker run`" + assert info.external_id == "ws-org1-proj1" + assert runner.calls == [], "creating a workspace must not shell out" + + +async def test_create_workspace_refuses_a_hostile_id(tmp_path): + adapter = _adapter(tmp_path, FakeRunner()) + with pytest.raises(ValidationError): + await adapter.create_workspace( + organization_id="../../etc", project_id="p", template_id="t", image="i" + ) + + +# --------------------------------------------------------------------------- +# start_session — the hardening flags are the whole point +# --------------------------------------------------------------------------- + + +async def test_start_session_passes_every_hardening_flag(tmp_path): + runner = FakeRunner(container_id="deadbeef") + adapter = _adapter(tmp_path, runner) + + info = await adapter.create_workspace( + organization_id="org1", project_id="proj1", template_id="t1", image="node:20-bookworm" + ) + session = await adapter.start_session(workspace_external_id=info.external_id) + + assert session.external_id == "deadbeef" + argv = runner.last_argv + assert argv[0] == "docker" + assert argv[1] == "run" + + # Isolation + assert "--cap-drop" in argv and "ALL" in argv + assert "--security-opt" in argv and "no-new-privileges" in argv + assert "--user" in argv and "1000:1000" in argv + assert "--pids-limit" in argv and "512" in argv + # Resource limits come from the adapter's configuration + assert "--cpus" in argv and "2.0" in argv + assert "--memory" in argv and "512m" in argv + # Immutable root with scratch space + assert "--read-only" in argv + assert argv.count("--tmpfs") == 2 + assert "/tmp" in argv and "/home/app" in argv + # Labels + assert f"{LABEL_ORG}=org1" in argv + assert f"{LABEL_PROJECT}=proj1" in argv + assert f"{LABEL_ROLE}=workspace" in argv + + +async def test_start_session_mounts_only_the_work_tree(tmp_path): + """The git dir must never be visible inside the container (D6).""" + runner = FakeRunner(container_id="cid") + adapter = _adapter(tmp_path, runner) + info = await adapter.create_workspace( + organization_id="org1", project_id="proj1", template_id="t1", image="node:20-bookworm" + ) + await adapter.start_session(workspace_external_id=info.external_id) + + argv = runner.last_argv + mounts = [argv[i + 1] for i, token in enumerate(argv) if token == "-v"] + assert mounts == [f"{tmp_path}/org1/proj1/work:/workspace"], ( + "exactly one bind mount, and it is the work tree" + ) + assert not any("git" in mount for mount in mounts) + + +async def test_start_session_publishes_no_port(tmp_path): + """A workspace container is reached with `exec`, never over the network.""" + runner = FakeRunner(container_id="cid") + adapter = _adapter(tmp_path, runner) + info = await adapter.create_workspace( + organization_id="org1", project_id="proj1", template_id="t1", image="node:20-bookworm" + ) + await adapter.start_session(workspace_external_id=info.external_id) + + argv = runner.last_argv + assert "-p" not in argv + assert "--publish" not in argv + assert not any(token.startswith("0.0.0.0") for token in argv) + + +async def test_start_session_reuses_an_existing_container(tmp_path): + """A restart must not stack duplicate containers for one project.""" + runner = FakeRunner() + runner.responses = [ + ProcessResult(argv=(), exit_code=0, stdout="existingcid\n"), # ps -a lookup + ProcessResult(argv=(), exit_code=0, stdout="existingcid\n"), # docker start + ] + adapter = _adapter(tmp_path, runner) + info = await adapter.create_workspace( + organization_id="org1", project_id="proj1", template_id="t1", image="node:20-bookworm" + ) + session = await adapter.start_session(workspace_external_id=info.external_id) + + assert session.external_id == "existingcid" + assert runner.calls[1][0][1] == "start" + assert all(call[0][1] != "run" for call in runner.calls), "must not run a second container" + + +# --------------------------------------------------------------------------- +# run_command +# --------------------------------------------------------------------------- + + +async def test_run_command_execs_inside_the_container_with_a_workdir(tmp_path): + runner = FakeRunner(stdout="ok\n") + adapter = _adapter(tmp_path, runner) + + result = await adapter.run_command( + session_external_id="cid", + command="pytest -q", + timeout_seconds=120, + max_output_bytes=10_000, + ) + + assert result.stdout == "ok\n" + argv = runner.last_argv + assert argv[:3] == ("docker", "exec", "-w") + assert argv[3] == "/workspace" + assert argv[-3:] == ("sh", "-lc", "pytest -q") + assert runner.calls[-1][1]["timeout_seconds"] == 120.0 + assert runner.calls[-1][1]["max_output_bytes"] == 10_000 + + +async def test_run_command_refuses_a_secret_in_the_environment(tmp_path): + """argv is world-readable via `ps`; credentials must never travel there.""" + adapter = _adapter(tmp_path, FakeRunner()) + with pytest.raises(ValidationError): + await adapter.run_command( + session_external_id="cid", + command="env", + timeout_seconds=30, + max_output_bytes=1_000, + env={"GIT_TOKEN": "ghp_aaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, + ) + + +async def test_run_command_allows_a_harmless_environment_variable(tmp_path): + runner = FakeRunner() + adapter = _adapter(tmp_path, runner) + await adapter.run_command( + session_external_id="cid", + command="node -v", + timeout_seconds=30, + max_output_bytes=1_000, + env={"NODE_ENV": "test"}, + ) + argv = runner.last_argv + assert "-e" in argv and "NODE_ENV=test" in argv + + +async def test_run_command_refuses_an_empty_command(tmp_path): + adapter = _adapter(tmp_path, FakeRunner()) + with pytest.raises(ValidationError): + await adapter.run_command( + session_external_id="cid", command=" ", timeout_seconds=10, max_output_bytes=100 + ) + + +# --------------------------------------------------------------------------- +# files +# --------------------------------------------------------------------------- + + +async def test_write_and_read_round_trip_through_the_bind_mount(tmp_path): + runner = FakeRunner(container_id="cid") + adapter = _adapter(tmp_path, runner) + info = await adapter.create_workspace( + organization_id="org1", project_id="proj1", template_id="t1", image="node:20-bookworm" + ) + await adapter.start_session(workspace_external_id=info.external_id) + + await adapter.write_file(session_external_id="cid", path="/workspace/src/a.ts", content="x") + assert await adapter.read_file(session_external_id="cid", path="/workspace/src/a.ts") == "x" + assert (tmp_path / "org1" / "proj1" / "work" / "src" / "a.ts").read_text() == "x" + + +async def test_read_file_refuses_an_escape(tmp_path): + runner = FakeRunner(container_id="cid") + adapter = _adapter(tmp_path, runner) + info = await adapter.create_workspace( + organization_id="org1", project_id="proj1", template_id="t1", image="node:20-bookworm" + ) + await adapter.start_session(workspace_external_id=info.external_id) + + with pytest.raises(PathEscapeError): + await adapter.read_file(session_external_id="cid", path="../../../../etc/passwd") + + +# --------------------------------------------------------------------------- +# lifecycle +# --------------------------------------------------------------------------- + + +async def test_destroy_removes_the_container_and_its_volumes(tmp_path): + runner = FakeRunner(container_id="cid") + adapter = _adapter(tmp_path, runner) + await adapter.destroy(session_external_id="cid") + + argv = runner.last_argv + assert argv[:3] == ("docker", "rm", "-f") + assert "-v" in argv + assert argv[-1] == "cid" + + +async def test_pause_and_resume_map_to_stop_and_start(tmp_path): + runner = FakeRunner() + adapter = _adapter(tmp_path, runner) + await adapter.pause(session_external_id="cid") + assert runner.last_argv[1] == "stop" + await adapter.resume(session_external_id="cid") + assert runner.last_argv[1] == "start" + + +async def test_preview_url_is_loopback_only(tmp_path): + adapter = _adapter(tmp_path, FakeRunner()) + assert await adapter.get_preview_url(session_external_id="cid", port=8123) == ( + "http://127.0.0.1:8123" + ) + + +async def test_preview_url_refuses_a_protected_platform_port(tmp_path): + from app.domain.platform.rules import RuleViolation + + adapter = _adapter(tmp_path, FakeRunner()) + with pytest.raises(RuleViolation): + await adapter.get_preview_url(session_external_id="cid", port=6379) + + +# --------------------------------------------------------------------------- +# orphan reaping +# --------------------------------------------------------------------------- + + +async def test_list_managed_containers_parses_labels(tmp_path): + runner = FakeRunner( + ps_stdout=( + "abc123\tctr-a\tUp 2 hours\t" + "smart-org.org=org1,smart-org.project=proj1,smart-org.role=workspace\n" + "def456\tctr-b\tExited (0)\t" + "smart-org.org=org2,smart-org.project=proj2,smart-org.role=demo\n" + ) + ) + adapter = _adapter(tmp_path, runner) + containers = await adapter.list_managed_containers() + + assert [c.container_id for c in containers] == ["abc123", "def456"] + assert containers[0].organization_id == "org1" + assert containers[1].role == "demo" + assert runner.last_argv[1] == "ps" + assert f"label={LABEL_ROLE}" in runner.last_argv + + +async def test_list_managed_containers_is_empty_when_the_cli_fails(tmp_path): + runner = FakeRunner(exit_code=1) + adapter = _adapter(tmp_path, runner) + assert await adapter.list_managed_containers() == [] + + +# --------------------------------------------------------------------------- +# the shared process runner (real subprocesses, no container needed) +# --------------------------------------------------------------------------- + + +async def test_run_process_caps_output_and_reports_truncation(): + result = await run_process( + ["sh", "-c", "yes abcdefgh | head -c 200000"], + timeout_seconds=30, + max_output_bytes=1_000, + ) + assert result.exit_code == 0 + assert result.truncated is True + assert len(result.stdout.encode()) <= 1_000 + + +async def test_run_process_kills_the_process_group_on_timeout(): + result = await run_process(["sh", "-c", "sleep 30"], timeout_seconds=1) + assert result.timed_out is True + assert result.ok is False + + +async def test_run_process_reports_a_missing_binary_instead_of_raising(): + result = await run_process(["definitely-not-a-real-binary-xyz"], timeout_seconds=5) + assert result.spawn_failed is True + assert result.exit_code == 127 + assert result.spawn_error == "FileNotFoundError" + + +async def test_run_process_does_not_inherit_the_parent_environment(monkeypatch): + """A child must not see the platform's secrets (PLAN-v2 §7.5).""" + monkeypatch.setenv("JWT_SECRET", "super-secret-value-not-for-children") + monkeypatch.setenv("DATABASE_URL", "postgres://user:pw@host/db") + + result = await run_process( + ["sh", "-c", "env"], timeout_seconds=10, max_output_bytes=50_000 + ) + assert "super-secret-value-not-for-children" not in result.stdout + assert "postgres://user:pw@host/db" not in result.stdout + + +async def test_run_process_streams_output_to_the_callback(): + chunks: list[str] = [] + + async def collect(chunk: str) -> None: + chunks.append(chunk) + + await run_process( + ["sh", "-c", "printf 'hello'; printf 'world'"], + timeout_seconds=10, + on_output=collect, + ) + assert "".join(chunks) == "helloworld" + + +# --------------------------------------------------------------------------- +# HTTP surface: infrastructure is owner-only +# --------------------------------------------------------------------------- + + +@pytest_asyncio.fixture(autouse=True) +async def _platform_seed(seeded): + return seeded + + +async def _owner_org(client: AsyncClient) -> tuple[str, dict]: + from app.domain.identity.models import PlatformRole + from tests.conftest import signup + + token = ( + await signup(client, "sandbox-owner@example.com", platform_role=PlatformRole.OWNER) + )["access_token"] + headers = auth_header(token) + org_id = ( + await client.post("/api/organizations", json={"name": "سازمان زیرساخت"}, headers=headers) + ).json()["id"] + return org_id, headers + + +async def _manager_org(client: AsyncClient) -> tuple[str, dict]: + from tests.conftest import signup + + token = (await signup(client, "sandbox-manager@example.com"))["access_token"] + headers = auth_header(token) + org_id = ( + await client.post("/api/organizations", json={"name": "سازمان مدیر"}, headers=headers) + ).json()["id"] + return org_id, headers + + +async def test_org_member_cannot_run_a_sandbox_command(client: AsyncClient): + """The org's own owner is still not allowed to touch infrastructure.""" + org_id, headers = await _manager_org(client) + template_id = ( + await client.get(f"/api/organizations/{org_id}/sandbox/templates", headers=headers) + ).json()[0]["id"] + + response = await client.post( + f"/api/organizations/{org_id}/sandbox/workspaces", + json={"project_id": "whatever", "template_id": template_id}, + headers=headers, + ) + assert response.status_code == 403, response.text + + +async def test_org_member_may_still_read_sandbox_status(client: AsyncClient): + """Templates and limits are status, not infrastructure.""" + org_id, headers = await _manager_org(client) + templates = await client.get( + f"/api/organizations/{org_id}/sandbox/templates", headers=headers + ) + assert templates.status_code == 200 + assert templates.json() + + limits = await client.get( + f"/api/organizations/{org_id}/sandbox/limits", headers=headers + ) + assert limits.status_code == 200 + assert limits.json()["timeout_seconds"] >= 1 + + +async def test_owner_may_create_a_workspace(client: AsyncClient): + org_id, headers = await _owner_org(client) + project_id = ( + await client.post( + f"/api/organizations/{org_id}/projects", + json={"name": "پروژهٔ زیرساخت"}, + headers=headers, + ) + ).json()["id"] + template_id = ( + await client.get(f"/api/organizations/{org_id}/sandbox/templates", headers=headers) + ).json()[0]["id"] + + response = await client.post( + f"/api/organizations/{org_id}/sandbox/workspaces", + json={"project_id": project_id, "template_id": template_id}, + headers=headers, + ) + assert response.status_code in (200, 201), response.text + + +async def test_the_owner_check_helper_rejects_a_manager(): + from app.domain.identity.models import Membership, PlatformRole, Role, User + from app.interfaces.http.routers.sandbox import _require_platform_owner + + class _Ctx: + def __init__(self, user): + self.user = user + + manager = User(email="m@x.com", password_hash="x", platform_role=PlatformRole.MANAGER) + with pytest.raises(PermissionDeniedError): + _require_platform_owner(_Ctx(manager)) + + owner = User(email="o@x.com", password_hash="x", platform_role=PlatformRole.OWNER) + _require_platform_owner(_Ctx(owner)) # must not raise + + _ = (Membership, Role) diff --git a/apps/api/tests/test_e2e_smoke.py b/apps/api/tests/test_e2e_smoke.py index 295cb71..5cac516 100644 --- a/apps/api/tests/test_e2e_smoke.py +++ b/apps/api/tests/test_e2e_smoke.py @@ -23,6 +23,7 @@ from __future__ import annotations import pytest_asyncio from httpx import AsyncClient +from app.domain.identity.models import PlatformRole from tests.conftest import auth_header, signup @@ -34,7 +35,14 @@ async def _platform_seed(seeded): async def test_end_to_end_smoke_path(client: AsyncClient): # -- 1. signup -------------------------------------------------------- # - signup_body = await signup(client, "smoke@example.com", name="کاربر دود") + # The platform owner, not a plain manager: step 6 drives the sandbox + # directly, and v2 makes container/command access owner-only + # (PLAN-v2 §7.7). The org-scoped prefix still scopes the tenant and the + # audit trail, so this is the same path a customer walks minus the + # infrastructure step. + signup_body = await signup( + client, "smoke@example.com", name="کاربر دود", platform_role=PlatformRole.OWNER + ) token = signup_body["access_token"] headers = auth_header(token) diff --git a/apps/api/tests/test_workers.py b/apps/api/tests/test_workers.py index f7da564..1bf3dc4 100644 --- a/apps/api/tests/test_workers.py +++ b/apps/api/tests/test_workers.py @@ -21,6 +21,7 @@ import pytest_asyncio from httpx import AsyncClient from app.domain.base import utcnow +from app.domain.identity.models import PlatformRole from app.workers import base as worker_base from app.workers.ai_runtime import AiRuntimeWorker from app.workers.billing import BillingWorker @@ -48,9 +49,18 @@ async def _workers_use_the_test_database(monkeypatch, session_factory): return session_factory -async def _make_org_with_employee(client: AsyncClient, email: str) -> tuple[str, dict, str, str]: - """Return ``(org_id, headers, project_id, employee_id)`` for a fresh tenant.""" - token = (await signup(client, email))["access_token"] +async def _make_org_with_employee( + client: AsyncClient, + email: str, + *, + platform_role: PlatformRole = PlatformRole.MANAGER, +) -> tuple[str, dict, str, str]: + """Return ``(org_id, headers, project_id, employee_id)`` for a fresh tenant. + + ``platform_role`` is a parameter because the sandbox-gateway tests drive + container endpoints, which v2 restricts to the platform owner. + """ + token = (await signup(client, email, platform_role=platform_role))["access_token"] headers = auth_header(token) org_id = ( await client.post("/api/organizations", json={"name": "سازمان کارگر"}, headers=headers) @@ -249,7 +259,7 @@ async def test_billing_roll_up_refuses_a_nonsense_period(delta: timedelta): async def test_sandbox_gateway_stops_an_idle_session_and_audits_it(client: AsyncClient): """An abandoned session must be stopped, and the stop must be auditable.""" org_id, headers, project_id, _ = await _make_org_with_employee( - client, "worker-gateway@example.com" + client, "worker-gateway@example.com", platform_role=PlatformRole.OWNER ) template_id = ( await client.get(f"/api/organizations/{org_id}/sandbox/templates", headers=headers) @@ -301,7 +311,7 @@ async def test_sandbox_gateway_stops_an_idle_session_and_audits_it(client: Async async def test_sandbox_gateway_leaves_a_fresh_session_alone(client: AsyncClient): """A session that was just used must not be reaped.""" org_id, headers, project_id, _ = await _make_org_with_employee( - client, "worker-gateway-fresh@example.com" + client, "worker-gateway-fresh@example.com", platform_role=PlatformRole.OWNER ) template_id = ( await client.get(f"/api/organizations/{org_id}/sandbox/templates", headers=headers) diff --git a/apps/web/src/app/(app)/projects/[id]/page.tsx b/apps/web/src/app/(app)/projects/[id]/page.tsx index 6c373e0..099ea95 100644 --- a/apps/web/src/app/(app)/projects/[id]/page.tsx +++ b/apps/web/src/app/(app)/projects/[id]/page.tsx @@ -34,7 +34,7 @@ export default function ProjectDetailPage({ params: Promise<{ id: string }>; }) { const { id: projectId } = React.use(params); - const { activeOrganization } = useSession(); + const { activeOrganization, user } = useSession(); const [tab, setTab] = React.useState("repo"); const [project, setProject] = React.useState(null); @@ -136,7 +136,11 @@ export default function ProjectDetailPage({ ) : tab === "preview" ? ( ) : ( - + )} ); diff --git a/apps/web/src/components/sandbox-panel.tsx b/apps/web/src/components/sandbox-panel.tsx index 5158d16..892f7b4 100644 --- a/apps/web/src/components/sandbox-panel.tsx +++ b/apps/web/src/components/sandbox-panel.tsx @@ -10,6 +10,10 @@ * tampered client cannot widen them. * 2. It shows no shell/exec affordance for the platform host. The only execution * surface is a workspace session, which is isolated and metered. + * 3. Containers, ports and command output are *infrastructure*. In v2 only the + * platform owner may touch them (PLAN-v2 §7.7), so a non-owner sees status + * only: no create form, no session, no action buttons. The read-only routes + * stay open to org members, which is what keeps this panel useful to them. */ import * as React from "react"; @@ -58,7 +62,16 @@ function humanBytes(bytes: number): string { return `${bytes} بایت`; } -export function SandboxPanel({ orgId, projectId }: { orgId: string; projectId: string }) { +export function SandboxPanel({ + orgId, + projectId, + canManageInfra = false, +}: { + orgId: string; + projectId: string; + /** True only for the platform owner. Gates every mutating control. */ + canManageInfra?: boolean; +}) { const [templates, setTemplates] = React.useState([]); const [workspaces, setWorkspaces] = React.useState([]); const [limits, setLimits] = React.useState(null); @@ -177,41 +190,51 @@ export function SandboxPanel({ orgId, projectId }: { orgId: string; projectId: s )} - - ساخت محیط ایزوله - - کد در یک محیط جداگانه اجرا می‌شود؛ هیچ دستوری روی سرور میزبان اجرا نمی‌گردد. هر - دستور در دفتر مصرف ثبت می‌شود. - - {templates.length === 0 ? ( -

قالبی در دسترس نیست.

- ) : ( -
-
- setTemplateId(e.target.value)} + options={templates.map((template) => ({ + value: template.id, + label: `${template.name} — ${template.image}`, + }))} + /> +
+ +
+ )} + {templates.length > 0 && ( +

+ {templates.find((t) => t.id === templateId)?.description ?? ""} +

+ )} +
+ ) : ( + + محیط اجرا + + زیرساخت (کانتینر، پورت و اجرای دستور) تنها توسط مالک سامانه مدیریت می‌شود. برای + اجرای کارها کافی است به مدیر هوش مصنوعی دستور بدهید. + + + )} محیط‌های این پروژه @@ -242,23 +265,25 @@ export function SandboxPanel({ orgId, projectId }: { orgId: string; projectId: s ارائه‌دهنده: {workspace.provider}

-
- - -
+ {canManageInfra && ( +
+ + +
+ )} ); })} @@ -266,7 +291,7 @@ export function SandboxPanel({ orgId, projectId }: { orgId: string; projectId: s )}
- {activeWorkspace && session && ( + {canManageInfra && activeWorkspace && session && (