# Agentic AI Security: Sandboxing LLM Tool Calls in Production

> Source: <https://dev.to/ayinedjimi-consultants/agentic-ai-security-sandboxing-llm-tool-calls-in-production-2odk>
> Published: 2026-08-26 10:05:12+00:00

When you give a language model the ability to call tools — run code, query databases, browse the web — you've created an autonomous execution surface. Most tutorials skip the part where that surface gets exploited.

This post covers practical steps for sandboxing LLM tool calls before they reach production. No theory: concrete code patterns that limit the blast radius when something goes wrong.

A standard LLM integration is relatively contained: input goes in, text comes out. The worst case is a model generating harmful content.

Agent architectures change the calculus entirely. The loop looks like this:

```
User prompt → LLM → tool call decision → tool execution → result → LLM → ...
```

At the "tool execution" step, the model's output directly drives system behavior. A prompt injection in a document the agent reads can redirect it to exfiltrate data. An unchecked shell tool lets the model run arbitrary commands. An HTTP tool without domain restrictions can trigger SSRF against your internal network.

These aren't hypothetical. They're the same attack classes that plagued web applications for decades — now applied to any agentic system you build.

The single most effective control is a strict allowlist of what tools exist and what parameters they accept. Don't let the model invent tool calls — only allow predefined, validated schemas.

``` python
import pathlib
import jsonschema
from dataclasses import dataclass
from typing import Any, Callable

@dataclass
class ToolSpec:
    name: str
    description: str
    param_schema: dict  # JSON Schema for validation
    handler: Callable
    requires_confirmation: bool = False

class ToolRegistry:
    def __init__(self):
        self._tools: dict[str, ToolSpec] = {}

    def register(self, spec: ToolSpec):
        self._tools[spec.name] = spec

    def call(self, tool_name: str, params: dict) -> Any:
        if tool_name not in self._tools:
            raise ValueError(
                f"Unknown tool: {tool_name!r}. Allowed: {list(self._tools)}"
            )
        spec = self._tools[tool_name]
        jsonschema.validate(params, spec.param_schema)
        return spec.handler(**params)

# Example: a file-read tool restricted to one directory
SAFE_DIR = pathlib.Path("/var/app/data").resolve()

def safe_read_file(path: str) -> str:
    target = (SAFE_DIR / path).resolve()
    if not str(target).startswith(str(SAFE_DIR)):
        raise PermissionError(f"Path traversal blocked: {path!r}")
    return target.read_text()

registry = ToolRegistry()
registry.register(ToolSpec(
    name="read_file",
    description="Read a file from the data directory",
    param_schema={
        "type": "object",
        "properties": {
            "path": {"type": "string", "pattern": r"^[\w\-/\.]+$"}
        },
        "required": ["path"],
        "additionalProperties": False,
    },
    handler=safe_read_file,
))
```

Two independent layers: the registry rejects unknown tool names outright, validates params against a JSON Schema before any execution, and the handler itself re-checks path resolution to block traversal.

If your agent needs to execute code — and many do — never use `subprocess.Popen(shell=True)`

with model-generated content. The model controls the string, and `shell=True`

hands it command injection on a plate.

For Python code execution, run it in a child process with tightly bounded resources:

``` python
import os
import resource
import subprocess
import tempfile

def run_python_sandbox(code: str, timeout: int = 5) -> str:
    # Run untrusted Python in a restricted subprocess.
    with tempfile.NamedTemporaryFile(suffix=".py", mode="w", delete=False) as f:
        f.write(code)
        tmpfile = f.name

    try:
        result = subprocess.run(
            ["python3", "-E", "-S", tmpfile],  # -E: ignore env vars, -S: no site
            capture_output=True,
            text=True,
            timeout=timeout,
            preexec_fn=_set_resource_limits,
        )
        if result.returncode != 0:
            return f"Error: {result.stderr[:500]}"
        return result.stdout[:2000]
    except subprocess.TimeoutExpired:
        return "Execution timed out"
    finally:
        os.unlink(tmpfile)

def _set_resource_limits():
    # 50 MB address space
    resource.setrlimit(resource.RLIMIT_AS, (50 * 1024 * 1024, 50 * 1024 * 1024))
    # 10 seconds CPU time
    resource.setrlimit(resource.RLIMIT_CPU, (10, 10))
    # Max 10 open file descriptors (no network sockets)
    resource.setrlimit(resource.RLIMIT_NOFILE, (10, 10))
```

On Linux, pair this with a seccomp filter. At the container level, run agent workloads with dropped capabilities:

```
docker run \
  --security-opt seccomp=/etc/docker/seccomp-restricted.json \
  --security-opt no-new-privileges \
  --cap-drop ALL \
  --read-only \
  --tmpfs /tmp \
  agent-sandbox:latest
```

For higher-assurance workloads — untrusted user-supplied code, multi-tenant setups — use gVisor (`runsc`

) or Firecracker microVMs. The subprocess pattern above is a floor, not a ceiling.

The tool registry controls *what* an agent can do. Rate limiting controls *how much* it can do before a human should review it.

``` python
import threading
from collections import defaultdict
from datetime import datetime, timedelta

class AgentBudget:
    def __init__(self, max_calls: int = 20, window_seconds: int = 300):
        self.max_calls = max_calls
        self.window = timedelta(seconds=window_seconds)
        self._calls: dict[str, list[datetime]] = defaultdict(list)
        self._lock = threading.Lock()

    def check_and_consume(self, session_id: str, tool_name: str) -> bool:
        now = datetime.utcnow()
        key = f"{session_id}:{tool_name}"

        with self._lock:
            self._calls[key] = [
                t for t in self._calls[key] if now - t < self.window
            ]
            if len(self._calls[key]) >= self.max_calls:
                return False
            self._calls[key].append(now)
            return True
```

When a tool call fails the budget check, return an error to the model — not to the user directly. The model reports that it cannot complete the task, which is the correct outcome. Don't silently swallow the limit or retry automatically.

Pair budget enforcement with capability scoping: an agent handling customer support queries should never receive access to database write tools, even if those tools exist in the system. Instantiate the registry with only the tools relevant to the task at hand.

None of the above controls are verifiable without logs. Every tool call should emit a structured record:

``` python
import json
import logging
from datetime import datetime
from typing import Any

logger = logging.getLogger("agent.audit")

def audit_tool_call(
    session_id: str,
    tool_name: str,
    params: dict,
    result: Any = None,
    error: str | None = None,
):
    record = {
        "ts": datetime.utcnow().isoformat() + "Z",
        "session": session_id,
        "tool": tool_name,
        "params": params,       # sanitize sensitive fields before this point
        "success": error is None,
        "error": error,
        "result_bytes": len(str(result)) if result is not None else 0,
    }
    logger.info(json.dumps(record))
```

Log *before* execution with `status: "attempting"`

and *after* with the outcome. If the process is killed mid-call, you still have a record of intent.

Store audit logs in append-only storage — S3 with Object Lock, write-once Kafka topics, or a WORM-capable log backend. An agent that is compromised should not be able to clean up after itself by truncating its own log file.

Agentic systems are code paths driven by model output. The security controls are the same as for any external input: validate, restrict, rate limit, log.

The difference is that model outputs are less predictable than typed user input, which makes defense-in-depth more critical. An allowlist registry, a restricted execution environment, and append-only audit logs give you three independent layers — any single bypass still hits the next one.

For a structured checklist of what to harden in AI-connected and web infrastructure, see the [free security hardening checklists](https://ayinedjimi-consultants.fr/checklists) we publish.

*I run AYI NEDJIMI Consultants, a cybersecurity consulting firm. We publish free security hardening checklists — PDF and Excel.*
