AI Security

AI Agent Security: Preventing Autonomous AI from Being Weaponized

How to design secure agentic AI systems — covering the principal-agent problem, minimal footprint principles, human-in-the-loop requirements, sandboxing, and audit trail design.

September 29, 20259 min readShipSafer Team

AI agents — systems where an LLM autonomously plans and executes multi-step tasks using tools — represent a qualitative shift in the security threat landscape. Traditional LLM applications have a clear human in the loop: the human asks, the model answers, the human decides what to do with the answer. In agentic systems, the model decides what to do, executes it, observes the result, and continues autonomously.

This autonomy dramatically expands the blast radius of any compromise. A prompt-injected chatbot might produce bad text. A prompt-injected agent might delete your production database, exfiltrate your customer records, or make unauthorized financial transactions.

The Principal-Agent Security Problem

In agentic AI systems, the concept of "principal" becomes critical. The principal is the entity whose intentions the agent is meant to fulfill — typically the human user or the developer who configured the agent. The agent is the system executing tasks on their behalf.

The security problem: an agent's actions can be influenced by multiple parties, not all of whom are the legitimate principal.

Principal hierarchy in a typical AI agent:

  1. The developer (highest authority — defines system prompt, tools, constraints)
  2. The authenticated user (can direct the agent within developer-defined limits)
  3. External content processed by the agent (should have NO authority over agent behavior)

The fundamental vulnerability is that agents routinely violate this hierarchy. When an agent reads a web page, processes an email, or retrieves a document, that content gets injected into the context window alongside the system prompt and user instructions. If that content contains instructions, the agent may execute them — elevating untrusted external content to the same authority as the legitimate principal.

This is the core mechanic of indirect prompt injection in agentic systems.

Minimal Footprint: The First Principle of Agent Security

The single most important agent security principle is minimal footprint: give the agent only the capabilities required to complete the specific task, nothing more.

Permission Scoping

from dataclasses import dataclass
from typing import Callable

@dataclass
class Tool:
    name: str
    description: str
    function: Callable
    risk_level: str  # "read_only", "write_limited", "write_broad", "destructive"
    requires_confirmation: bool

class TaskScopedAgent:
    def __init__(self, task_type: str, user_id: str):
        self.user_id = user_id
        # Each task type gets only the tools it needs
        self.tools = self._load_tools_for_task(task_type)

    def _load_tools_for_task(self, task_type: str) -> list[Tool]:
        TASK_TOOLS = {
            "document_summarization": [
                Tool("read_document", "Read a document", read_document, "read_only", False),
            ],
            "calendar_assistant": [
                Tool("list_events", "List calendar events", list_events, "read_only", False),
                Tool("create_event", "Create a calendar event", create_event, "write_limited", True),
                # NOT: delete_event, modify_other_users_calendars, send_email
            ],
            "code_review": [
                Tool("read_file", "Read source files", read_file, "read_only", False),
                Tool("add_comment", "Add a PR comment", add_comment, "write_limited", True),
                # NOT: merge_pr, modify_files, delete_branch
            ],
        }
        return TASK_TOOLS.get(task_type, [])

Data Access Scoping

Tools should not have broader data access than the task requires:

def read_file_scoped(filename: str, user_id: str, allowed_dir: str) -> str:
    """Read a file, but only from within the user's allowed directory."""
    from pathlib import Path

    base = Path(allowed_dir).resolve()
    target = (base / filename).resolve()

    # Path traversal prevention
    if not str(target).startswith(str(base)):
        raise PermissionError(f"Access denied: {filename} is outside allowed directory")

    if not target.exists():
        raise FileNotFoundError(f"File not found: {filename}")

    return target.read_text()


# Configure agent with scoped file access
agent = Agent(
    tools=[
        lambda f: read_file_scoped(f, user_id=current_user.id, allowed_dir=f"/workspaces/{current_user.id}/")
    ]
)

Human-in-the-Loop: When to Require Confirmation

Not all agent actions are equally reversible. Designing a confirmation system based on action reversibility and impact is more robust than a binary "confirm everything" or "confirm nothing" approach.

Risk Classification for Agent Actions

from enum import Enum

class ActionRisk(Enum):
    REVERSIBLE_READ = "reversible_read"       # List files, read content — no confirmation
    REVERSIBLE_WRITE = "reversible_write"     # Create draft, stage changes — soft confirm
    IRREVERSIBLE_WRITE = "irreversible_write" # Send email, commit to main — hard confirm
    DESTRUCTIVE = "destructive"               # Delete, overwrite — explicit confirm with preview
    FINANCIAL = "financial"                   # Any monetary transaction — hard confirm + amount display
    EXTERNAL = "external"                     # API calls to third-party services — confirm + show params

ACTION_RISK_MAP = {
    "read_file": ActionRisk.REVERSIBLE_READ,
    "list_directory": ActionRisk.REVERSIBLE_READ,
    "write_draft": ActionRisk.REVERSIBLE_WRITE,
    "commit_changes": ActionRisk.IRREVERSIBLE_WRITE,
    "send_email": ActionRisk.IRREVERSIBLE_WRITE,
    "delete_file": ActionRisk.DESTRUCTIVE,
    "create_payment": ActionRisk.FINANCIAL,
    "call_external_api": ActionRisk.EXTERNAL,
}

async def execute_with_confirmation(
    tool_name: str,
    params: dict,
    user_id: str,
) -> Any:
    risk = ACTION_RISK_MAP.get(tool_name, ActionRisk.EXTERNAL)

    if risk == ActionRisk.REVERSIBLE_READ:
        # Execute immediately
        return await tools[tool_name](**params)

    elif risk in (ActionRisk.REVERSIBLE_WRITE, ActionRisk.IRREVERSIBLE_WRITE):
        # Show preview and require approval
        preview = await tools[f"preview_{tool_name}"](**params)
        confirmed = await request_user_confirmation(
            user_id=user_id,
            action=tool_name,
            preview=preview,
            reversible=(risk == ActionRisk.REVERSIBLE_WRITE),
        )
        if not confirmed:
            return {"status": "cancelled", "reason": "User declined"}

    elif risk in (ActionRisk.DESTRUCTIVE, ActionRisk.FINANCIAL):
        # Hard confirmation with explicit acknowledgment
        confirmed = await request_explicit_confirmation(
            user_id=user_id,
            action=tool_name,
            params=params,
            warning=f"This action is {'irreversible' if risk == ActionRisk.DESTRUCTIVE else 'financial'}.",
        )
        if not confirmed:
            return {"status": "cancelled", "reason": "User declined"}

    return await tools[tool_name](**params)

Detecting When Agents Go Off-Script

Monitor agent behavior for signs that it may have been hijacked by injection:

class AgentBehaviorMonitor:
    def __init__(self, original_task: str):
        self.original_task = original_task
        self.actions_taken: list[dict] = []
        self.suspicious_signals: list[str] = []

    def log_action(self, tool_name: str, params: dict, rationale: str):
        self.actions_taken.append({
            "tool": tool_name,
            "params": params,
            "rationale": rationale,
            "timestamp": datetime.utcnow().isoformat(),
        })

        # Check for suspicious patterns
        self._analyze_action(tool_name, params, rationale)

    def _analyze_action(self, tool_name: str, params: dict, rationale: str):
        # Tool not expected for this task type
        if tool_name not in EXPECTED_TOOLS_FOR_TASK[self.task_type]:
            self.suspicious_signals.append(
                f"Unexpected tool call: {tool_name} (task: {self.task_type})"
            )

        # Rationale mentions external instructions
        injection_phrases = ["document said", "instructions in the file", "email instructed"]
        if any(phrase in rationale.lower() for phrase in injection_phrases):
            self.suspicious_signals.append(
                f"Agent rationale suggests following instructions from retrieved content"
            )

        # Data exfiltration pattern: reading sensitive data then calling outbound tool
        recent_tools = [a["tool"] for a in self.actions_taken[-3:]]
        if "read_file" in recent_tools and tool_name in ("send_email", "http_request"):
            self.suspicious_signals.append(
                "Possible exfiltration: file read followed by outbound action"
            )

    def should_pause_for_review(self) -> bool:
        return len(self.suspicious_signals) >= 2

Sandboxing Agent Execution

For agents that execute code, access the filesystem, or run shell commands, containerized sandboxing is essential.

Code Execution Sandboxing

import subprocess
import tempfile
import os

def execute_code_sandboxed(code: str, language: str, timeout_seconds: int = 10) -> dict:
    """Execute agent-generated code in a Docker sandbox."""

    with tempfile.NamedTemporaryFile(mode='w', suffix=f'.{language}', delete=False) as f:
        f.write(code)
        code_file = f.name

    try:
        result = subprocess.run(
            [
                "docker", "run",
                "--rm",                          # Remove container after execution
                "--network=none",                # No network access
                "--memory=256m",                 # Memory limit
                "--cpus=0.5",                    # CPU limit
                "--read-only",                   # Read-only filesystem
                "--tmpfs", "/tmp:size=50m",      # Writable tmp only
                "--security-opt=no-new-privileges",
                f"-v", f"{code_file}:/code.{language}:ro",
                f"sandbox-{language}:latest",
                f"/code.{language}",
            ],
            capture_output=True,
            text=True,
            timeout=timeout_seconds,
        )

        return {
            "stdout": result.stdout[:10000],  # Cap output size
            "stderr": result.stderr[:2000],
            "exit_code": result.returncode,
        }
    except subprocess.TimeoutExpired:
        return {"error": "Execution timed out", "exit_code": -1}
    finally:
        os.unlink(code_file)

File System Sandboxing

Use Linux namespaces or containers to confine file system access:

import os
import subprocess

def run_agent_with_fs_sandbox(agent_fn, user_id: str):
    """Run agent in a chroot jail with only the user's workspace visible."""
    workspace = f"/sandboxes/{user_id}"
    os.makedirs(workspace, exist_ok=True)

    # Use unshare to create a new mount namespace
    # Agent can only see /sandbox which maps to the user's workspace
    subprocess.run(["mount", "--bind", workspace, "/sandbox"], check=True)

    try:
        return agent_fn(workspace="/sandbox")
    finally:
        subprocess.run(["umount", "/sandbox"])

Audit Trails for Agentic Systems

Unlike traditional applications where users explicitly trigger each action, agents take sequences of actions autonomously. Comprehensive audit trails are essential for:

  • Incident investigation when an agent takes unexpected actions
  • Demonstrating compliance (agents acting on behalf of users create audit obligations)
  • Detecting gradual drift in agent behavior over time

Structured Audit Logging

from pydantic import BaseModel
from datetime import datetime

class AgentActionLog(BaseModel):
    trace_id: str                    # Unique ID for the full agent execution trace
    step_number: int                 # Position in the execution sequence
    timestamp: datetime
    user_id: str
    agent_type: str
    tool_name: str
    tool_params: dict                # Parameters passed to the tool
    tool_result_summary: str         # Abbreviated result (not full content, which may be large/sensitive)
    agent_reasoning: str             # The chain-of-thought reasoning for this action
    human_confirmed: bool            # Was this action confirmed by a human?
    execution_duration_ms: int
    content_hash: str                # Hash of full params+result for integrity

class AgentAuditLogger:
    def __init__(self, storage_backend):
        self.storage = storage_backend

    def log_step(self, log: AgentActionLog):
        # Sign the log entry to prevent tampering
        log_dict = log.dict()
        log_dict["signature"] = self._sign(log_dict)
        self.storage.write(log_dict)

    def get_trace(self, trace_id: str) -> list[AgentActionLog]:
        records = self.storage.query({"trace_id": trace_id})
        # Verify integrity of each record
        for record in records:
            self._verify_signature(record)
        return [AgentActionLog(**r) for r in records]

Multi-Agent Security

Increasingly, systems involve multiple agents orchestrating each other. This introduces trust hierarchy questions: when Agent B receives instructions from Agent A, what level of trust should it grant?

class TrustHierarchy:
    TRUST_LEVELS = {
        "system_prompt": 100,    # Developer-defined instructions
        "human_user": 80,        # Authenticated user
        "orchestrator_agent": 60, # Agent calling this agent
        "retrieved_content": 10,  # Content from knowledge base, web, etc.
    }

    def get_effective_permissions(self, instruction_source: str) -> set[str]:
        trust = self.TRUST_LEVELS.get(instruction_source, 0)

        permissions = {"read_owned_data"}  # Always available

        if trust >= 60:
            permissions.add("create_draft")

        if trust >= 80:
            permissions.add("send_message")
            permissions.add("modify_owned_data")

        if trust == 100:
            permissions.add("modify_any_data")
            permissions.add("delete_data")

        return permissions

The key principle: an orchestrator agent should not be able to grant its sub-agents more permissions than the orchestrator itself was granted. This prevents privilege escalation through agent chains.

Incident Response for Agentic Systems

When an agent takes unexpected actions, you need clear incident response procedures:

  1. Immediate: Kill switch to halt all agent execution for affected user/tenant
  2. Containment: Audit trail review to understand exactly what actions were taken
  3. Remediation: Rollback tools for reversible actions (delete draft emails, restore files)
  4. Root cause: Review retrieved content that may have triggered the behavior
  5. Prevention: Update input/output validation, add confirmation steps for implicated tool calls

Build the kill switch before you need it:

class AgentKillSwitch:
    def __init__(self, redis_client):
        self.redis = redis_client

    def halt_all_agents(self, user_id: str, reason: str):
        """Immediately stop all running agent tasks for a user."""
        self.redis.setex(f"agent_halt:{user_id}", 3600, reason)

    def is_halted(self, user_id: str) -> bool:
        return self.redis.exists(f"agent_halt:{user_id}") > 0

# Check at start of every agent step
def execute_agent_step(user_id: str, step_fn):
    if kill_switch.is_halted(user_id):
        raise AgentHaltedException("Agent execution halted by administrator")
    return step_fn()

Agentic AI systems are powerful and the security requirements are correspondingly more demanding. The principles — minimal footprint, explicit trust hierarchies, human confirmation for high-impact actions, sandboxed execution, and comprehensive audit trails — are not suggestions. They are the baseline for responsible deployment of autonomous AI.

AI agents
agentic AI
AI security
autonomous AI
LLM tools
sandboxing

Check Your Security Score — Free

See exactly how your domain scores on DMARC, TLS, HTTP headers, and 25+ other automated security checks in under 60 seconds.