"""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 run_step( self, *, organization_id: str, project_id: str, run_id: str, image: str, work_dir: str, command: str, timeout_seconds: float, on_output: Callable[[str], Awaitable[None]] | None = None, role: str = ROLE_BUILD, publish_port: int | None = None, app_port: int | None = None, keep_alive: bool = False, name_suffix: str = "", ) -> ProcessResult: """Run one pipeline step in a **fresh** container over ``work_dir``. A fresh container per step is the point: a build must not be able to inherit a process, a cache or a stray file from the previous step or from an employee's interactive workspace. ``--rm`` guarantees nothing is left behind on the host. ``publish_port`` publishes on ``127.0.0.1`` only. ``keep_alive`` starts the container detached so it outlives this call — that is how the demo container is created; a build step is run in the foreground so its output streams straight back. """ org = _check_id(organization_id, "سازمان") project = _check_id(project_id, "پروژه") safe_run = re.sub(r"[^a-z0-9]", "", (run_id or "").lower())[:12] or "run" name = f"smartorg-{role}-{org}-{project}-{safe_run}" if name_suffix: name = f"{name}-{_SNAPSHOT_NAME.sub('-', name_suffix.lower()).strip('-')}" name = name[:200] args: list[str] = ["run"] if keep_alive: args.append("-d") else: args.extend(["--rm"]) args.extend( [ "--name", name, "--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", "--read-only", "--tmpfs", "/tmp", "--tmpfs", "/home/app", "-v", f"{work_dir}:{SANDBOX_WORKSPACE_PATH}", "-w", SANDBOX_WORKSPACE_PATH, ] ) if publish_port is not None: assert_not_protected_port(int(publish_port)) # Loopback publish only — never 0.0.0.0. args.extend( [ "-p", f"127.0.0.1:{int(publish_port)}:{int(app_port or publish_port)}", ] ) args.extend( [ "--label", f"{LABEL_ORG}={org}", "--label", f"{LABEL_PROJECT}={project}", "--label", f"{LABEL_ROLE}={role}", image or FALLBACK_IMAGE, "sh", "-lc", command or _KEEPALIVE, ] ) return await self._runner( [self.bin, *args], timeout_seconds=float(timeout_seconds), max_output_bytes=self.max_output_bytes, on_output=on_output, env={}, ) 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", ]