16. Tool Security

Chapter 16 of 18 · 25 min

Function calling expands the attack surface of LLM applications. A compromised or poorly designed tool can expose filesystem access, network capabilities, or sensitive data to malicious queries. Security must be built into the tool architecture from the start.

Input Validation

Every tool must validate inputs before execution:

import re
from typing import Any
from pydantic import BaseModel, validator, constr

class PathInput(BaseModel):
    path: constr(max_length=255, pattern=r'^[a-zA-Z0-9/_.\-]+$')  # Alphanumeric and safe chars only
    
    @validator('path')
    def prevent_path_traversal(cls, v):
        # Block common traversal attempts
        dangerous_patterns = ['../', '..\\', '%2e%2e', '~']
        for pattern in dangerous_patterns:
            if pattern.lower() in v.lower():
                raise ValueError(f"Path traversal attempt detected: {pattern}")
        return v

class ReadFileTool:
    def _validate_path(self, path: str) -> PathInput:
        try:
            return PathInput(path=path)
        except Exception as e:
            raise ValueError(f"Invalid path: {e}")
    
    def _run(self, path: str, max_size: int = 1024 * 1024) -> dict:
        validated = self._validate_path(path)
        
        full_path = f"/allowed/base/dir/{validated.path}"
        
        # Additional size check
        if os.path.getsize(full_path) > max_size:
            raise ValueError(f"File exceeds maximum size: {max_size}")
        
        with open(full_path, 'r') as f:
            return {"content": f.read(), "path": validated.path}

Sandboxed Execution

Tools should execute with minimal privileges:

import os
import resource

class SandboxedExecutor:
    """Execute tools with resource limits and restricted permissions."""
    
    def __init__(self, base_dir: str, max_memory_mb: int = 512):
        self.base_dir = os.path.abspath(base_dir)
        self.max_memory_bytes = max_memory_mb * 1024 * 1024
    
    def execute(self, func: callable, *args, **kwargs):
        # Set resource limits before execution
        resource.setrlimit(resource.RLIMIT_AS, (self.max_memory_bytes, self.max_memory_bytes))
        resource.setrlimit(resource.RLIMIT_CPU, (30, 30))  # 30 seconds CPU time
        
        # Chroot-like restriction using os.chdir
        original_cwd = os.getcwd()
        try:
            os.chdir(self.base_dir)
            
            # Run with restricted environment
            result = func(*args, **kwargs)
            
            # Verify we stayed within allowed directory
            if not self._is_safe_path(os.getcwd()):
                raise SecurityError("Process escaped base directory")
            
            return result
        finally:
            os.chdir(original_cwd)
    
    def _is_safe_path(self, path: str) -> bool:
        real_path = os.path.realpath(path)
        return real_path.startswith(self.base_dir)

Tool Allowlist

Only explicitly allowed tools should be available:

from typing import Callable
from dataclasses import dataclass

@dataclass
class ToolPermission:
    name: str
    allowed: bool
    max_calls_per_minute: int = 60
    requires_audit: bool = False

class SecureToolRegistry:
    def __init__(self):
        self._permissions: dict[str, ToolPermission] = {}
        self._implementations: dict[str, Callable] = {}
    
    def register(self, name: str, implementation: Callable, permission: ToolPermission):
        self._permissions[name] = permission
        self._implementations[name] = implementation
    
    def execute(self, tool_name: str, params: dict) -> Any:
        if tool_name not in self._permissions:
            raise SecurityError(f"Tool '{tool_name}' not found in registry")
        
        permission = self._permissions[tool_name]
        
        if not permission.allowed:
            raise SecurityError(f"Tool '{tool_name}' is not permitted")
        
        if tool_name not in self._implementations:
            raise SecurityError(f"Tool '{tool_name}' has no implementation")
        
        # Audit logging for restricted tools
        if permission.requires_audit:
            self._audit_call(tool_name, params)
        
        return self._implementations[tool_name](**params)
    
    def _audit_call(self, tool_name: str, params: dict):
        import structlog
        logger = structlog.get_logger()
        logger.warning(
            "restricted_tool_call",
            tool=tool_name,
            params=self._redact_sensitive(params)
        )
    
    def _redact_sensitive(self, params: dict) -> dict:
        sensitive_keys = ['password', 'token', 'api_key', 'secret']
        redacted = params.copy()
        for key in sensitive_keys:
            if key in redacted:
                redacted[key] = '***REDACTED***'
        return redacted

SQL Injection Prevention

Database tools require special attention:

import sqlite3

class SafeDatabaseTool:
    def __init__(self, db_path: str):
        self.db_path = db_path
        self._validate_connection()
    
    def _validate_connection(self):
        # Ensure database is in allowed location
        real_path = os.path.realpath(self.db_path)
        if not real_path.startswith("/allowed/databases/"):
            raise SecurityError("Database not in allowed directory")
    
    def execute_query(self, query: str, params: tuple):
        # Whitelist validation
        allowed_keywords = ['SELECT', 'FROM', 'WHERE', 'AND', 'OR', 'LIMIT', 'ORDER BY']
        
        upper_query = query.upper()
        for keyword in allowed_keywords:
            if keyword in upper_query:
                # Verify it's used correctly
                pattern = rf'\b{keyword}\b'
                if not re.search(pattern, upper_query):
                    raise SecurityError(f"Invalid {keyword} usage")
        
        # Parameterized query prevents injection
        conn = sqlite3.connect(self.db_path)
        cursor = conn.cursor()
        
        try:
            cursor.execute(query, params)
            return cursor.fetchall()
        finally:
            conn.close()
EXERCISE

Create a tool that reads files but blocks path traversal attempts (../), enforces a maximum file size, and logs all access to an audit file. Test with both valid paths and traversal attempts.