Agentic AI Security: Sandboxing LLM Tool Calls in Production An engineer detailed practical sandboxing techniques for securing LLM tool calls in production agentic systems, emphasizing strict tool allowlists, JSON Schema validation, and resource-limited subprocess execution to mitigate prompt injection, path traversal, and command injection risks. 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.