When you give a language model the ability to call tools — execute code, read files, hit APIs — you create a new attack surface that most teams aren't ready for. A compromised prompt or a crafted user input can now trigger arbitrary commands, leak credentials, or exfiltrate data. Production agentic systems have already been exploited via prompt injection attacks that hijack tool calls. Sandboxing LLM tool calls is not optional in production. Here is how to do it.
Why Tool Calls Are a Different Threat Model
Traditional web API security is well-understood: validate inputs, sanitize outputs, authenticate callers. In an agentic system, the "caller" is a language model that produces natural language instructions converted to structured tool invocations. The threat model shifts in three ways:
Indirect prompt injection: an external document retrieved by the agent contains malicious instructions that redirect tool behavior. The model never knows it was manipulated.
Over-permissioned tools: the agent has access to a delete_file tool it never actually needs — but a crafted prompt can invoke it anyway.
Unconstrained arguments: tool call arguments are model-generated and may contain unexpected values even when the schema type checks pass.
Layer 1 — Tool Schema as the First Defense
The cleanest way to limit what an agent can do is to constrain what it can express. Use strict JSON schemas for every tool, and validate the model's output before execution — not after.
from jsonschema import validate, ValidationError
import json
TOOL_SCHEMAS = {
"read_file": {
"type": "object",
"properties": {
"path": {
"type": "string",
"pattern": "^/data/[a-zA-Z0-9_\\-]+\\.txt$"
}
},
"required": ["path"],
"additionalProperties": False
}
}
def validate_tool_call(tool_name: str, arguments: dict) -> None:
schema = TOOL_SCHEMAS.get(tool_name)
if schema is None:
raise ValueError(f"Unknown tool: {tool_name}")
try:
validate(instance=arguments, schema=schema)
except ValidationError as e:
raise ValueError(f"Invalid tool arguments: {e.message}")
def execute_tool(tool_name: str, raw_arguments: str) -> str:
arguments = json.loads(raw_arguments)
validate_tool_call(tool_name, arguments)
return dispatch_tool(tool_name, arguments)
The pattern on path is the critical detail. Without it, a model producing {"path": "../../etc/passwd"} passes type validation just fine. Regex allowlisting is faster and more reliable than blocklisting traversal sequences.
Layer 2 — Process Isolation for Code Execution
If your agent executes code — Python sandboxes, shell commands, SQL — schema validation is not enough. You need OS-level isolation. Docker is the minimum; gVisor or Firecracker microvms are better for untrusted workloads. Here is a minimal secure code execution wrapper using subprocess with hard limits:
import subprocess
import resource
import tempfile
from pathlib import Path
def run_sandboxed_python(code: str, timeout: int = 5) -> dict:
"""Execute model-generated Python in a restricted subprocess.
No network, capped CPU and memory, no new processes."""
with tempfile.NamedTemporaryFile(suffix=".py", mode="w", delete=False) as f:
f.write(code)
script_path = f.name
def set_limits():
resource.setrlimit(resource.RLIMIT_CPU, (5, 5)) # 5s CPU
resource.setrlimit(resource.RLIMIT_AS,
(128 * 1024 * 1024, 128 * 1024 * 1024)) # 128 MB RAM
resource.setrlimit(resource.RLIMIT_NPROC, (0, 0)) # no fork
try:
result = subprocess.run(
["python3", "-u", script_path],
capture_output=True,
text=True,
timeout=timeout,
preexec_fn=set_limits,
env={"PATH": "/usr/bin:/bin", "HOME": "/tmp"}
)
return {
"stdout": result.stdout[:4096],
"stderr": result.stderr[:1024],
"returncode": result.returncode
}
except subprocess.TimeoutExpired:
return {"error": "timeout", "stdout": "", "stderr": ""}
finally:
Path(script_path).unlink(missing_ok=True)
This does not replace containerization — it is one layer in a defence-in-depth stack. In production, combine it with a Docker container that has --network none, a read-only root filesystem, and a seccomp profile.
Layer 3 — Tool Permission Scoping Per Agent Role
Every agent should operate under the minimal tool set its task actually requires. Define tool ACLs per role, not globally.
from enum import Enum
from dataclasses import dataclass, field
class ToolPermission(Enum):
READ_FILE = "read_file"
WRITE_FILE = "write_file"
RUN_CODE = "run_code"
HTTP_GET = "http_get"
HTTP_POST = "http_post"
DB_READ = "db_read"
DB_WRITE = "db_write"
@dataclass
class AgentRole:
name: str
allowed_tools: set[ToolPermission] = field(default_factory=set)
ANALYST_ROLE = AgentRole(
name="analyst",
allowed_tools={ToolPermission.READ_FILE, ToolPermission.DB_READ}
)
def check_permission(role: AgentRole, tool: ToolPermission) -> None:
if tool not in role.allowed_tools:
raise PermissionError(
f"Role '{role.name}' cannot call '{tool.value}'"
)
This pattern maps cleanly onto existing RBAC systems. An analyst agent that only reads data cannot be tricked into writing or posting, regardless of what the model produces. The blast radius of any successful injection is bounded by the role definition — not by hope.
For teams building this from scratch, the LLM deployment section of our security hardening checklists covers role scoping decisions end-to-end, including how to handle multi-agent pipelines where agents spawn sub-agents with inherited permissions.
Layer 4 — Audit Logging Every Tool Call
Every tool invocation should be logged with enough context to reconstruct the full chain of events: session ID, role, tool name, argument hash, result length, and a short preview. Feed these into your SIEM or anomaly detection layer.
import logging
import json
from datetime import datetime, timezone
tool_logger = logging.getLogger("tool_audit")
def audited_tool_call(session_id: str, role: AgentRole,
tool_name: str, arguments: dict, result: str) -> None:
tool_logger.info(json.dumps({
"ts": datetime.now(timezone.utc).isoformat(),
"session_id": session_id,
"role": role.name,
"tool": tool_name,
"args_hash": hash(json.dumps(arguments, sort_keys=True)),
"args_preview": str(arguments)[:200],
"result_len": len(result),
"result_preview": result[:100]
}))
Unusual patterns — an agent calling http_post ten times in a minute, accessing paths outside its normal working set, or suddenly invoking write tools after behaving read-only for a month — are worth alerting on. The log structure above gives you the fields you need for both rule-based and ML-based anomaly detection.
The Takeaway
Agentic AI security is not one control: it is a layered system. Schema validation catches obvious injection attempts at the input boundary. Process isolation contains code execution at the OS level. Tool permission scoping limits blast radius at the role level. Audit logs give you forensic visibility after the fact.
The single most common mistake is giving agents more tools than they need "for flexibility." Before you deploy any agentic system to production, enumerate every tool call it can make and ask: if this call were hijacked, what is the worst case? If the answer is "anything," you have work to do.
Start with the smallest possible tool set. Add tools only when the agent demonstrably needs them. Audit everything. That approach holds regardless of which underlying language model you use.
I run AYI NEDJIMI Consultants, a cybersecurity consulting firm. We publish free security hardening checklists — PDF and Excel.
Top comments (0)