n4nAI

How to sandbox tool execution for AI agents

Step-by-step guide to sandbox AI agent tool execution using Docker isolation, network egress control, and resource limits for safe agent ops.

n4n Team4 min read782 words

Audio narration

Coming soon — every post will get a voice note here.

Building agents that call arbitrary code or APIs demands isolation. To sandbox AI agent tool execution, you need a hard boundary between model reasoning and side-effecting operations, enforced by OS-level containment. This guide walks through a concrete, end-to-end setup using containers, network policy, and resource limits you can ship today.

Step 1: Define the trust boundary

The agent loop is simple: a model emits a tool call, your code executes it, you return the result. The moment you execute untrusted logic, the caller (the model) is not your security perimeter—the tool runtime is.

Treat the LLM as a remote suggestion engine. Run it in your normal service mesh, but never let its outputs directly invoke syscalls outside a confined context. Separate the process that decides what to do from the process that does it. Draw the line clearly: the orchestrator holds API keys and session state; the executor holds nothing but the serialized input you hand it.

Step 2: Package the tool runtime as a minimal container

Start with a stripped image. Multi-stage builds keep the attack surface small. The example below installs only Python and the specific library your tool needs.

# build stage
FROM python:3.12-slim AS build
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# runtime stage
FROM python:3.12-slim
COPY --from=build /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
COPY tool.py /app/tool.py
USER 1000:1000
ENTRYPOINT ["python", "/app/tool.py"]

The container runs as a non-root UID and contains no shell, no compiler, and no package manager. That limits post-exploitation options. If you want even less, use a distroless base:

FROM gcr.io/distroless/python3-debian12
COPY tool.py /app/tool.py
USER nonroot:nonroot
ENTRYPOINT ["/app/tool.py"]

Distroless images ship no package manager or shell, so an attacker who breaks out of your tool code still has no easy foothold.

Step 3: Launch tools with hardened Docker flags

When you spawn a container for a tool call, drop all capabilities and disable privilege escalation. Use a dedicated bridge network with no internet route.

docker run --rm \
  --user 1000:1000 \
  --cap-drop=ALL \
  --security-opt no-new-privileges \
  --security-opt seccomp=default.json \
  --read-only \
  --tmpfs /tmp:size=16m \
  --memory=128m \
  --pids-limit=64 \
  --network=tool-isolation \
  -i tool-runtime:latest

--read-only makes the root filesystem immutable; --tmpfs gives ephemeral scratch space. --memory and --pids-limit stop fork bombs and memory exhaustion. Docker’s default seccomp profile already blocks 44 dangerous syscalls; --security-opt no-new-privileges prevents setuid tricks. If your tool needs fewer syscalls, ship a custom seccomp profile and reference it explicitly.

Step 4: Control network egress

A tool that can phone home is a tool that can exfiltrate. Create a Docker network with no gateway, then selectively allow egress via a filtering proxy.

docker network create --internal tool-isolation

The --internal flag blocks all external connectivity. If the tool must reach an API, run a sidecar proxy on a separate network and whitelist destinations. A minimal TinyProxy config enforces the allowlist:

Allow 172.18.0.0/16
Deny *

Your tool container then uses HTTP_PROXY=http://proxy:3128 while the proxy enforces the allowlist. Anything not matched returns 403. This keeps the sandbox AI agent tool execution path blind to the open internet by default.

Step 5: Enforce time and resource limits

Even with memory caps, a hung tool wastes cycles. Wrap the invocation in a host-level timeout.

timeout --signal=KILL 30s docker run --rm ... tool-runtime:latest

In Python, drive this with subprocess and stream JSON over stdin/stdout:

import subprocess, json, logging

def sandbox_ai_agent_tool_execution(tool_input: dict, timeout=30) -> dict:
    cmd = [
        "timeout", f"{timeout}s", "docker", "run", "--rm",
        "--user", "1000:1000", "--cap-drop=ALL",
        "--security-opt", "no-new-privileges",
        "--read-only", "--tmpfs", "/tmp:size=16m",
        "--memory=128m", "--pids-limit=64",
        "--network=tool-isolation", "-i", "tool-runtime:latest"
    ]
    proc = subprocess.run(
        cmd, input=json.dumps(tool_input).encode(),
        capture_output=True, check=False
    )
    if proc.returncode in (137, 124, 139):
        logging.warning("tool killed: %s", proc.returncode)
        return {"error": "tool killed: timeout/OOM/segfault"}
    return json.loads(proc.stdout.decode() or "{}")

Capture stderr in logs for debugging, but never return it raw to the model—it may leak host paths.

Step 6: Serialize inputs and outputs safely

Never pass Python objects via pickle across the boundary. Use a strict JSON schema and validate both sides.

from pydantic import BaseModel, Field

class ToolRequest(BaseModel):
    query: str = Field(max_length=1024)
    max_rows: int = 10

class ToolResponse(BaseModel):
    rows: list = Field(default_factory=list)
    truncated: bool = False

The container reads stdin, parses with the same model, executes, and writes only the response model to stdout. Add a hard cap on response size (e.g., 1 MB) before printing to avoid memory blowups in the orchestrator.

Step 7: Wire the sandbox into the agent loop

Your agent calls the model, gets a tool invocation, and routes it to the sandbox. For the model call, point your OpenAI-compatible client at the n4n.ai endpoint to access 240+ models with automatic fallback; keep that traffic outside the container.

from openai import OpenAI

client = OpenAI(base_url="https://api.n4n.ai/v1", api_key="YOUR_KEY")

def run_agent(user_msg: str):
    resp = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": user_msg}],
        tools=[{"type": "function", "function": {"name": "db_query", "parameters": {...}}}]
    )
    msg = resp.choices[0].message
    if msg.tool_calls:
        for call in msg.tool_calls:
            args = json.loads(call.function.arguments)
            # sandbox AI agent tool execution happens here
            result = sandbox_ai_agent_tool_execution(args)
            # feed result back to model in next turn
    return resp

Inside the container, tool.py is equally minimal:

import sys, json
from pydantic import BaseModel

class ToolRequest(BaseModel):
    query: str
    max_rows: int = 10

req = ToolRequest(**json.load(sys.stdin))
# ... perform read-only query ...
print(json.dumps({"rows": [], "truncated": False}))

The model never sees the host. The tool never sees the API key.

Step 8: Verify isolation and behavior

Verification is not optional. Run three tests before shipping:

  1. Privilege test: Have the tool attempt open("/etc/shadow"). Expect PermissionError.
  2. Network test: Tool tries requests.get("https://evil.test"). Expect connection failure or proxy 403.
  3. Resource test: Tool allocates 500 MB or spawns 200 threads. Expect container kill with exit 137.

Automate them in pytest:

def test_privilege():
    out = sandbox_ai_agent_tool_execution({"query": "cat /etc/shadow"})
    assert "error" in out

def test_network():
    out = sandbox_ai_agent_tool_execution({"query": "fetch https://evil.test"})
    assert "error" in out

def test_resource():
    out = sandbox_ai_agent_tool_execution({"query": "alloc 500mb"})
    assert out.get("error", "").startswith("tool killed")

A simple success criterion: the host /etc/shadow remains unreadable, outbound traffic is zero without proxy allowlist, and a 60-second infinite loop dies at 30s. Log every invocation with the container ID, input hash, and exit code.

Operating notes

Rotate the tool image tags per deployment. Pin digests, not tags, to avoid supply-chain drift. If you need richer isolation, swap the Docker runtime for gVisor (runsc) with the same flags—the interface stays identical.

Sandbox AI agent tool execution is not a one-time feature; it is a deployment invariant. Treat the boundary as part of your CI: every tool PR must pass the three isolation tests above or the merge blocks. That’s the whole path from a bare agent loop to a confined execution environment an attacker can’t escape.

Tagsai-agentstool-usesandboxingsecurity

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All ai agent tool use design patterns posts →