← Back to DevBytes

Building Secure Tool-Using Agents: Sandboxing and Permissions

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:

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:

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

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.

— Ad —

Google AdSense will appear here after approval

← Back to all articles