Building Secure Tool-Using Agents: Sandboxing and Permissions
Tool-using agents — LLM systems that can invoke functions, run code, or interact with external services — are among the most powerful patterns in modern AI engineering. But with that power comes a sobering reality: every tool you hand to an agent is a potential attack surface. A misaligned prompt, a poisoned retrieval result, or a cleverly crafted user input can convince an agent to delete files, exfiltrate secrets, or execute malicious code. This tutorial walks through the architecture, patterns, and concrete code needed to build agents that use tools safely through sandboxing and permission systems.
What Is a Secure Tool-Using Agent?
A secure tool-using agent is an LLM-driven system that can call external capabilities — shell commands, file operations, HTTP requests, database queries, code execution — while enforcing strict boundaries on what those calls can do. Security is achieved through two complementary mechanisms:
- Sandboxing — isolating the execution environment so that even a compromised or misbehaving tool cannot escape to affect the host system, network, or other processes.
- Permissions — a policy layer that decides, before each tool invocation, whether the action is allowed given the current context, user identity, and risk profile.
Together these form defense in depth. Sandboxing contains the blast radius when something goes wrong; permissions prevent the wrong thing from being attempted in the first place.
Why It Matters
Without sandboxing and permissions, tool-using agents inherit all the risks of the tools they call, amplified by the unpredictability of natural language control. Consider a coding agent with shell access. A user asks it to "clean up the project," and the agent helpfully runs rm -rf ./* from the wrong directory. Or worse: an attacker injects instructions into a web page the agent is summarizing, telling it to POST environment variables to an external endpoint. These are not hypothetical scenarios — prompt injection, tool abuse, and accidental destruction are documented failure modes in production agent systems.
The core principle is least privilege: an agent should have exactly the capabilities it needs for the current task, no more, and those capabilities should operate within an isolated boundary that limits damage if they are misused.
Architecting the Security Layers
A well-designed secure agent has four layers, each of which can fail independently without compromising the others:
- Tool interface — a narrow, explicit API that the LLM can call, with typed parameters and documented behavior.
- Permission gate — a middleware that inspects every tool call against a policy before execution.
- Sandbox runtime — the isolated environment where the tool actually runs.
- Audit and observability — logging of every decision, invocation, and outcome for review and incident response.
Let's build each layer in Python, starting with the tool interface and permission gate.
Defining Tools with Explicit Schemas
The first step is to define tools as structured objects rather than raw functions. This forces you to declare inputs, outputs, and required permissions up front, which the permission gate can then evaluate.
from dataclasses import dataclass, field
from typing import Callable, Any
@dataclass
class ToolPermission:
"""Declares what a tool is allowed to touch."""
filesystem: bool = False
network: bool = False
subprocess: bool = False
write: bool = False
scope: str = "read-only" # read-only, read-write, destructive
@dataclass
class Tool:
name: str
description: str
parameters: dict
handler: Callable[[dict], Any]
permission: ToolPermission
def to_openai_schema(self) -> dict:
return {
"type": "function",
"function": {
"name": self.name,
"description": self.description,
"parameters": self.parameters,
},
}
Notice the ToolPermission dataclass. Every tool carries a declaration of what resources it needs. This is not the enforcement mechanism — it is a contract that the permission gate reads and evaluates.
Building the Permission Gate
The permission gate sits between the LLM's decision to call a tool and the actual execution. It evaluates the call against a policy and can allow, deny, or escalate to a human for approval.
from enum import Enum
from typing import Optional
class Decision(Enum):
ALLOW = "allow"
DENY = "deny"
ESCALATE = "escalate"
@dataclass
class PolicyContext:
user_id: str
session_id: str
task: str
trust_level: str = "standard" # standard, elevated, admin
class PermissionGate:
def __init__(self, auto_approve_scopes: set[str] = None):
self.auto_approve_scopes = auto_approve_scopes or {"read-only"}
self.log: list[dict] = []
def evaluate(
self,
tool: Tool,
arguments: dict,
context: PolicyContext
) -> tuple[Decision, str]:
# Rule 1: read-only tools are auto-approved
if tool.permission.scope in self.auto_approve_scopes:
return Decision.ALLOW, "Auto-approved: read-only scope"
# Rule 2: destructive operations require elevated trust
if tool.permission.scope == "destructive":
if context.trust_level != "admin":
return Decision.ESCALATE, (
"Destructive operation requires admin approval"
)
# Rule 3: network access from untrusted tasks is blocked
if tool.permission.network and context.trust_level == "standard":
suspicious = any(
kw in context.task.lower()
for kw in ["summarize", "translate", "extract"]
)
if suspicious:
return Decision.DENY, (
"Network access blocked for untrusted summarization task"
)
# Rule 4: filesystem writes outside project dir are denied
if tool.permission.write and "path" in arguments:
path = arguments["path"]
if not path.startswith("/workspace/project/"):
return Decision.DENY, (
f"Write outside project directory blocked: {path}"
)
return Decision.ALLOW, "Policy check passed"
def record(self, tool_name: str, arguments: dict,
decision: Decision, reason: str, context: PolicyContext):
self.log.append({
"tool": tool_name,
"arguments": arguments,
"decision": decision.value,
"reason": reason,
"user": context.user_id,
"session": context.session_id,
})
The gate implements four concrete rules. These are illustrative — real systems will have dozens, often loaded from configuration or a policy engine like OPA. The key design point is that all rules live in one place and every tool call passes through them.
Sandboxing Tool Execution
Even with a permission gate, you must assume a tool could be exploited or behave unexpectedly. Sandboxing ensures the tool runs in an environment where the damage it can do is bounded. For code execution tools, the most practical approach is containerized or process-isolated execution with resource limits.
Below is a sandboxed code execution tool that runs untrusted Python in a separate process with restricted builtins, no network, and a timeout.
import subprocess
import tempfile
import os
import json
class SandboxExecutor:
"""Runs Python code in an isolated subprocess with restrictions."""
SANDBOX_PRELUDE = """
import sys
import builtins
# Block dangerous builtins
_FORBIDDEN = {"open", "exec", "eval", "compile", "__import__",
"globals", "locals", "vars", "input"}
for _name in _FORBIDDEN:
setattr(builtins, _name, lambda *a, _n=_name: (
_ for _ in ()).throw(PermissionError(
f"Use of builtin '{_n}' is blocked in sandbox")))
# Restrict sys modules
sys.modules["subprocess"] = None
sys.modules["socket"] = None
sys.modules["http"] = None
sys.modules["urllib"] = None
sys.modules["os"] = None
"""
def execute(self, code: str, timeout: int = 10) -> dict:
with tempfile.NamedTemporaryFile(
mode="w", suffix=".py", delete=False
) as f:
f.write(self.SANDBOX_PRELUDE)
f.write("\n")
f.write(code)
script_path = f.name
try:
result = subprocess.run(
["python", script_path],
capture_output=True,
text=True,
timeout=timeout,
env={"PATH": os.environ["PATH"]}, # minimal env
cwd=tempfile.gettempdir(),
)
return {
"stdout": result.stdout,
"stderr": result.stderr,
"returncode": result.returncode,
}
except subprocess.TimeoutExpired:
return {
"stdout": "",
"stderr": f"Execution timed out after {timeout}s",
"returncode": -1,
}
finally:
os.unlink(script_path)
This is a baseline. For production, prefer stronger isolation: Docker containers with seccomp profiles, gVisor, Firecracker microVMs, or WebAssembly runtimes like Wasmtime. The subprocess approach here blocks the most obvious escapes but should not be your only boundary for truly adversarial code.
Wiring It All Together
Now we combine the tool definitions, permission gate, and sandbox into a single agent loop. The agent receives an LLM tool-call request, evaluates it, and either executes or escalates.
class SecureAgent:
def __init__(self, tools: list[Tool], gate: PermissionGate,
sandbox: SandboxExecutor):
self.tools = {t.name: t for t in tools}
self.gate = gate
self.sandbox = sandbox
def handle_tool_call(self, tool_name: str, arguments: dict,
context: PolicyContext) -> dict:
if tool_name not in self.tools:
return {"error": f"Unknown tool: {tool_name}"}
tool = self.tools[tool_name]
decision, reason = self.gate.evaluate(tool, arguments, context)
self.gate.record(tool_name, arguments, decision, reason, context)
if decision == Decision.DENY:
return {"error": f"Permission denied: {reason}"}
if decision == Decision.ESCALATE:
return {
"error": "Action requires human approval",
"reason": reason,
"pending_approval": True,
"tool": tool_name,
"arguments": arguments,
}
# Decision.ALLOW — execute
try:
result = tool.handler(arguments)
return {"result": result}
except Exception as e:
return {"error": f"Tool execution failed: {str(e)}"}
# --- Define concrete tools ---
def read_file_handler(args: dict) -> str:
path = args["path"]
with open(path, "r") as f:
return f.read()[:4096] # cap output size
def run_code_handler_factory(sandbox: SandboxExecutor):
def handler(args: dict) -> dict:
return sandbox.execute(args["code"], timeout=args.get("timeout", 10))
return handler
sandbox = SandboxExecutor()
tools = [
Tool(
name="read_file",
description="Read a file from the project directory.",
parameters={
"type": "object",
"properties": {"path": {"type": "string"}},
"required": ["path"],
},
handler=read_file_handler,
permission=ToolPermission(filesystem=True, scope="read-only"),
),
Tool(
name="run_python",
description="Execute Python code in a sandbox.",
parameters={
"type": "object",
"properties": {
"code": {"type": "string"},
"timeout": {"type": "integer"},
},
"required": ["code"],
},
handler=run_code_handler_factory(sandbox),
permission=ToolPermission(subprocess=True, scope="read-write"),
),
]
gate = PermissionGate(auto_approve_scopes={"read-only"})
agent = SecureAgent(tools, gate, sandbox)
# --- Example usage ---
ctx = PolicyContext(
user_id="alice",
session_id="sess-001",
task="Summarize the README",
trust_level="standard",
)
# This is allowed — read-only
print(agent.handle_tool_call("read_file", {"path": "/workspace/project/README.md"}, ctx))
# This is allowed — sandboxed code execution, read-write scope
print(agent.handle_tool_call("run_python", {"code": "print(2 + 2)"}, ctx))
# This would be denied if it tried to write outside the project dir
# (requires a write tool, not shown here for brevity)
Run this and you will see the read-only file call auto-approved, the code execution approved through the sandbox, and any attempt to violate policy blocked with a clear reason recorded in the gate's log.
Human-in-the-Loop Approval
For destructive or high-risk operations, auto-deny is too restrictive and auto-approve is too dangerous. The right answer is escalation: pause the agent, surface the request to a human, and resume only on explicit approval.
class ApprovalStore:
def __init__(self):
self.pending: dict[str, dict] = {}
self.decisions: dict[str, bool] = {}
def create(self, tool: str, arguments: dict) -> str:
approval_id = f"apr-{len(self.pending) + 1}"
self.pending[approval_id] = {"tool": tool, "arguments": arguments}
return approval_id
def resolve(self, approval_id: str, approved: bool):
self.decisions[approval_id] = approved
def is_approved(self, approval_id: str) -> Optional[bool]:
return self.decisions.get(approval_id)
# Extend SecureAgent to handle escalation
class SecureAgentWithApproval(SecureAgent):
def __init__(self, tools, gate, sandbox, approvals: ApprovalStore):
super().__init__(tools, gate, sandbox)
self.approvals = approvals
def handle_tool_call(self, tool_name, arguments, context):
tool = self.tools.get(tool_name)
if not tool:
return {"error": f"Unknown tool: {tool_name}"}
decision, reason = self.gate.evaluate(tool, arguments, context)
self.gate.record(tool_name, arguments, decision, reason, context)
if decision == Decision.DENY:
return {"error": f"Permission denied: {reason}"}
if decision == Decision.ESCALATE:
approval_id = self.approvals.create(tool_name, arguments)
return {
"status": "awaiting_approval",
"approval_id": approval_id,
"reason": reason,
}
return {"result": tool.handler(arguments)}
def resume_after_approval(self, approval_id: str):
if approval_id not in self.pending:
return {"error": "Unknown approval id"}
if not self.approvals.is_approved(approval_id):
return {"error": "Not approved or still pending"}
req = self.approvals.pending[approval_id]
tool = self.tools[req["tool"]]
return {"result": tool.handler(req["arguments"])}
In a real deployment, the approval request would be sent to a Slack channel, a web dashboard, or an email. The agent holds state and resumes when the human responds. This pattern is essential for any agent that can perform irreversible actions.
Best Practices
- Default to deny. Start with no tools enabled and add them per task. An agent with zero tools cannot cause harm.
- Scope tools narrowly. Instead of a single "run_shell" tool, provide "list_files," "read_file," "write_file" as separate tools with distinct permissions. Granularity is your friend.
- Cap outputs and timeouts. Every tool should have a maximum output size and execution time. Unbounded tools are denial-of-service vectors.
- Log everything. Record the tool name, arguments, decision, executor identity, and result for every call. You will need this for debugging and incident response.
- Treat all LLM input as untrusted. Prompt injection means content the agent reads — web pages, emails, documents — can contain instructions. Never let retrieved content elevate permissions.
- Use real isolation for code execution. Subprocess restrictions are a starting point. For production, use containers, microVMs, or Wasm with proper seccomp and network policies.
- Rotate and minimize credentials. If a tool needs an API key, give it a short-lived token scoped to the minimum required actions. Never pass long-lived admin credentials into a sandbox.
- Test your boundaries. Write adversarial tests that try to escape the sandbox, bypass the gate, and abuse tools. If your tests cannot break it, neither can a determined attacker — for now.
Conclusion
Building secure tool-using agents is not a single feature you add at the end; it is an architectural commitment that shapes how you define tools, structure execution, and mediate every action the agent takes. By combining explicit tool schemas, a centralized permission gate, robust sandboxing, and human escalation for high-risk operations, you create a system where the agent's capabilities are powerful but bounded. The goal is not to eliminate risk entirely — that is impossible when giving an LLM real-world effectors — but to make every risk deliberate, visible, and contained. Start with least privilege, layer your defenses, log relentlessly, and treat the boundary between the agent and the world as the most important code in your entire system.