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.
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)
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:
import os
import resource
import subprocess
import tempfile
def run_python_sandbox(code: str, timeout: int = 5) -> str:
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():
resource.setrlimit(resource.RLIMIT_AS, (50 * 1024 * 1024, 50 * 1024 * 1024))
resource.setrlimit(resource.RLIMIT_CPU, (10, 10))
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.
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:
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 we publish.
I run AYI NEDJIMI Consultants, a cybersecurity consulting firm. We publish free security hardening checklists β PDF and Excel.