From cd51258fb58651ca9cda47b63f917960ed0c700f Mon Sep 17 00:00:00 2001 From: Matte23 Date: Thu, 2 Apr 2026 13:24:07 +0200 Subject: [PATCH] feat: Remove MCP specific code --- README.md | 45 +++++++---- docker_agent_sandbox/sandbox.py | 133 ++++++++++++++++---------------- 2 files changed, 93 insertions(+), 85 deletions(-) diff --git a/README.md b/README.md index d16f4d2..00e9f08 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,9 @@ A Docker-based sandbox and LangChain tool set for LLM agents that need a safe, i ## What it provides -- **`DockerSandbox`** — manages a single long-running Docker container: lifecycle (`start`/`stop`), file writes via the tar archive API, and command execution with timeout enforcement. +- **`DockerSandbox`** — manages a single long-running Docker container: lifecycle (`start`/`stop`), file writes via the tar archive API, and command execution with timeout enforcement. Volumes, ports, environment variables, and capabilities are all caller-supplied. - **LangChain tools** — a ready-to-use set of tools that expose the sandbox to an agent: `bash`, `read_file`, `write_file`, `edit_file`, `list_dir`, `delete_file`, `move_file`, `copy_file`, `make_dir`, `search_files`, `grep`. -The sandbox directory is bind-mounted inside the container, so the agent works with stable paths regardless of where the sandbox lives on the host. - ## Requirements - Python ≥ 3.11 @@ -37,18 +35,29 @@ docker-agent-sandbox = { path = "../docker-agent-sandbox" } from docker_agent_sandbox import DockerSandbox sandbox = DockerSandbox( - sandbox_dir="/tmp/my-sandbox", # bind-mounted into the container container_name="my-agent-run", - container_workdir="/workspace", # path inside the container image="my-agent-image", dockerfile_dir="/path/to/dockerfile", # only needed if building the image + volumes={"/host/sandbox": {"bind": "/workspace", "mode": "rw"}}, + ports={"8080/tcp": None}, # Docker assigns a random host port + environment={"MY_VAR": "value"}, + working_dir="/workspace", + cap_drop=["ALL"], + cap_add=["SYS_PTRACE"], + security_opt=["no-new-privileges"], ) sandbox.build_image_if_missing() sandbox.start() ``` -### 2. Run commands and write files +### 2. Inspect mapped ports + +```python +host_port = sandbox.get_host_port("8080/tcp") +``` + +### 3. Run commands and write files ```python exit_code, output = sandbox.exec("ls -la") @@ -56,7 +65,7 @@ sandbox.write_file("/workspace/hello.py", "print('hello')") exit_code, output = sandbox.exec("python /workspace/hello.py") ``` -### 3. Bind tools to a LangChain agent +### 4. Bind tools to a LangChain agent ```python from docker_agent_sandbox import make_bash_tool, make_file_ops_tools @@ -66,7 +75,7 @@ tools = make_file_ops_tools(sandbox) + [make_bash_tool(sandbox)] # Pass `tools` to your LangChain / LangGraph agent as usual. ``` -### 4. Tear down +### 5. Tear down ```python sandbox.stop() @@ -74,24 +83,30 @@ sandbox.stop() ## API reference -### `DockerSandbox(sandbox_dir, container_name, container_workdir, pin_mcp_port, image, dockerfile_dir)` +### `DockerSandbox(container_name, image, dockerfile_dir, volumes, ports, environment, working_dir, network_mode, cap_drop, cap_add, security_opt)` | Parameter | Default | Description | |---|---|---| -| `sandbox_dir` | — | Host directory bind-mounted into the container | | `container_name` | — | Docker container name | -| `container_workdir` | `"/workspace"` | Working directory inside the container | -| `pin_mcp_port` | `8080` | Port exposed by a sidecar MCP server (if any) | | `image` | `"docker-agent-sandbox"` | Docker image tag to run | | `dockerfile_dir` | `None` | Directory containing the `Dockerfile`; required only if calling `build_image_if_missing` | +| `volumes` | `{}` | Volume bindings passed directly to Docker (same format as `docker-py`) | +| `ports` | `{}` | Port bindings passed directly to Docker, e.g. `{"8080/tcp": None}` for a random host port | +| `environment` | `{}` | Environment variables injected into the container | +| `working_dir` | `None` | Default working directory for `exec` calls (uses image `WORKDIR` if omitted) | +| `network_mode` | `"bridge"` | Docker network mode | +| `cap_drop` | `None` | Linux capabilities to drop | +| `cap_add` | `None` | Linux capabilities to add | +| `security_opt` | `None` | Docker security options | #### Methods | Method | Description | |---|---| | `build_image_if_missing()` | Builds the image from `dockerfile_dir` if not already present locally | -| `start()` | Removes any existing container with the same name, starts a fresh one, and waits for the MCP port | +| `start()` | Removes any existing container with the same name and starts a fresh one | | `stop()` | Removes the container | +| `get_host_port(container_port)` | Returns the host port Docker mapped to `container_port` (e.g. `"8080/tcp"`) | | `exec(command, timeout=120)` | Runs a bash command; returns `(exit_code, stdout+stderr)` | | `write_file(path, content)` | Writes a UTF-8 file inside the container using the tar archive API | @@ -111,7 +126,3 @@ sandbox.stop() | `make_make_dir_tool(sandbox)` | `make_dir` | `mkdir -p` | | `make_search_files_tool(sandbox)` | `search_files` | `find` by name glob | | `make_grep_tool(sandbox)` | `grep` | Extended-regex search with optional recursion | - -## Security notes - -Containers are started with `cap_drop=ALL`, `cap_add=SYS_PTRACE` (needed for debuggers/tracers), and `no-new-privileges`. Network mode is `bridge` with no explicit outbound rules — restrict further via Docker network policies if needed. diff --git a/docker_agent_sandbox/sandbox.py b/docker_agent_sandbox/sandbox.py index c33bcaf..374e93b 100644 --- a/docker_agent_sandbox/sandbox.py +++ b/docker_agent_sandbox/sandbox.py @@ -4,9 +4,7 @@ from __future__ import annotations import concurrent.futures import io -import socket import tarfile -import time from pathlib import Path from typing import TYPE_CHECKING @@ -20,30 +18,37 @@ if TYPE_CHECKING: class DockerSandbox: """ - Manages a single long-running Docker container used as the bash execution - environment for an LLM agent. + Manages a single long-running Docker container. - The sandbox directory is bind-mounted at *container_workdir* inside the - container (default ``/workspace``), giving the model a stable, short path - regardless of where the sandbox lives on the host. + All container configuration (volumes, ports, environment variables, + capabilities) is supplied by the caller. """ def __init__( self, - sandbox_dir: str, container_name: str, - container_workdir: str = "/workspace", - pin_mcp_port: int = 8080, image: str = "docker-agent-sandbox", dockerfile_dir: str | None = None, + volumes: dict | None = None, + ports: dict | None = None, + environment: dict[str, str] | None = None, + working_dir: str | None = None, + network_mode: str = "bridge", + cap_drop: list[str] | None = None, + cap_add: list[str] | None = None, + security_opt: list[str] | None = None, ) -> None: - self.sandbox_dir = sandbox_dir self.container_name = container_name - self.container_workdir = container_workdir - self.pin_mcp_port = pin_mcp_port - self.mcp_url: str = "" self._image = image self._dockerfile_dir = dockerfile_dir + self._volumes = volumes or {} + self._ports = ports or {} + self._environment = environment or {} + self._working_dir = working_dir + self._network_mode = network_mode + self._cap_drop = cap_drop + self._cap_add = cap_add + self._security_opt = security_opt self._client: docker.DockerClient = docker.from_env() self._container: docker.models.containers.Container | None = None @@ -61,9 +66,7 @@ class DockerSandbox: pass if self._dockerfile_dir is None: - raise ValueError( - "dockerfile_dir must be provided to build the image" - ) + raise ValueError("dockerfile_dir must be provided to build the image") logger.info("Building image {!r} from {} …", self._image, self._dockerfile_dir) _, logs = self._client.images.build( @@ -80,74 +83,71 @@ class DockerSandbox: def start(self) -> None: """ - Start the sandbox container. + Start the container. Any existing container with the same name is removed first so that - re-running the agent always starts from a clean state. + re-runs always start from a clean state. """ - # Remove any leftover container from a previous run. try: old = self._client.containers.get(self.container_name) old.remove(force=True) except docker.errors.NotFound: pass - self._container = self._client.containers.run( - self._image, + run_kwargs: dict = dict( name=self.container_name, detach=True, - volumes={ - self.sandbox_dir: {"bind": self.container_workdir, "mode": "rw,Z"} - }, - working_dir=self.container_workdir, - environment={ - "CONTAINER_WORKSPACE": self.container_workdir, - "PIN_MCP_PORT": str(self.pin_mcp_port), - }, - # Expose pin-mcp port; Docker assigns a random host port. - ports={f"{self.pin_mcp_port}/tcp": None}, - # No outbound network needed; all tools are pre-installed. - network_mode="bridge", - # Minimal capability set; SYS_PTRACE is required for ltrace/strace/gdb. - cap_drop=["ALL"], - cap_add=["SYS_PTRACE"], - security_opt=["no-new-privileges"], + volumes=self._volumes, + environment=self._environment, + ports=self._ports, + network_mode=self._network_mode, ) + if self._working_dir is not None: + run_kwargs["working_dir"] = self._working_dir + if self._cap_drop is not None: + run_kwargs["cap_drop"] = self._cap_drop + if self._cap_add is not None: + run_kwargs["cap_add"] = self._cap_add + if self._security_opt is not None: + run_kwargs["security_opt"] = self._security_opt - # Resolve the host port Docker assigned and wait for pin-mcp to be ready. + self._container = self._client.containers.run(self._image, **run_kwargs) self._container.reload() - host_port = self._container.ports[f"{self.pin_mcp_port}/tcp"][0]["HostPort"] - self.mcp_url = f"http://localhost:{host_port}/mcp" - self._wait_for_mcp() logger.info( - "Container {!r} started (id={}), pin-mcp at {}.", + "Container {!r} started (id={}).", self.container_name, self._container.short_id, - self.mcp_url, - ) - - def _wait_for_mcp(self, timeout: int = 30) -> None: - """Block until pin-mcp's TCP port accepts connections, or raise on timeout.""" - host_port = int(self.mcp_url.split(":")[-1].split("/")[0]) - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - try: - with socket.create_connection(("localhost", host_port), timeout=1): - return - except OSError: - time.sleep(0.5) - raise RuntimeError( - f"pin-mcp did not become ready on port {host_port} within {timeout}s" ) def stop(self) -> None: - """Remove the sandbox container.""" + """Remove the container.""" if self._container is not None: self._container.remove(force=True) logger.info("Container {!r} stopped.", self.container_name) self._container = None + # ------------------------------------------------------------------ + # Port inspection + # ------------------------------------------------------------------ + + def get_host_port(self, container_port: str) -> str: + """ + Return the host port Docker mapped to *container_port*. + + *container_port* should include the protocol, e.g. ``"8080/tcp"``. + Raises ``RuntimeError`` if the container is not running or the port + is not exposed. + """ + if self._container is None: + raise RuntimeError("Sandbox container is not running.") + mappings = self._container.ports.get(container_port) + if not mappings: + raise RuntimeError( + f"Port {container_port!r} is not mapped on container {self.container_name!r}" + ) + return mappings[0]["HostPort"] + # ------------------------------------------------------------------ # File I/O # ------------------------------------------------------------------ @@ -156,10 +156,8 @@ class DockerSandbox: """ Write *content* to *path* inside the container using ``put_archive``. - Using the archive API avoids all shell-escaping concerns; any text - (including content with quotes, backslashes, or null bytes) is - transferred safely as a tar stream. Parent directories are created - automatically via a preceding ``mkdir -p``. + The tar archive API avoids all shell-escaping concerns. Parent + directories are created automatically via a preceding ``mkdir -p``. """ if self._container is None: raise RuntimeError("Sandbox container is not running.") @@ -167,10 +165,8 @@ class DockerSandbox: p = Path(path) encoded = content.encode("utf-8") - # Ensure the parent directory exists inside the container. self._container.exec_run(["mkdir", "-p", str(p.parent)]) - # Pack the file into an in-memory tar archive and push it in. buf = io.BytesIO() with tarfile.open(fileobj=buf, mode="w") as tar: info = tarfile.TarInfo(name=p.name) @@ -196,10 +192,11 @@ class DockerSandbox: return 1, "Sandbox container is not running." def _run() -> tuple[int, bytes]: + kwargs: dict = dict(demux=False) + if self._working_dir is not None: + kwargs["workdir"] = self._working_dir exit_code, output = self._container.exec_run( - ["bash", "-c", command], - workdir=self.container_workdir, - demux=False, + ["bash", "-c", command], **kwargs ) return exit_code, output or b""