AI agent security

Claude Managed Agents security guide

Use this eight-item guide on Anthropic's hosted agent harness. Install @anthropic-ai/sdk rather than claude-agent-sdk, know that Anthropic executes built-in tools with no customer pre-execution hook, screen inbound user.message with guardEvents, gate custom tools on agent.custom_tool_use, deny with user.custom_tool_result and is_error instead of throwing, don't treat always_ask as policy, correlate on an ID you own, and guard any MCP server you host.

11 min read
In short: Use this eight-item guide on Anthropic's hosted agent harness. Install @anthropic-ai/sdk rather than claude-agent-sdk, know that Anthropic executes built-in tools with no customer pre-execution hook, screen inbound user.message with guardEvents, gate custom tools on agent.custom_tool_use, deny with user.custom_tool_result and is_error instead of throwing, don't treat always_ask as policy, correlate on an ID you own, and guard any MCP server you host.

What is the Claude Managed Agents security guide?

Use this guide on Claude Managed Agents, Anthropic's hosted agent harness, through @anthropic-ai/sdk (>=0.86.0 <1) or the Python anthropic SDK (>=0.92.0,<2).

Managed Agents is a different product from the Claude Agent SDK, and the difference is the whole security story. The Agent SDK runs a local query() loop on your machine, where a PreToolUse hook can deny Bash before it runs. Managed Agents runs the loop in Anthropic's environment. Anthropic executes the built-in toolset – bash, files, and the rest – and your application does not get a pre-execution hook for any of it.

That changes what "securing the agent" means. You are not gating the agent's whole tool surface, because you don't hold it. You are gating the two boundaries your application still owns, and being precise about which risks that does and doesn't cover.

Arcjet Guard is a runtime policy layer for AI agents. You define rules – prompt-injection detectors, rate limits, PII checks, allowlists – and Guard allows or denies before side effects run. The examples below use @arcjet/guard/claude-managed-agents/v0 and its Python counterpart, arcjet.guard.claude_managed_agents.

Work through these eight topics in order. Each one is a control you can verify, not a slogan.

  • Install the right peer. This adapter uses the Anthropic API SDK, not claude-agent-sdk.
  • Know which boundaries you hold and which Anthropic holds.
  • Screen inbound user.message before you send it. This is the only pre-model gate.
  • Gate custom tools on agent.custom_tool_use, where your application still executes the handler.
  • Deny by returning user.custom_tool_result with is_error. Never throw.
  • Don't treat always_ask or user.tool_confirmation as a policy gate.
  • Correlate on an ID you own, never on an Anthropic session or event ID.
  • Put Guard inside any MCP server you host, because Anthropic is the MCP client.

How do you install the Claude Managed Agents adapter?

The peer is the Anthropic API SDK. Installing claude-agent-sdk or @anthropic-ai/claude-agent-sdk for this adapter is the most common opening mistake – they are the other product.

In JavaScript, @anthropic-ai/sdk (>=0.86.0 <1) is an optional peer of @arcjet/guard, not a dependency of it. Node.js 22 or later.

Terminal window
npm install @arcjet/guard @anthropic-ai/sdk

Import from the versioned path. The Managed Agents API is pre-1.0, so the segment is v0, and there is no unversioned alias – @arcjet/guard/claude-managed-agents does not resolve.

import { launchArcjet } from "@arcjet/guard";
import {
claudeManagedAgentsContext,
guardCustomTool,
guardEvents,
} from "@arcjet/guard/claude-managed-agents/v0";
export const arcjet = launchArcjet({ key: process.env.ARCJET_KEY! });

In Python, install the extra. It depends on anthropic>=0.92.0,<2 and needs CPython 3.10 or later.

Terminal window
pip install "arcjet[claude-managed-agents]"
from arcjet.guard import launch_arcjet
from arcjet.guard.claude_managed_agents import (
claude_managed_agents_context,
guard_custom_tool,
guard_events,
)
arcjet = launch_arcjet(key=ARCJET_KEY)

Use launch_arcjet_sync in Flask, Django, or other synchronous code.

Commit a lockfile, run npm audit or pip-audit in CI, and pin the pre-1.0 peers. A pre-1.0 minor can move an event schema without a major-version signal, so treat an upgrade as a review event that re-checks the gate still fires.

Which Claude Managed Agents boundaries can you actually enforce?

Be explicit about this before writing any code, because the honest answer changes your architecture.

SurfaceWho executes itCan you deny it?

Inbound user.message

You, before events.send

Yes – guardEvents / guard_events

Custom tools (agent.custom_tool_use)

Your application

Yes – guardCustomTool / guard_custom_tool

Built-in tools (agent.tool_use): bash, files

Anthropic

No. The toolset defaults to always_allow and there is no customer pre-execution hook

MCP tools on a server Anthropic reachesAnthropic is the MCP clientOnly if you host the server, from inside its handlers

The two rows you control are real gates: on a deny, the effect does not happen. The two you don't are not gaps in the adapter, they are the shape of a hosted harness. By the time you observe an agent.tool_use event, that built-in tool has already run.

Design around that. Anything you cannot afford an agent to do unreviewed must be a custom tool your application executes, not a built-in one – because a custom tool is a boundary you hold and a built-in one isn't. That single decision does more for the security of a Managed Agents deployment than any rule you write afterwards. See AI agent security architecture for how this compares to in-process and proxy enforcement.

How do you screen inbound text in Claude Managed Agents?

There is no guardInbound and no UserPromptSubmit hook – Anthropic doesn't expose a local pre-prompt hook on this product. guardEvents / guard_events is the gate, and it wraps the send itself. On a deny the event is never sent and the model never sees the prompt.

This is the only place a turn can be declined before the model reads it, which makes it the right home for prompt-injection screening.

import Anthropic from "@anthropic-ai/sdk";
import { detectPromptInjection } from "@arcjet/guard";
import {
claudeManagedAgentsContext,
guardEvents,
} from "@arcjet/guard/claude-managed-agents/v0";
import { arcjet } from "./arcjet.js";
const client = new Anthropic();
export async function sendTurn(
sessionId: string,
conversationId: string,
userText: string,
) {
const inbound = await guardEvents(
arcjet,
{
events: [
{ type: "user.message", content: [{ type: "text", text: userText }] },
],
inbound: {
action: "message.received",
rules: ({ text }) => [detectPromptInjection()(text)],
},
context: claudeManagedAgentsContext({ correlationId: conversationId }),
},
(body) => client.beta.sessions.events.send(sessionId, body),
);
if (!inbound.allowed) {
return inbound.message;
}
}

guardEvents answers { allowed: true, sent } or { allowed: false, outcome, message }. On a deny it never calls the send callback. Only user.message events are screened.

The Python API is shaped differently, and the difference is easy to get wrong. There is no inbound option and no inbound helper: action and rules sit at the top level, the wrapper takes send=, and what it returns replaces send. On a deny it raises ArcjetDeniedError rather than returning a result.

from anthropic import AsyncAnthropic
from arcjet.guard import ArcjetDeniedError, DetectPromptInjection, launch_arcjet
from arcjet.guard.claude_managed_agents import guard_events
client = AsyncAnthropic()
arcjet = launch_arcjet(key=ARCJET_KEY)
inbound = DetectPromptInjection()
send = guard_events(
guard=arcjet,
send=client.beta.sessions.events.send,
action="message.received",
session_id=CONVERSATION_ID,
rules=lambda arguments: [inbound(arguments["prompt"])],
)
async def send_turn(session_id: str, user_text: str) -> bool:
try:
await send(
session_id,
events=[
{
"type": "user.message",
"content": [{"type": "text", "text": user_text}],
}
],
)
except ArcjetDeniedError:
return False
return True

Pass an async Anthropic client. A blocking client makes the wrapper synchronous, and it then can't be awaited from a coroutine.

Both helpers default to failing closed. On inbound specifically, onGuardError: "allow" / on_guard_error="allow" is a defensible choice: failing closed here stops the agent answering at all during a Guard outage, and inbound screening is a filter rather than an authorization. Decide it deliberately instead of inheriting the default. A real DENY always blocks regardless of that setting.

How do you gate custom tools on agent.custom_tool_use?

Custom tools are the surface where your application still executes the handler, so this is where a policy can stop a side effect. Anthropic's permission policies do not apply to them.

When the session emits agent.custom_tool_use, wrap the handler you are about to run. On a DENY – or when Guard can't be evaluated and onGuardError is "deny" – the handler does not run.

import Anthropic from "@anthropic-ai/sdk";
import { localDetectSensitiveInfo, tokenBucket } from "@arcjet/guard";
import {
claudeManagedAgentsContext,
guardCustomTool,
} from "@arcjet/guard/claude-managed-agents/v0";
import type { AgentCustomToolUseEvent } from "@arcjet/guard/claude-managed-agents/v0";
import { arcjet } from "./arcjet.js";
const client = new Anthropic();
const lookupLimit = tokenBucket({
bucket: "lookups",
refillRate: 10,
intervalSeconds: 60,
maxTokens: 10,
});
const detectPii = localDetectSensitiveInfo({
deny: ["EMAIL", "PHONE_NUMBER", "IP_ADDRESS", "CREDIT_CARD_NUMBER"],
});
export function lookupOrder(
event: AgentCustomToolUseEvent,
sessionId: string,
conversationId: string,
) {
return guardCustomTool(
arcjet,
{
event,
execute: async (input) => ({
orderId: String(input.orderId),
status: `shipped (${String(input.note)})`,
}),
send: (result) =>
client.beta.sessions.events.send(sessionId, { events: [result] }),
},
{
action: "order.looked-up",
rules: (input) => [
lookupLimit({ key: String(input.orderId), requested: 1 }),
detectPii(String(input.note)),
],
context: claudeManagedAgentsContext({ correlationId: conversationId }),
},
);
}

guardCustomTool answers { allowed: true, output }, or { allowed: false, result } after it has already sent the denial through send. Only send user.custom_tool_result yourself when the answer is allowed, with custom_tool_use_id set to the triggering event id – otherwise you send a second result for a call that already has one.

The Python helper takes run for the hosted path and returns a handler you call as await handler(event, send=..., session_id=...). On a deny it sends the error result itself and answers None.

from arcjet.guard import LocalDetectSensitiveInfo, TokenBucket, launch_arcjet
from arcjet.guard.claude_managed_agents import guard_custom_tool
arcjet = launch_arcjet(key=ARCJET_KEY)
lookup_limit = TokenBucket(
refill_rate=10,
interval_seconds=60,
max_tokens=10,
bucket="lookups",
)
detect_pii = LocalDetectSensitiveInfo(
deny=["EMAIL", "PHONE_NUMBER", "IP_ADDRESS", "CREDIT_CARD_NUMBER"],
)
async def lookup_order(event) -> dict:
return {
"order_id": event.input["order_id"],
"status": f"shipped ({event.input['note']})",
}
guarded_lookup = guard_custom_tool(
guard=arcjet,
run=lookup_order,
action="order.looked-up",
session_id=CONVERSATION_ID,
rules=lambda arguments: [
lookup_limit(key="orders", requested=1),
detect_pii(arguments["note"]),
],
)

In Python, LocalDetectSensitiveInfo() with neither allow nor deny fails during local evaluation and still reports an ALLOW conclusion, so the check looks configured and blocks nothing. Always pass an explicit list in both languages.

Key the rate limit on a trusted identifier, not on free-text the model produced. Scan the free-text arguments – a note, a reason, a body. An opaque orderId won't trip email, phone, card, or IP detection, so passing it to the PII helper adds cost and no coverage. That helper runs on a local model backend, so the text never leaves your process. See detecting and redacting PII in LLM inputs and outputs.

How do you return a Claude Managed Agents denial?

Return the denial as a user.custom_tool_result event with is_error set, and put the denial payload in the result text so the model can inspect it. is_error is on the events schema; there is no second field to invent.

Don't throw. A throw is a raw exception in your event handler, not a result the session can read. The model is left waiting on a tool result that never arrives, and the failure surfaces as a broken conversation rather than a decision.

Both helpers default to onGuardError: "deny" / on_guard_error="deny". If Guard can't be evaluated, inbound user.message is not sent and the custom-tool handler does not run. Set "allow" only where you can accept running the action without a complete security decision. A DENY conclusion always blocks regardless.

The core guard() call still fails open, and reports it through hasFailedOpen() / has_failed_open(). The wrappers that sit around an effect fail closed. If you make a direct guard() call anywhere in this flow, treat an ALLOW as unproven until you have checked that flag.

Why isn't always_ask a Claude Managed Agents policy gate?

The agent toolset defaults to always_allow. Built-in bash and file tools run in Anthropic's environment with no customer pre-execution hook.

always_ask is opt-in. It pauses the session for a user.tool_confirmation event. That is human-in-the-loop confirmation, not policy: it asks a person, it doesn't evaluate a rule, and allowed-tool lists and confirmation results can approve a built-in after the fact. There is no guardApproval and no canUseTool on this adapter – those names belong to the Claude Agent SDK.

Don't put Arcjet policy on user.tool_confirmation. Use guardCustomTool / guard_custom_tool for the tools your application executes. See human approval is not a security policy.

What about MCP servers in Claude Managed Agents?

Anthropic is the MCP client. When a session calls an MCP tool, Anthropic connects to the server directly, and you get no local PreToolUse hook.

That splits into two cases. If you host the MCP server, put Guard inside that server's tool handlers – the handler is a boundary you own, and it's the same enforcement point you'd use for any other server-side tool. If Anthropic reaches a server you don't run, you cannot deny that call from this adapter at all, and no configuration will change that.

Treat a third-party MCP server reachable from a Managed Agents session as an unguarded capability with your agent's authority behind it, and scope what the session can reach accordingly. See securing MCP server agent tool calls and the lethal trifecta, which is the exfiltration shape this creates when private data and an outbound channel meet untrusted content.

How do you correlate Claude Managed Agents decisions?

claudeManagedAgentsContext / claude_managed_agents_context reads a correlation ID your application owns, such as the conversation ID you already store against the user. It never mints one.

It drops Anthropic's identifiers – session IDs (sesn_…), event IDs (sevt_…), and the agent.custom_tool_use id – because they aren't IDs you created. This trips people up, because Anthropic's session id is right there and looks like the obvious key. It has a different job: it addresses the session you send events to. Correlation is about joining your decisions to your conversation.

const session = await client.beta.sessions.create({
agent: AGENT_ID,
environment_id: ENVIRONMENT_ID,
});
// session.id addresses the session. conversationId correlates the
// decisions, and it is yours.
const context = claudeManagedAgentsContext({ correlationId: conversationId });

If you omit a correlation ID, the call is uncorrelated rather than joined to a generated one. That is the honest outcome – don't substitute a generated value to fill the gap.

Pass the same ID on inbound screening and on every custom-tool call in the conversation, so one Sequence covers the whole turn. See compliance evidence for AI agents for what that record is worth after the fact.

How do you verify the gates actually fire?

A missing decision is not a denial. If the model answers from context instead of calling the custom tool, nothing is sent, no guard call happens, and no decision is returned – which looks identical to a working gate, because in both cases the side effect didn't happen.

Read the decision in the Arcjet Console or your logs rather than concluding from the absence of an effect. Give a test agent a system prompt telling it to complete the request without follow-up questions and to quote retrieved values verbatim; a model that masks a card number itself leaves the rule nothing to detect, and Guard then correctly allows.

Check each gate separately. Confirm the inbound helper refuses to send on a prompt-injection deny. Confirm a custom-tool deny arrives as user.custom_tool_result with is_error set and that your handler didn't run. Confirm the fail-closed path by making Guard unreachable. And confirm what you already know you can't gate: watch a built-in tool run without a decision, so the boundary is documented rather than assumed. See functional testing of security rules.

What do you do after this guide?

Confirm each item against one sensitive session: a prompt that asks for a refund, a custom tool that takes a free-text note, and one built-in tool call so you can see for yourself where the boundary sits.

If you are choosing between products rather than already committed, read the Claude Agent SDK security guide alongside this one. The Agent SDK gives you PreToolUse over the whole tool surface including Bash; Managed Agents gives you a hosted environment and two boundaries. That trade-off, not the rule syntax, is the decision.

Then apply AI agent runtime security for the sequence, budget, and identity controls that aren't Managed-Agents-specific.

Frequently asked questions

What is the Claude Managed Agents security guide?

Use this eight-item guide on Anthropic's hosted agent harness. Install @anthropic-ai/sdk rather than claude-agent-sdk, know that Anthropic executes built-in tools with no customer pre-execution hook, screen inbound user.message with guardEvents, gate custom tools on agent.custom_tool_use, deny with user.custom_tool_result and is_error instead of throwing, don't treat always_ask as policy, correlate on an ID you own, and guard any MCP server you host.

Is this the same as the Claude Agent SDK?

No. The Claude Agent SDK is a local query() loop with PreToolUse hooks over the whole tool surface. Claude Managed Agents is Anthropic's hosted harness, where Anthropic executes the built-in toolset. The peer here is @anthropic-ai/sdk, not claude-agent-sdk.

Can I deny a built-in bash or file tool?

No. The agent toolset defaults to always_allow and Anthropic runs those tools in its own environment with no customer pre-execution hook. Make anything you need to gate a custom tool your application executes.

How should a denied custom tool respond?

Send user.custom_tool_result with is_error set and the denial payload in the result text. Don't throw — a throw is a raw exception rather than a result the session can read.

Can I correlate on the Anthropic session id?

No. claudeManagedAgentsContext drops Anthropic session and event ids (sesn_…, sevt_…) because they aren't ids you created. Pass a conversation id your application owns, or leave the call uncorrelated.

AI runtime security in your code

Protect your AI agent workflows with Arcjet

Arcjet guards run inside the tool, so the allow or deny arrives before the side effect rather than after it.