Least Privilege for AI Agents: Runtime Capability Minimization and Reducing Blast Radius

In 1975, Jerome Saltzer and Michael Schroeder published “The Protection of Information in Computer Systems” in the Proceedings of the IEEE — a foundational paper that articulated eight principles for secure system design. One of them, least privilege, states that every program and every user of the system should operate using the least set of privileges necessary to complete the job. The paper was written about time-sharing operating systems. The principle applies with equal force to AI agents executing tool calls in 2026.

Most agent deployments violate it completely.

An agent initialized with a LangChain tool list that includes file_read, file_write, web_fetch, shell_exec, and email_send doesn’t have a security posture. It has a blast radius. If that agent processes a prompt-injected document, follows a malicious URL, or misinterprets a user instruction, an attacker can exfiltrate files to an external endpoint, delete local data, execute arbitrary system commands, and send email from the agent’s identity — all from a single compromised interaction.

This post gives practitioners a concrete framework for enumerating what capabilities an agent actually needs, scoping tool access by task, sandboxing execution environments, and auditing capability creep over time. The goal isn’t to make agents less useful. It’s to bound what a successful attack can accomplish.

Why Agents Violate Least Privilege by Default

The violation isn’t accidental negligence. It follows naturally from how agent frameworks are designed and how developers use them.

Frameworks initialize with broad capability. LangChain’s initialize_agent function accepts a tools list that developers populate incrementally as they add features. AutoGen’s AssistantAgent and UserProxyAgent pattern defaults to allowing arbitrary code execution in the UserProxyAgent — the reasoning being that code execution is often what makes agents useful. CrewAI’s default agent configuration includes all tools registered to the crew. None of these frameworks impose a capability minimum as a default; they impose capability maximum as a ceiling.

Developers add tools iteratively and rarely remove them. An agent that needs web search for one task gets web_fetch. When a later task requires email sending, email_send is added. The first task’s completion never triggers removal of the tools that weren’t used. Tool sets accumulate through additive development cycles without corresponding removal cycles.

“Just in case” grants are common. Because tool registration is cheap and an agent that fails due to missing tools is immediately visible while an agent with excess capabilities is silently dangerous, developers tend to grant capabilities prophylactically. “The agent might need to write a file” becomes an implicit justification for file_write, even when the current task only reads.

Capability inventory is absent from most security audits. Traditional security audits check authentication, authorization at API boundaries, input validation, and secrets management. The agent’s internal tool set — what actions it can take after it receives an authenticated request — rarely appears as a distinct audit surface. Security teams aren’t asking “what can this agent do?” with the same rigor they apply to “what can this service access?”

OWASP LLM Top 10 2025 identifies this as LLM06: Excessive Agency — granting AI systems more capabilities than required for their designated function, enabling disproportionate impact from successful exploitation. It’s the highest-impact capability failure class in the OWASP LLM taxonomy precisely because it’s structural: no individual vulnerability, but a design choice that amplifies every other vulnerability.

Capability Taxonomy for AI Agents

Before applying least privilege, you need a vocabulary for capability classes. Here’s a working taxonomy with six capability dimensions:

1. Data Read Access

  • File system read — access to local files and directories
  • Database read — SQL or NoSQL query execution against application databases
  • Email/calendar read — access to inbox, calendar entries, contact lists
  • Web/browser access — fetching URLs, rendering web pages
  • Credential store read — access to secrets managers, environment variables, configuration files

2. Data Write Access

  • File system write — creating, modifying, or deleting files
  • Database write — INSERT, UPDATE, DELETE operations
  • Email/message send — sending email, Slack, or other messages on behalf of the agent identity
  • Form/API submission — submitting data to external services or web forms

3. Code Execution

  • Shell command execution — running arbitrary commands in a shell
  • Interpreter execution — running code in Python, JavaScript, or other runtimes
  • Container spawning — creating new containers or processes
  • Package installation — modifying the runtime environment through pip install, npm install, or similar

4. Network Access

  • Outbound HTTP — making requests to external URLs
  • API calls — making authenticated calls to third-party services (GitHub, Slack, Stripe)
  • DNS resolution — resolving hostnames (often overlooked as an exfiltration vector via DNS tunneling)
  • Inbound listener — binding to ports or accepting incoming connections

5. System Access

  • Process management — listing, killing, or spawning system processes
  • Environment variable access — reading the process environment (contains credentials in most deployments)
  • Host filesystem mount — access beyond the container’s own filesystem
  • System configuration — modifying system settings, cron jobs, or startup scripts

6. Inter-Agent Delegation

  • Spawning sub-agents — instantiating other agent instances
  • Instructing peer agents — sending instructions to other agents in a multi-agent pipeline
  • Acquiring elevated capabilities — requesting capability expansion at runtime

This taxonomy exists to make capability grants explicit and auditable, not to suggest all capabilities are equivalent risks.

Blast Radius Analysis by Capability Profile

The blast radius of a compromised agent — the maximum damage achievable from a single successful attack — is a function of capability intersection. Individual capabilities have moderate impact; combinations compound dramatically.

Read-only, no network. An agent limited to file-system read with no outbound network access and no write capability. Compromise enables information disclosure within the filesystem scope, with limited exfiltration paths. An attacker cannot exfiltrate data via outbound tool calls or network requests; however, data can still leak through the agent’s response channel — if the agent returns sensitive file contents in its reply, that reply passes through the orchestration layer and may appear in logs, UI, or upstream telemetry. Write capability is absent, so persistent modification is blocked. This is a meaningfully bounded blast radius compared to broader profiles, but it is not exfiltration-proof.

Read + outbound network. Adding outbound HTTP to a read-only agent creates an exfiltration channel. An attacker who injects a malicious instruction can cause the agent to read sensitive files and POST their contents to an attacker-controlled endpoint. This combination is extremely common in RAG agents that need web access alongside document retrieval. The blast radius is: full exfiltration of anything in the agent’s read scope.

Read + write, no network. An agent with filesystem read and write but no network access can corrupt or destroy data within its scope, but cannot exfiltrate it externally. Attackers can cause data loss; they cannot extract and transmit it. Depending on context (if files contain credentials), this can enable persistence or lateral movement within the system.

Read + write + email send. Intersection of these three capabilities is particularly dangerous. This profile — common in productivity agents that help users manage documents and communications — enables: reading all accessible files and email, exfiltrating data by sending email to an attacker-controlled address, and writing to files (potentially including configuration or credential files). All of this happens through a channel (email) that is trusted, authenticated, and typically not monitored for data exfiltration behavior.

Read + write + code execution + network. This profile achieves essentially unlimited blast radius. Code execution with network access enables arbitrary remote code execution — the agent can download and execute attacker-provided code, establish reverse shells, pivot to other systems in the network, and perform all data read/write operations as side effects. A single successful prompt injection against an agent with this profile is equivalent to a full system compromise.

Code execution + package installation. Adding package installation to code execution enables supply chain attacks through the agent’s own runtime — an agent that can pip install from an attacker-controlled package index can be made to download and execute arbitrary code as part of what appears to be a normal dependency resolution.

The pattern is consistent: each added capability multiplies, rather than adds, the blast radius of all preceding capabilities. This is why least privilege as a combination constraint matters more than as a per-capability threshold.

Least Privilege Implementation Patterns

Task-Scoped Tool Sets

The most effective least privilege pattern for agents is not a single tool set but multiple tool sets — one per task type — with each set containing only the tools that task requires.

Instead of:

# Anti-pattern: one agent, all tools
agent = initialize_agent(
    tools=[file_read, file_write, web_fetch, shell_exec, email_send, db_read, db_write],
    llm=llm,
    agent=AgentType.ZERO_SHOT_REACT_DESCRIPTION,
)

Define capability profiles and select the profile that matches the current task:

# Task-scoped profiles
TOOL_PROFILES = {
    "research": [web_fetch, file_read],               # read-only, external access
    "document_processing": [file_read, file_write],   # local file scope only
    "communication": [email_send],                    # send-only, no read
    "data_analysis": [db_read],                       # database read, no write
}

def get_agent_for_task(task_type: str):
    tools = TOOL_PROFILES.get(task_type)
    if tools is None:
        raise ValueError(f"Unknown task type: {task_type}. No capability profile defined.")
    return initialize_agent(tools=tools, llm=llm, agent=AgentType.ZERO_SHOT_REACT_DESCRIPTION)

This approach forces every new task type to justify its capability requirements explicitly. The absence of a capability profile is a hard failure, not a default-to-maximum fallback.

Read-Only by Default; Write Requires Justification

Make read access the baseline and treat write access as a capability that requires explicit justification in the deployment documentation. This applies to all write classes: file system write, database write, email send, API submission.

For LangChain, this means wrapping write tools in an audited layer. Use StructuredTool for tools with multiple parameters, and implement audit logging before the write executes:

import logging
from langchain.tools import StructuredTool

logger = logging.getLogger(__name__)

def confirmed_file_write(path: str, content: str) -> str:
    """Write to a file. Use only when write access is explicitly documented.
    
    Logs the write operation with path and caller context before executing.
    """
    logger.info("file_write: path=%s len=%d", path, len(content))
    with open(path, "w", encoding="utf-8") as fh:
        fh.write(content)
    return f"Wrote {len(content)} bytes to {path}"

# Use StructuredTool for multi-argument write tools
file_write_tool = StructuredTool.from_function(
    func=confirmed_file_write,
    name="file_write",
    description="Write content to a file. Every write is logged.",
)

The pattern enforces audit trail at the capability layer rather than relying on LLM-level introspection or post-hoc log analysis.

Sandboxed Execution Environments

Code-execution capability is the highest-risk single capability class because it can acquire all others. Agents that need code execution should operate in isolated containers with explicit restrictions:

Container isolation fundamentals:

  • No unnecessary host filesystem mounts — avoid -v /:/host or mounting sensitive host paths; use --mount type=tmpfs,destination=/tmp for scratch space rather than host bind mounts
  • No network access unless specifically required (--network=none or a restrictive egress allowlist)
  • Non-root execution inside the container (set USER in the Dockerfile or use --user 1000:1000)
  • Read-only root filesystem where possible (--read-only), with explicit tmpfs mounts for necessary write paths
  • Resource limits (CPU, memory, PIDs) to prevent resource exhaustion (--cpus, --memory, --pids-limit)

For Python interpreter access specifically, RestrictedPython provides a compile-time restricted execution mode that disables certain dangerous constructs. A complete implementation requires installing RestrictedPython’s policy guard helpers so that attribute access, iteration, and assignment work correctly inside restricted bytecode. Python-level restriction is not a security boundary on its own — it is a defense-in-depth supplement to container isolation, not a replacement.

The key export names changed between RestrictedPython versions — always check your installed version’s Guards module. The pattern below targets RestrictedPython ≥ 7.x:

import concurrent.futures
from RestrictedPython import compile_restricted, safe_globals, safe_builtins
from RestrictedPython.Guards import safer_getattr, guarded_getitem_default

def safe_exec(code: str, timeout_seconds: int = 5) -> str:
    """Execute Python code in a restricted environment.
    
    Requires container isolation as the primary containment layer.
    Executed code must assign its output to a variable named `result`.
    Timeout uses a thread executor to work in both main and worker threads.
    """
    restricted_globals = safe_globals.copy()
    restricted_globals["__builtins__"] = safe_builtins
    restricted_globals["_getiter_"] = iter
    restricted_globals["_getattr_"] = safer_getattr
    restricted_globals["_getitem_"] = guarded_getitem_default
    # _write_ guard: return a callable that sets the named attribute
    restricted_globals["_write_"] = lambda obj: obj.__dict__.__setitem__
    # Raise on in-place mutation (+=, -=, etc.) rather than silently ignoring it
    def _inplacevar_(op: str, x, y):
        raise RestrictedOperationError(f"In-place operator '{op}' not permitted")
    restricted_globals["_inplacevar_"] = _inplacevar_

    byte_code = compile_restricted(code, filename="<agent_exec>", mode="exec")
    local_vars: dict = {}

    def _exec():
        exec(byte_code, restricted_globals, local_vars)  # noqa: S102

    # Thread-pool timeout works in both main and worker threads (unlike signal.alarm)
    with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
        fut = pool.submit(_exec)
        try:
            fut.result(timeout=timeout_seconds)
        except concurrent.futures.TimeoutError:
            raise TimeoutError(f"Agent code execution exceeded {timeout_seconds}s limit")

    return str(local_vars.get("result", ""))


class RestrictedOperationError(Exception):
    pass

This doesn’t replace container isolation — it adds a second containment layer so that even a container escape attempt starts from a more restricted execution context.

Network egress restrictions deserve specific attention. An outbound allowlist is more effective than monitoring for the same reason that allowlisting is generally more effective than denylisting: it bounds the attack surface to known-good endpoints rather than trying to enumerate all possible bad ones. For agents that call specific APIs (GitHub, Stripe, an internal service), the allowlist is typically short. For agents that need general web access, consider DNS-based filtering to known-safe categories rather than unrestricted internet access.

Framework-Specific Patterns

LangChain tool filtering:

from langchain.agents import AgentExecutor, create_react_agent
from langchain.hub import pull

# Scope the executor to a specific tool subset
def create_scoped_executor(tool_names: list[str], all_tools: list, llm) -> AgentExecutor:
    tool_map = {t.name: t for t in all_tools}
    missing = [name for name in tool_names if name not in tool_map]
    if missing:
        raise ValueError(f"Unknown tools requested: {missing}. Refusing to create executor.")
    scoped_tools = [tool_map[name] for name in tool_names]
    react_prompt = pull("hwchase17/react")           # standard ReAct prompt template
    agent = create_react_agent(llm, scoped_tools, react_prompt)
    return AgentExecutor(agent=agent, tools=scoped_tools, verbose=False)

# Research task: read + web only
research_executor = create_scoped_executor(
    tool_names=["web_search", "file_read"],
    all_tools=ALL_REGISTERED_TOOLS,
    llm=llm,
)

AutoGen agent system message restrictions: AutoGen’s API for code execution has evolved across major versions. The principle — disable execution for agents that don’t need it, containerize when execution is required — applies across versions. The example below uses the legacy pyautogen / AutoGen 0.2 API which remains widely deployed; for AutoGen 0.4+, the equivalent uses DockerCommandLineCodeExecutor from autogen_ext:

# pyautogen / AutoGen 0.2 API
from autogen import UserProxyAgent

# Agent without code execution — disable explicitly
readonly_agent = UserProxyAgent(
    name="readonly_assistant",
    code_execution_config=False,
    human_input_mode="NEVER",
)

# Agent with Docker-sandboxed execution.
# Additional network and mount isolation must be configured at the Docker
# daemon level — AutoGen does not enforce --network=none or read-only FS.
sandboxed_agent = UserProxyAgent(
    name="sandboxed_coder",
    code_execution_config={
        "use_docker": True,          # enable Docker; optionally specify image name
        "timeout": 30,
        "work_dir": "/tmp/agent_workspace",
    },
    human_input_mode="NEVER",
)

CrewAI task-level tool assignment: CrewAI allows tool assignment at the task level, which is the right granularity:

from crewai import Agent, Task

researcher = Agent(
    role="Researcher",
    goal="Gather information",
    tools=[web_search_tool],    # only web search
)

writer = Agent(
    role="Writer",
    goal="Draft content",
    tools=[file_read_tool],     # only file read — no web, no write
)

# Task-specific tool override further restricts the agent's tools for this specific task
review_task = Task(
    description="Review existing drafts",
    agent=writer,
    tools=[file_read_tool],     # explicitly limit to read for this task
)

Tool Use Logging and Audit Trails

Least privilege creates a clear ground truth for what an agent should be doing. Logging creates the audit trail to detect deviations.

Instrument every tool call with:

  • Agent identity (which agent instance made the call)
  • Tool name and parameters (what was requested)
  • Timestamp and session ID (when, in which context)
  • Success/failure status (did the tool execute)
  • Result summary (not full content for sensitive data — a hash or type annotation)

For LangChain, a custom callback handler makes this automatic:

import hashlib
from uuid import UUID
from langchain.callbacks.base import BaseCallbackHandler

class CapabilityAuditHandler(BaseCallbackHandler):
    """Logs every tool call with agent identity and run-level correlation.
    
    Thread-safe: uses run_id (UUID) as correlation key rather than a shared
    mutable field, so concurrent or nested tool calls don't clobber each other.
    """
    def __init__(self, agent_id: str, session_id: str):
        super().__init__()
        self.agent_id = agent_id
        self.session_id = session_id

    def _common(self, run_id) -> dict:
        return {"agent_id": self.agent_id, "session_id": self.session_id, "run_id": str(run_id)}

    def on_tool_start(self, serialized, input_str, run_id: UUID | None = None, **kwargs):
        tool_name = (serialized or {}).get("name", "unknown")
        input_bytes = input_str.encode() if isinstance(input_str, str) else str(input_str).encode()
        audit_log.record(
            event="tool_start",
            tool=tool_name,
            input_hash=hashlib.sha256(input_bytes).hexdigest()[:8],
            **self._common(run_id),
        )

    def on_tool_end(self, output, run_id: UUID | None = None, **kwargs):
        output_str = str(output)
        audit_log.record(
            event="tool_end",
            output_type=type(output).__name__,
            output_len=len(output_str),
            **self._common(run_id),
        )

This log structure supports two critical use cases: real-time anomaly detection (a research agent making write tool calls should trigger an alert) and periodic capability auditing (which tools are being used vs. registered — the gap represents candidates for removal).

Periodic Capability Review

An agent’s capability set should be treated as a living artifact with the same review cadence as its other configuration. The relevant questions for each registered tool:

  1. Is this tool currently exercised in production? Tool call logs from the last 30 days are the ground truth. A tool with zero calls is a candidate for removal.
  2. Does every current use case require this tool? A tool used in one of ten task types should be moved to that task type’s scoped profile, not kept in the global registry.
  3. Has the risk profile of this tool changed? An API that previously required read-only authentication may now offer write endpoints; the risk of having that tool changes even without changes to the agent.
  4. Are the write tools still justified? Every write capability should have a documented use case in the deployment runbook. If the justification document is stale, the capability should be removed pending re-justification.

This review process doesn’t require significant tooling — a quarterly document review against the tool call audit log is sufficient for most deployments. What matters is that it exists and has a named owner.

NIST Controls Alignment

NIST SP 800-53 revision 5 provides specific controls for least privilege that apply directly to AI agent deployments:

AC-6 Least Privilege requires that the information system employs the concept of least privilege, allowing only authorized accesses for users (or processes acting on behalf of users) which are necessary to accomplish assigned organizational tasks. Sub-controls include:

  • AC-6(1) Authorize Access to Security Functions — explicit authorization for privileged operations
  • AC-6(2) Non-Privileged Access for Non-Privileged Functions — separate accounts for privileged vs. non-privileged activity
  • AC-6(9) Log Use of Privileged Functions — audit trails for all privileged operations
  • AC-6(10) Prohibit Non-Privileged Users from Executing Privileged Functions — enforcement, not just policy

For AI agents, “non-privileged users” maps to the agents themselves, and “privileged functions” maps to write access, code execution, and inter-agent delegation capabilities. The control structure translates directly: separate tool profiles correspond to AC-6(2); the audit handler above addresses AC-6(9); enforcement at the executor level addresses AC-6(10).

The NIST AI Risk Management Framework (AI RMF) MANAGE function additionally requires organizations to identify, assess, and manage risks from AI system behaviors — which implicitly includes capability scope as a risk surface requiring active management.

Defense in Depth Framing

Least privilege is one layer of a multi-layer agent security posture. Understanding its relationship to complementary controls prevents both over-reliance and under-application.

Least privilege bounds the capability ceiling. It determines the maximum damage achievable from any attack that successfully influences agent behavior — whether through prompt injection, jailbreak, or compromised orchestration. It acts before an attack, at design time, by shrinking the attack surface at the tool level.

Circuit breakers detect and halt in-progress misuse. After an agent receives a compromised instruction, circuit breakers monitor for behavioral anomalies — unusual tool call frequencies, unexpected data patterns, velocity limits on write operations — and interrupt the agent loop when misuse is detected. Circuit breakers act during an attack, at runtime, by interrupting execution after compromise but before completion.

Human-in-the-loop oversight catches edge cases. HITL provides a final check on consequential actions — particularly those involving write operations, financial transactions, or external communications. HITL acts at decision points, by inserting human judgment at capability exercise boundaries.

Zero-trust architecture and identity scoping complement capability minimization at the identity and credential layer. An agent with a minimal tool set operating under a narrowly scoped service identity achieves defense in depth at both the capability and credential dimensions.

These layers are independent — failure of one doesn’t defeat the others. An agent that bypasses circuit breakers still operates within its capability ceiling. An agent that successfully exfiltrates via email can still only access data within its read scope. The effectiveness of each layer adds to, rather than substitutes for, the others.

Practical Audit Checklist

For practitioners auditing an existing deployment:

Capability enumeration:

  • List every tool registered to every agent instance in the deployment
  • Map each tool to the capability taxonomy (read/write/exec/network/system/delegation)
  • For each write and exec capability, document the specific use case that justifies it

Blast radius assessment:

  • Identify which capability intersections are present (read + network? exec + network?)
  • For each dangerous intersection, assess what exfiltration or damage is possible
  • Prioritize removal of capabilities that create the most dangerous intersections

Implementation review:

  • Confirm agents are initialized with task-scoped tool sets, not global tool registries
  • Confirm code execution capability runs in a sandboxed container, not on the host
  • Confirm outbound network access is allowlisted to known endpoints, not unrestricted
  • Confirm write operations are logged with agent identity and session ID

Ongoing hygiene:

  • Review tool call logs quarterly to identify unused registered tools
  • Assign a named owner for the capability review process
  • Include capability scope in new-agent deployment review

Starting Point

The goal of this framework isn’t perfection. An agent deployed with ten capabilities and no audit trail is more exposed than one deployed with six capabilities and a functioning audit log, even if six is not the theoretical minimum. Improvement is achievable incrementally.

A reasonable starting point: enumerate every capability in the current deployment, remove any with no documented use case, and add a tool call audit handler to the executor. That alone — enumeration, removal, and logging — will reveal the actual capability footprint, eliminate passive accumulation, and create the audit trail needed to detect when the footprint changes unexpectedly.

Saltzer and Schroeder’s principle is fifty years old. The implementation details change with every framework release. The underlying logic does not: every capability you grant an agent is a capability an attacker can use against you. Minimize the grant.


This post is part of a series on AI agent security. Related posts: Zero-Trust Architecture for AI Agent Deployments covers identity and trust boundary architecture; Non-Human Identity Security for AI Agents covers credential and OAuth scoping; Circuit Breakers for AI Agent Controllability covers runtime misuse detection. Together these address different layers of the same threat model.