| """Docker execution environment for sandboxed command execution. |
| |
| Security hardened (cap-drop ALL, no-new-privileges, PID limits), |
| configurable resource limits (CPU, memory, disk), and optional filesystem |
| persistence via bind mounts. |
| """ |
|
|
| import logging |
| import os |
| import re |
| import shutil |
| import subprocess |
| import sys |
| import threading |
| import time |
| import uuid |
| from typing import Optional |
|
|
| from tools.environments.base import BaseEnvironment |
| from tools.interrupt import is_interrupted |
|
|
| logger = logging.getLogger(__name__) |
|
|
|
|
| |
| |
| |
| _DOCKER_SEARCH_PATHS = [ |
| "/usr/local/bin/docker", |
| "/opt/homebrew/bin/docker", |
| "/Applications/Docker.app/Contents/Resources/bin/docker", |
| ] |
|
|
| _docker_executable: Optional[str] = None |
| _ENV_VAR_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") |
|
|
|
|
| def _normalize_forward_env_names(forward_env: list[str] | None) -> list[str]: |
| """Return a deduplicated list of valid environment variable names.""" |
| normalized: list[str] = [] |
| seen: set[str] = set() |
|
|
| for item in forward_env or []: |
| if not isinstance(item, str): |
| logger.warning("Ignoring non-string docker_forward_env entry: %r", item) |
| continue |
|
|
| key = item.strip() |
| if not key: |
| continue |
| if not _ENV_VAR_NAME_RE.match(key): |
| logger.warning("Ignoring invalid docker_forward_env entry: %r", item) |
| continue |
| if key in seen: |
| continue |
|
|
| seen.add(key) |
| normalized.append(key) |
|
|
| return normalized |
|
|
|
|
| def _load_hermes_env_vars() -> dict[str, str]: |
| """Load ~/.hermes/.env values without failing Docker command execution.""" |
| try: |
| from hermes_cli.config import load_env |
|
|
| return load_env() or {} |
| except Exception: |
| return {} |
|
|
|
|
| def find_docker() -> Optional[str]: |
| """Locate the docker CLI binary. |
| |
| Checks ``shutil.which`` first (respects PATH), then probes well-known |
| install locations on macOS where Docker Desktop may not be in PATH |
| (e.g. when running as a gateway service via launchd). |
| |
| Returns the absolute path, or ``None`` if docker cannot be found. |
| """ |
| global _docker_executable |
| if _docker_executable is not None: |
| return _docker_executable |
|
|
| found = shutil.which("docker") |
| if found: |
| _docker_executable = found |
| return found |
|
|
| for path in _DOCKER_SEARCH_PATHS: |
| if os.path.isfile(path) and os.access(path, os.X_OK): |
| _docker_executable = path |
| logger.info("Found docker at non-PATH location: %s", path) |
| return path |
|
|
| return None |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| _SECURITY_ARGS = [ |
| "--cap-drop", "ALL", |
| "--cap-add", "DAC_OVERRIDE", |
| "--cap-add", "CHOWN", |
| "--cap-add", "FOWNER", |
| "--security-opt", "no-new-privileges", |
| "--pids-limit", "256", |
| "--tmpfs", "/tmp:rw,nosuid,size=512m", |
| "--tmpfs", "/var/tmp:rw,noexec,nosuid,size=256m", |
| "--tmpfs", "/run:rw,noexec,nosuid,size=64m", |
| ] |
|
|
|
|
| _storage_opt_ok: Optional[bool] = None |
|
|
|
|
| def _ensure_docker_available() -> None: |
| """Best-effort check that the docker CLI is available before use. |
| |
| Reuses ``find_docker()`` so this preflight stays consistent with the rest of |
| the Docker backend, including known non-PATH Docker Desktop locations. |
| """ |
| docker_exe = find_docker() |
| if not docker_exe: |
| logger.error( |
| "Docker backend selected but no docker executable was found in PATH " |
| "or known install locations. Install Docker Desktop and ensure the " |
| "CLI is available." |
| ) |
| raise RuntimeError( |
| "Docker executable not found in PATH or known install locations. " |
| "Install Docker and ensure the 'docker' command is available." |
| ) |
|
|
| try: |
| result = subprocess.run( |
| [docker_exe, "version"], |
| capture_output=True, |
| text=True, |
| timeout=5, |
| ) |
| except FileNotFoundError: |
| logger.error( |
| "Docker backend selected but the resolved docker executable '%s' could " |
| "not be executed.", |
| docker_exe, |
| exc_info=True, |
| ) |
| raise RuntimeError( |
| "Docker executable could not be executed. Check your Docker installation." |
| ) |
| except subprocess.TimeoutExpired: |
| logger.error( |
| "Docker backend selected but '%s version' timed out. " |
| "The Docker daemon may not be running.", |
| docker_exe, |
| exc_info=True, |
| ) |
| raise RuntimeError( |
| "Docker daemon is not responding. Ensure Docker is running and try again." |
| ) |
| except Exception: |
| logger.error( |
| "Unexpected error while checking Docker availability.", |
| exc_info=True, |
| ) |
| raise |
| else: |
| if result.returncode != 0: |
| logger.error( |
| "Docker backend selected but '%s version' failed " |
| "(exit code %d, stderr=%s)", |
| docker_exe, |
| result.returncode, |
| result.stderr.strip(), |
| ) |
| raise RuntimeError( |
| "Docker command is available but 'docker version' failed. " |
| "Check your Docker installation." |
| ) |
|
|
|
|
| class DockerEnvironment(BaseEnvironment): |
| """Hardened Docker container execution with resource limits and persistence. |
| |
| Security: all capabilities dropped, no privilege escalation, PID limits, |
| size-limited tmpfs for scratch dirs. The container itself is the security |
| boundary — the filesystem inside is writable so agents can install packages |
| (pip, npm, apt) as needed. Writable workspace via tmpfs or bind mounts. |
| |
| Persistence: when enabled, bind mounts preserve /workspace and /root |
| across container restarts. |
| """ |
|
|
| def __init__( |
| self, |
| image: str, |
| cwd: str = "/root", |
| timeout: int = 60, |
| cpu: float = 0, |
| memory: int = 0, |
| disk: int = 0, |
| persistent_filesystem: bool = False, |
| task_id: str = "default", |
| volumes: list = None, |
| forward_env: list[str] | None = None, |
| network: bool = True, |
| host_cwd: str = None, |
| auto_mount_cwd: bool = False, |
| ): |
| if cwd == "~": |
| cwd = "/root" |
| super().__init__(cwd=cwd, timeout=timeout) |
| self._base_image = image |
| self._persistent = persistent_filesystem |
| self._task_id = task_id |
| self._forward_env = _normalize_forward_env_names(forward_env) |
| self._container_id: Optional[str] = None |
| logger.info(f"DockerEnvironment volumes: {volumes}") |
| |
| if volumes is not None and not isinstance(volumes, list): |
| logger.warning(f"docker_volumes config is not a list: {volumes!r}") |
| volumes = [] |
|
|
| |
| _ensure_docker_available() |
|
|
| |
| resource_args = [] |
| if cpu > 0: |
| resource_args.extend(["--cpus", str(cpu)]) |
| if memory > 0: |
| resource_args.extend(["--memory", f"{memory}m"]) |
| if disk > 0 and sys.platform != "darwin": |
| if self._storage_opt_supported(): |
| resource_args.extend(["--storage-opt", f"size={disk}m"]) |
| else: |
| logger.warning( |
| "Docker storage driver does not support per-container disk limits " |
| "(requires overlay2 on XFS with pquota). Container will run without disk quota." |
| ) |
| if not network: |
| resource_args.append("--network=none") |
|
|
| |
| |
| |
| from tools.environments.base import get_sandbox_dir |
|
|
| |
| volume_args = [] |
| workspace_explicitly_mounted = False |
| for vol in (volumes or []): |
| if not isinstance(vol, str): |
| logger.warning(f"Docker volume entry is not a string: {vol!r}") |
| continue |
| vol = vol.strip() |
| if not vol: |
| continue |
| if ":" in vol: |
| volume_args.extend(["-v", vol]) |
| if ":/workspace" in vol: |
| workspace_explicitly_mounted = True |
| else: |
| logger.warning(f"Docker volume '{vol}' missing colon, skipping") |
|
|
| host_cwd_abs = os.path.abspath(os.path.expanduser(host_cwd)) if host_cwd else "" |
| bind_host_cwd = ( |
| auto_mount_cwd |
| and bool(host_cwd_abs) |
| and os.path.isdir(host_cwd_abs) |
| and not workspace_explicitly_mounted |
| ) |
| if auto_mount_cwd and host_cwd and not os.path.isdir(host_cwd_abs): |
| logger.debug(f"Skipping docker cwd mount: host_cwd is not a valid directory: {host_cwd}") |
|
|
| self._workspace_dir: Optional[str] = None |
| self._home_dir: Optional[str] = None |
| writable_args = [] |
| if self._persistent: |
| sandbox = get_sandbox_dir() / "docker" / task_id |
| self._home_dir = str(sandbox / "home") |
| os.makedirs(self._home_dir, exist_ok=True) |
| writable_args.extend([ |
| "-v", f"{self._home_dir}:/root", |
| ]) |
| if not bind_host_cwd and not workspace_explicitly_mounted: |
| self._workspace_dir = str(sandbox / "workspace") |
| os.makedirs(self._workspace_dir, exist_ok=True) |
| writable_args.extend([ |
| "-v", f"{self._workspace_dir}:/workspace", |
| ]) |
| else: |
| if not bind_host_cwd and not workspace_explicitly_mounted: |
| writable_args.extend([ |
| "--tmpfs", "/workspace:rw,exec,size=10g", |
| ]) |
| writable_args.extend([ |
| "--tmpfs", "/home:rw,exec,size=1g", |
| "--tmpfs", "/root:rw,exec,size=1g", |
| ]) |
|
|
| if bind_host_cwd: |
| logger.info(f"Mounting configured host cwd to /workspace: {host_cwd_abs}") |
| volume_args = ["-v", f"{host_cwd_abs}:/workspace", *volume_args] |
| elif workspace_explicitly_mounted: |
| logger.debug("Skipping docker cwd mount: /workspace already mounted by user config") |
|
|
| logger.info(f"Docker volume_args: {volume_args}") |
| all_run_args = list(_SECURITY_ARGS) + writable_args + resource_args + volume_args |
| logger.info(f"Docker run_args: {all_run_args}") |
|
|
| |
| |
| self._docker_exe = find_docker() or "docker" |
|
|
| |
| container_name = f"hermes-{uuid.uuid4().hex[:8]}" |
| run_cmd = [ |
| self._docker_exe, "run", "-d", |
| "--name", container_name, |
| "-w", cwd, |
| *all_run_args, |
| image, |
| "sleep", "2h", |
| ] |
| logger.debug(f"Starting container: {' '.join(run_cmd)}") |
| result = subprocess.run( |
| run_cmd, |
| capture_output=True, |
| text=True, |
| timeout=120, |
| check=True, |
| ) |
| self._container_id = result.stdout.strip() |
| logger.info(f"Started container {container_name} ({self._container_id[:12]})") |
|
|
| @staticmethod |
| def _storage_opt_supported() -> bool: |
| """Check if Docker's storage driver supports --storage-opt size=. |
| |
| Only overlay2 on XFS with pquota supports per-container disk quotas. |
| Ubuntu (and most distros) default to ext4, where this flag errors out. |
| """ |
| global _storage_opt_ok |
| if _storage_opt_ok is not None: |
| return _storage_opt_ok |
| try: |
| docker = find_docker() or "docker" |
| result = subprocess.run( |
| [docker, "info", "--format", "{{.Driver}}"], |
| capture_output=True, text=True, timeout=10, |
| ) |
| driver = result.stdout.strip().lower() |
| if driver != "overlay2": |
| _storage_opt_ok = False |
| return False |
| |
| |
| probe = subprocess.run( |
| [docker, "create", "--storage-opt", "size=1m", "hello-world"], |
| capture_output=True, text=True, timeout=15, |
| ) |
| if probe.returncode == 0: |
| |
| container_id = probe.stdout.strip() |
| if container_id: |
| subprocess.run([docker, "rm", container_id], |
| capture_output=True, timeout=5) |
| _storage_opt_ok = True |
| else: |
| _storage_opt_ok = False |
| except Exception: |
| _storage_opt_ok = False |
| logger.debug("Docker --storage-opt support: %s", _storage_opt_ok) |
| return _storage_opt_ok |
|
|
| def execute(self, command: str, cwd: str = "", *, |
| timeout: int | None = None, |
| stdin_data: str | None = None) -> dict: |
| exec_command, sudo_stdin = self._prepare_command(command) |
| work_dir = cwd or self.cwd |
| effective_timeout = timeout or self.timeout |
|
|
| |
| if sudo_stdin is not None and stdin_data is not None: |
| effective_stdin = sudo_stdin + stdin_data |
| elif sudo_stdin is not None: |
| effective_stdin = sudo_stdin |
| else: |
| effective_stdin = stdin_data |
|
|
| |
| if work_dir == "~" or work_dir.startswith("~/"): |
| exec_command = f"cd {work_dir} && {exec_command}" |
| work_dir = "/" |
|
|
| assert self._container_id, "Container not started" |
| cmd = [self._docker_exe, "exec"] |
| if effective_stdin is not None: |
| cmd.append("-i") |
| cmd.extend(["-w", work_dir]) |
| hermes_env = _load_hermes_env_vars() if self._forward_env else {} |
| for key in self._forward_env: |
| value = os.getenv(key) |
| if value is None: |
| value = hermes_env.get(key) |
| if value is not None: |
| cmd.extend(["-e", f"{key}={value}"]) |
| cmd.extend([self._container_id, "bash", "-lc", exec_command]) |
|
|
| try: |
| _output_chunks = [] |
| proc = subprocess.Popen( |
| cmd, |
| stdout=subprocess.PIPE, stderr=subprocess.STDOUT, |
| stdin=subprocess.PIPE if effective_stdin else subprocess.DEVNULL, |
| text=True, |
| ) |
| if effective_stdin: |
| try: |
| proc.stdin.write(effective_stdin) |
| proc.stdin.close() |
| except Exception: |
| pass |
|
|
| def _drain(): |
| try: |
| for line in proc.stdout: |
| _output_chunks.append(line) |
| except Exception: |
| pass |
|
|
| reader = threading.Thread(target=_drain, daemon=True) |
| reader.start() |
| deadline = time.monotonic() + effective_timeout |
|
|
| while proc.poll() is None: |
| if is_interrupted(): |
| proc.terminate() |
| try: |
| proc.wait(timeout=1) |
| except subprocess.TimeoutExpired: |
| proc.kill() |
| reader.join(timeout=2) |
| return { |
| "output": "".join(_output_chunks) + "\n[Command interrupted]", |
| "returncode": 130, |
| } |
| if time.monotonic() > deadline: |
| proc.kill() |
| reader.join(timeout=2) |
| return self._timeout_result(effective_timeout) |
| time.sleep(0.2) |
|
|
| reader.join(timeout=5) |
| return {"output": "".join(_output_chunks), "returncode": proc.returncode} |
| except Exception as e: |
| return {"output": f"Docker execution error: {e}", "returncode": 1} |
|
|
| def cleanup(self): |
| """Stop and remove the container. Bind-mount dirs persist if persistent=True.""" |
| if self._container_id: |
| try: |
| |
| stop_cmd = ( |
| f"(timeout 60 {self._docker_exe} stop {self._container_id} || " |
| f"{self._docker_exe} rm -f {self._container_id}) >/dev/null 2>&1 &" |
| ) |
| subprocess.Popen(stop_cmd, shell=True) |
| except Exception as e: |
| logger.warning("Failed to stop container %s: %s", self._container_id, e) |
|
|
| if not self._persistent: |
| |
| try: |
| subprocess.Popen( |
| f"sleep 3 && {self._docker_exe} rm -f {self._container_id} >/dev/null 2>&1 &", |
| shell=True, |
| ) |
| except Exception: |
| pass |
| self._container_id = None |
|
|
| if not self._persistent: |
| for d in (self._workspace_dir, self._home_dir): |
| if d: |
| shutil.rmtree(d, ignore_errors=True) |
|
|