Skip to content
Blog

Container Sandboxes for Tool Execution: Images, Seccomp, and Network Policies

Harden agent tool execution with container sandboxes: minimal images, seccomp profiles, dropped capabilities, and Kubernetes NetworkPolicy egress control.

Published on • October 18, 2026

AI Assistant

Agents that write and run code are powerful and dangerous in equal measure. The Hugging Face smolagents security guide lists the ways LLM-generated code goes wrong — “plain LLM error,” supply-chain attack, prompt injection from a browsed page, and misuse of publicly exposed agents — and its conclusion is blunt: “The only way to run LLM-generated code with truly robust security isolation is to use remote execution options like E2B or Docker.” A container sandbox is where your agent’s code runs. This post covers the three layers that decide whether that sandbox holds: the image, the syscall filter (Docker’s seccomp support), and the network rules (Kubernetes NetworkPolicy).

Start From the Framework’s Sandbox Hook

You rarely build the executor from scratch. smolagents’ CodeAgent takes an executor_type parameter — "docker", "e2b", "modal", or "blaxel" — that moves generated code out of your local process:

from smolagents import InferenceClientModel, CodeAgent

with CodeAgent(model=InferenceClientModel(), tools=[], executor_type="docker") as agent:
    agent.run("Can you give me the 100th Fibonacci number?")

The framework’s own guidance is that its hardened local interpreter (an AST-walking LocalPythonExecutor that denies imports not on an allowlist, blocks submodule access like random._os, and caps loop iterations) is a first layer, not a guarantee — the docs warn that “no local python sandbox can ever be completely secure.” For production agents you move execution into a container you control. The two architectural options from the same guide: sandbox just the code snippets (the model runs locally, only generated code runs remotely — no API keys transferred, no multi-agent support), or run the entire agentic system inside the sandbox (full isolation, but you must inject credentials into the sandbox environment). Choose per workload, and assume the second option means every secret in the sandbox is exfiltration bait.

Layer 1: The Image

The base image sets your blast radius before any runtime flag runs. smolagents’ documented Dockerfile pattern is the template to internalize:

FROM python:3.10-bullseye

RUN apt-get update && \
    apt-get install -y --no-install-recommends \
        build-essential \
        python3-dev && \
    pip install --no-cache-dir --upgrade pip && \
    pip install --no-cache-dir smolagents && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*

WORKDIR /app

# Run with limited privileges
USER nobody

CMD ["python", "-c", "print('Container ready')"]

Three decisions inside that file matter more than the package list. Non-root user: USER nobody means a code-execution bug is not a root shell. Minimal dependency set: the sandbox best-practices section says to “keep dependencies minimal” and “use fixed package versions” — every library you install is a potential escape gadget, as the guide itself notes when it points out that an allowed package like Pillow can still be abused to fill the disk. Regular rebuilds: “if you use base images, update them regularly” is the difference between a sandbox and a museum of CVEs.

Then bind the runtime at container creation, following the sandbox-manager pattern from the docs:

import docker

client = docker.from_env()
container = client.containers.run(
    "agent-sandbox",
    command="tail -f /dev/null",
    detach=True,
    mem_limit="512m",          # a fork bomb is a disk-full incident, not an outage
    cpu_quota=50000,           # ~50% of one CPU
    pids_limit=100,            # no process-table exhaustion
    security_opt=["no-new-privileges"],
    cap_drop=["ALL"],
)

cap_drop=["ALL"] plus no-new-privileges closes the classic privilege-escalation paths (setuid binaries can’t grant anything if there are no capabilities to gain). Execute as the non-root user too: container.exec_run(cmd=["python", "-c", code], user="nobody").

Layer 2: Seccomp — Filtering Syscalls

Capabilities constrain what a process can do with resources it has; seccomp constrains what it can ask the kernel to do at all. Docker documents it as exactly that: “a Linux kernel feature. You can use it to restrict the actions available within the container.” Unless you override it, every container runs under a default profile that “disables around 44 system calls out of 300+” and works as an allowlist — a defaultAction of SCMP_ACT_ERRNO that returns “Permission Denied” for anything not explicitly allowed.

The blocked list is worth reading as a threat model for agent workloads. mount, pivot_root, setns, unshare, and clone with namespace flags are the container-escape vocabulary. bpf, perf_event_open, and lookup_dcookie block host introspection. init_module/delete_module block kernel module manipulation. And notably for sandbox operators, the entire io_uring family (io_uring_enter, io_uring_register, io_uring_setup) is “blocked due to security vulnerabilities that can be exploited to break out of containers.” You get all of this for free; you opt out of it only deliberately:

docker run --rm -it \
    --security-opt seccomp=/path/to/seccomp-profile.json \
    --cap-drop ALL \
    --read-only \
    agent-sandbox

If you write a custom profile, keep the default’s structure: SCMP_ACT_ERRNO as the default action, SCMP_ACT_ALLOW for the syscalls your interpreter genuinely needs, and argument filters for the dangerous-but-ubiquitous ones. The Docker docs’ own advice stands: “it is not recommended to change the default seccomp profile.” Extend it only when something real breaks, and document why.

Layer 3: Network Policies — The Exfiltration Boundary

The most realistic agent attack is not a kernel escape; it is generated code quietly POSTing your data somewhere. Network egress control is the layer that catches it, and on Kubernetes that means NetworkPolicy.

Understand the default first, because it is hostile to security: “By default, a pod is non-isolated for egress; all outbound connections are allowed.” A pod becomes isolated for egress only when a NetworkPolicy selects it and lists "Egress" in its policyTypes. Once isolated, only destinations in some policy’s egress list are reachable. The fail-closed starting point is the empty-selector deny-all:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: agent-sandbox-deny-all
  namespace: agent-sandbox
spec:
  podSelector: {}          # every pod in the namespace
  policyTypes:
    - Egress               # isolated for egress; nothing is allowed yet

Now add exactly what your agent’s tools need. An agent that must call one internal tool service and a package mirror, and nothing else:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: agent-sandbox-egress
  namespace: agent-sandbox
spec:
  podSelector:
    matchLabels:
      app: agent-sandbox
  policyTypes:
    - Egress
  egress:
    - to:                                   # DNS, or nothing resolves at all
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: kube-system
      ports:
        - protocol: UDP
          port: 53
        - protocol: TCP
          port: 53
    - to:
        - podSelector:
            matchLabels:
              app: internal-tool-service
    - to:                                   # pinned package mirror only
        - ipBlock:
            cidr: 10.20.30.0/24

Two Kubernetes gotchas the docs call out that will burn you. First, “a default deny-all egress policy also blocks DNS traffic” — if you skip the DNS rule, sandboxed code fails in ways that look like flaky networking. Second, ipBlock “should select cluster-external IPs, since Pod IPs are ephemeral and unpredictable”; use namespace/pod selectors inside the cluster and CIDRs only for genuinely external endpoints. namespaceSelector and podSelector in the same to entry select particular pods within particular namespaces — that is how you allow exactly one service in one namespace instead of a whole flat network.

Wire the Layers Together

Each layer catches what the others miss. The image and runtime flags stop privilege escalation; seccomp stops novel kernel abuse even from a process that looks well-behaved; NetworkPolicy stops the boring, effective attack — reading files and sending them out. Assemble them into one admission-controlled pod spec (a Restricted Pod Security Standard namespace, a runtime class with the seccomp profile attached, resource quotas per namespace) and make the sandbox the only place agent code executes — no executor_type fallback to local.

Then test the boundary like an attacker would, on a schedule: run a canary job inside the sandbox that attempts mount, a raw-socket bind, a curl to a non-allowlisted IP, and a DNS TXT exfiltration. If any of them succeed, you found the gap before an injected prompt did.

Conclusion

Container sandboxes for agent tool execution are not one control but a stack. Harden the image to a non-root, minimal, pinned base; keep Docker’s default seccomp profile and its blocked escape syscalls rather than running unconfined; drop all capabilities and forbid privilege escalation at runtime; and isolate egress with a default-deny NetworkPolicy that allows DNS and exactly the services your tools require. Frameworks like smolagents give you the executor seam — executor_type="docker" — and even documented hardening patterns to build on. The work that remains is yours: making every layer fail closed, and verifying it stays that way.