{"slug": "agentic-ai-security-sandboxing-llm-tool-calls-in-production", "title": "Agentic AI Security: Sandboxing LLM Tool Calls in Production", "summary": "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.", "body_md": "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.\n\nThis 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.\n\nA standard LLM integration is relatively contained: input goes in, text comes out. The worst case is a model generating harmful content.\n\nAgent architectures change the calculus entirely. The loop looks like this:\n\n```\nUser prompt → LLM → tool call decision → tool execution → result → LLM → ...\n```\n\nAt 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.\n\nThese aren't hypothetical. They're the same attack classes that plagued web applications for decades — now applied to any agentic system you build.\n\nThe 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.\n\n``` python\nimport pathlib\nimport jsonschema\nfrom dataclasses import dataclass\nfrom typing import Any, Callable\n\n@dataclass\nclass ToolSpec:\n    name: str\n    description: str\n    param_schema: dict  # JSON Schema for validation\n    handler: Callable\n    requires_confirmation: bool = False\n\nclass ToolRegistry:\n    def __init__(self):\n        self._tools: dict[str, ToolSpec] = {}\n\n    def register(self, spec: ToolSpec):\n        self._tools[spec.name] = spec\n\n    def call(self, tool_name: str, params: dict) -> Any:\n        if tool_name not in self._tools:\n            raise ValueError(\n                f\"Unknown tool: {tool_name!r}. Allowed: {list(self._tools)}\"\n            )\n        spec = self._tools[tool_name]\n        jsonschema.validate(params, spec.param_schema)\n        return spec.handler(**params)\n\n# Example: a file-read tool restricted to one directory\nSAFE_DIR = pathlib.Path(\"/var/app/data\").resolve()\n\ndef safe_read_file(path: str) -> str:\n    target = (SAFE_DIR / path).resolve()\n    if not str(target).startswith(str(SAFE_DIR)):\n        raise PermissionError(f\"Path traversal blocked: {path!r}\")\n    return target.read_text()\n\nregistry = ToolRegistry()\nregistry.register(ToolSpec(\n    name=\"read_file\",\n    description=\"Read a file from the data directory\",\n    param_schema={\n        \"type\": \"object\",\n        \"properties\": {\n            \"path\": {\"type\": \"string\", \"pattern\": r\"^[\\w\\-/\\.]+$\"}\n        },\n        \"required\": [\"path\"],\n        \"additionalProperties\": False,\n    },\n    handler=safe_read_file,\n))\n```\n\nTwo 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.\n\nIf your agent needs to execute code — and many do — never use `subprocess.Popen(shell=True)`\n\nwith model-generated content. The model controls the string, and `shell=True`\n\nhands it command injection on a plate.\n\nFor Python code execution, run it in a child process with tightly bounded resources:\n\n``` python\nimport os\nimport resource\nimport subprocess\nimport tempfile\n\ndef run_python_sandbox(code: str, timeout: int = 5) -> str:\n    # Run untrusted Python in a restricted subprocess.\n    with tempfile.NamedTemporaryFile(suffix=\".py\", mode=\"w\", delete=False) as f:\n        f.write(code)\n        tmpfile = f.name\n\n    try:\n        result = subprocess.run(\n            [\"python3\", \"-E\", \"-S\", tmpfile],  # -E: ignore env vars, -S: no site\n            capture_output=True,\n            text=True,\n            timeout=timeout,\n            preexec_fn=_set_resource_limits,\n        )\n        if result.returncode != 0:\n            return f\"Error: {result.stderr[:500]}\"\n        return result.stdout[:2000]\n    except subprocess.TimeoutExpired:\n        return \"Execution timed out\"\n    finally:\n        os.unlink(tmpfile)\n\ndef _set_resource_limits():\n    # 50 MB address space\n    resource.setrlimit(resource.RLIMIT_AS, (50 * 1024 * 1024, 50 * 1024 * 1024))\n    # 10 seconds CPU time\n    resource.setrlimit(resource.RLIMIT_CPU, (10, 10))\n    # Max 10 open file descriptors (no network sockets)\n    resource.setrlimit(resource.RLIMIT_NOFILE, (10, 10))\n```\n\nOn Linux, pair this with a seccomp filter. At the container level, run agent workloads with dropped capabilities:\n\n```\ndocker run \\\n  --security-opt seccomp=/etc/docker/seccomp-restricted.json \\\n  --security-opt no-new-privileges \\\n  --cap-drop ALL \\\n  --read-only \\\n  --tmpfs /tmp \\\n  agent-sandbox:latest\n```\n\nFor higher-assurance workloads — untrusted user-supplied code, multi-tenant setups — use gVisor (`runsc`\n\n) or Firecracker microVMs. The subprocess pattern above is a floor, not a ceiling.\n\nThe tool registry controls *what* an agent can do. Rate limiting controls *how much* it can do before a human should review it.\n\n``` python\nimport threading\nfrom collections import defaultdict\nfrom datetime import datetime, timedelta\n\nclass AgentBudget:\n    def __init__(self, max_calls: int = 20, window_seconds: int = 300):\n        self.max_calls = max_calls\n        self.window = timedelta(seconds=window_seconds)\n        self._calls: dict[str, list[datetime]] = defaultdict(list)\n        self._lock = threading.Lock()\n\n    def check_and_consume(self, session_id: str, tool_name: str) -> bool:\n        now = datetime.utcnow()\n        key = f\"{session_id}:{tool_name}\"\n\n        with self._lock:\n            self._calls[key] = [\n                t for t in self._calls[key] if now - t < self.window\n            ]\n            if len(self._calls[key]) >= self.max_calls:\n                return False\n            self._calls[key].append(now)\n            return True\n```\n\nWhen 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.\n\nPair 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.\n\nNone of the above controls are verifiable without logs. Every tool call should emit a structured record:\n\n``` python\nimport json\nimport logging\nfrom datetime import datetime\nfrom typing import Any\n\nlogger = logging.getLogger(\"agent.audit\")\n\ndef audit_tool_call(\n    session_id: str,\n    tool_name: str,\n    params: dict,\n    result: Any = None,\n    error: str | None = None,\n):\n    record = {\n        \"ts\": datetime.utcnow().isoformat() + \"Z\",\n        \"session\": session_id,\n        \"tool\": tool_name,\n        \"params\": params,       # sanitize sensitive fields before this point\n        \"success\": error is None,\n        \"error\": error,\n        \"result_bytes\": len(str(result)) if result is not None else 0,\n    }\n    logger.info(json.dumps(record))\n```\n\nLog *before* execution with `status: \"attempting\"`\n\nand *after* with the outcome. If the process is killed mid-call, you still have a record of intent.\n\nStore 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.\n\nAgentic systems are code paths driven by model output. The security controls are the same as for any external input: validate, restrict, rate limit, log.\n\nThe 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.\n\nFor 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.\n\n*I run AYI NEDJIMI Consultants, a cybersecurity consulting firm. We publish free security hardening checklists — PDF and Excel.*", "url": "https://wpnews.pro/news/agentic-ai-security-sandboxing-llm-tool-calls-in-production", "canonical_source": "https://dev.to/ayinedjimi-consultants/agentic-ai-security-sandboxing-llm-tool-calls-in-production-2odk", "published_at": "2026-08-26 10:05:12+00:00", "updated_at": "2026-08-26 10:14:43.862726+00:00", "lang": "en", "topics": ["ai-safety", "ai-agents", "ai-infrastructure", "developer-tools"], "entities": [], "alternates": {"html": "https://wpnews.pro/news/agentic-ai-security-sandboxing-llm-tool-calls-in-production", "markdown": "https://wpnews.pro/news/agentic-ai-security-sandboxing-llm-tool-calls-in-production.md", "text": "https://wpnews.pro/news/agentic-ai-security-sandboxing-llm-tool-calls-in-production.txt", "jsonld": "https://wpnews.pro/news/agentic-ai-security-sandboxing-llm-tool-calls-in-production.jsonld"}}