Skip to content

Claude Agent SDK Python agent guard

Claude Agent SDK Python agents call authored @tool handlers, built-in tools, and MCP tools. Arcjet Guard sits at those boundaries so a policy can allow or deny the action before a side effect runs.

What is Arcjet? Arcjet is the runtime security platform that ships with your code. Enforce budgets, stop prompt injection, detect bots, and protect personal information with Arcjet's AI security building blocks.

Use protect() on HTTP routes. Use the helpers on this page for agent tools and other actions that have no HTTP request.

You need an Arcjet account and an ARCJET_KEY. Launch one client at module scope and reuse it.

Framework wrappers take an action string such as email.sent. That slug selects the matching remote policy and names the event in the Arcjet Console. Direct guard() calls use the field name label for the same slug. Don’t pass label to a wrapper such as guardTool().

You can submit SDK rules in code, rely on a published remote policy, or combine both. For more information about the decision model, see Agent guards.

This adapter is the Python claude-agent-sdk package plus guard_tool, guard_hooks, and claude_agent_context. It is not the JavaScript @arcjet/guard/claude-agent-sdk/v0 adapter. Don’t wrap these tools with @arcjet/guard/claude-agent-sdk/v0.

Until arcjet.guard.claude_agent_sdk is published, pin arcjet to 9ea0b06a. Published arcjet 1.0.0 on PyPI does not include this extra.

Terminal window
pip install "arcjet[claude-agent-sdk] @ git+https://github.com/arcjet/arcjet-py@9ea0b06a87bcee77b8df0664338c712c4668b87b"

arcjet[claude-agent-sdk] depends on claude-agent-sdk>=0.2.127,<1. CPython 3.10 or later.

Import helpers from arcjet.guard.claude_agent_sdk:

from arcjet.guard.claude_agent_sdk import (
claude_agent_context,
guard_hooks,
guard_tool,
)

Launch one client at module scope:

from arcjet.guard import launch_arcjet
arcjet = launch_arcjet(key=ARCJET_KEY)

Use launch_arcjet_sync with Flask, Django, or other sync code.

Pick the surface that matches what you hold when the effect runs:

You haveUseNeedsBlocks a call?
Any Python callableguard_action / guard_action_syncarcjetYes
An authored @toolguard_toolarcjet[claude-agent-sdk]Yes
An unwrapped built-in or MCP toolguard_hooks (PreToolUse)arcjet[claude-agent-sdk]Yes
Inbound user textguard_hooks (UserPromptSubmit)arcjet[claude-agent-sdk]Yes
A caller-owned UUID session_idclaude_agent_contextarcjet[claude-agent-sdk]No

guard_tool wraps an authored @tool. On DENY, or when Guard cannot be evaluated and on_guard_error is "deny", the helper returns JSON of ArcjetDenialResult in content with is_error: True. The tool handler does not run. Python does not forward structuredContent. The model reads JSON from content. Don’t raise. A raise is a raw exception. Omitting is_error looks like success.

guard_hooks screens inbound text on UserPromptSubmit and denies unwrapped built-in and MCP tools on PreToolUse. There is no inbound helper. Screen prompt injection on the hooks inbound path.

claude_agent_context reads a caller-owned UUID session_id from hook input, then the session_id= fallback. It never mints an ID. It never reads trace_id. If the caller did not pass a UUID session_id, the call is uncorrelated.

There is no guard_inbound. There is no guard_can_use_tool and no guard_approval. can_use_tool is human-in-the-loop (HITL) confirmation, not policy.

Don’t also wrap these tools with @arcjet/guard/claude-agent-sdk/v0.

guard_tool accepts this contract:

OptionRequiredDescription
guardYesClient from launch_arcjet or launch_arcjet_sync.
actionYesGuard label and capture name. Use resource.verb in the past tense, such as email.sent.
rulesNoBound SDK rule inputs, or a function of the parsed tool arguments. Omit to submit none.
session_idNoCaller-owned UUID. An authored handler has no extra.session_id, so pass the same id you give ClaudeAgentOptions. Never minted.
on_guard_errorNo"deny" (default) or "allow". A real DENY always blocks.

guard_hooks accepts this contract:

OptionRequiredDescription
guardYesClient from launch_arcjet or launch_arcjet_sync.
actionNoGuard label and capture name. A string, or a function of the hook input. Defaults to {tool_name}.invoked when a tool hook is registered.
rulesNoBound SDK rule inputs, or a function of the tool arguments. Omit to submit none.
inboundNoPolicy for UserPromptSubmit. Requires action. rules receives {"prompt": ...}.
excludeNoTools already wrapped with guard_tool. Pass {"server": ..., "name": ...} to match mcp__{server}__{name}, or a bare string for a built-in such as "Bash".
session_idNoCaller-owned UUID fallback. Hook session_id is preferred. Never minted.
on_guard_errorNo"deny" (default) or "allow". A real DENY always blocks.

guard_hooks needs a tool policy (action or rules), an inbound policy, or both. There is no guard_inbound.

claude_agent_context reads a caller-owned UUID session_id from hook input, then the session_id= fallback. It never mints an ID. It never reads trace_id.

There is no inbound helper. Screen prompt injection on guard_hooks inbound in UserPromptSubmit. This is the only place a turn can be declined before the model sees the prompt.

On DENY, UserPromptSubmit returns {"decision": "block"}. The model never sees the prompt.

Helpers default to on_guard_error="deny". "allow" is a legitimate choice on inbound, because failing closed there stops the agent answering during an outage.

Pass the UUID session_id that you already have. Don’t mint one. If you omit a UUID session_id, the call is uncorrelated.

from claude_agent_sdk import ClaudeAgentOptions, query
from arcjet.guard import DetectPromptInjection, launch_arcjet
from arcjet.guard.claude_agent_sdk import guard_hooks
arcjet = launch_arcjet(key=ARCJET_KEY)
inbound = DetectPromptInjection()
options = ClaudeAgentOptions(
session_id=conversation_id,
hooks=guard_hooks(
guard=arcjet,
session_id=conversation_id,
inbound={
"action": "message.received",
"rules": lambda arguments: [inbound(arguments["prompt"])],
},
),
)
async for message in query(prompt=user_text, options=options):
pass

guard_tool wraps an authored @tool. That authored handler is the local side effect this helper can stop.

On DENY the handler does not run. The helper returns JSON of ArcjetDenialResult in content with is_error: True:

{
"arcjetDenied": true,
"reason": "RATE_LIMIT",
"message": "Arcjet denied this call (RATE_LIMIT). It may be retried after 30 seconds.",
"retryable": true,
"retryAfterSeconds": 30
}

Python does not forward structuredContent. The model reads JSON from content. Don’t raise. A raise is a raw exception. Omitting is_error looks like success.

Scan free-text args (a note, reason, or body). An opaque order_id does not trip email, phone, card, or IP, so don’t pass it to LocalDetectSensitiveInfo. That helper runs on a local ML model backend.

from claude_agent_sdk import tool
from arcjet.guard import (
LocalDetectSensitiveInfo,
TokenBucket,
launch_arcjet,
)
from arcjet.guard.claude_agent_sdk import guard_tool
arcjet = launch_arcjet(key=ARCJET_KEY)
lookup_limit = TokenBucket(
refill_rate=10,
interval_seconds=60,
max_tokens=10,
bucket="lookups",
)
detect_pii = LocalDetectSensitiveInfo()
@tool("lookup_order", "Look up an order by ID", {"order_id": str, "note": str})
async def lookup_order(args: dict) -> dict:
return {
"content": [
{
"type": "text",
"text": f"{args['order_id']}: shipped ({args['note']})",
}
]
}
guarded_lookup = guard_tool(
guard=arcjet,
tool=lookup_order,
action="order.looked-up",
session_id=conversation_id,
rules=lambda arguments: [
lookup_limit(key="orders", requested=1),
detect_pii(arguments["note"]),
],
)

Built-ins (Bash, Write, and similar) and MCP tools not passed through guard_tool are gated here. PreToolUse returns permissionDecision: "deny", so the tool does not run. permissionDecision: "ask" is HITL, not deny.

Don’t apply guard_tool and PreToolUse to the same authored tool. That double-calls the guard.

from arcjet.guard import DetectPromptInjection, TokenBucket
from arcjet.guard.claude_agent_sdk import guard_hooks
mcp_limit = TokenBucket(
refill_rate=20,
interval_seconds=60,
max_tokens=20,
bucket="mcp-access",
)
hooks = guard_hooks(
guard=arcjet,
session_id=conversation_id,
action=lambda hook: f"{hook['tool_name']}.invoked",
rules=[mcp_limit(key="mcp", requested=1)],
exclude=[{"server": "support", "name": "lookup_order"}],
inbound={
"action": "message.received",
"rules": lambda arguments: [
DetectPromptInjection()(arguments["prompt"])
],
},
)

Pass hooks to ClaudeAgentOptions. Use this for tools you did not pass through guard_tool.

PreToolUse fires for every tool. List your wrapped tools in exclude or each one is guarded twice per invocation.

Entries match the reported name exactly. An authored tool arrives as mcp__{server}__{name}, so pass {"server": ..., "name": ...} and the qualified name is built for you. A bare string matches as-is, which is what you want for a built-in such as "Bash". A bare authored name does not match every server’s tool of that name.

can_use_tool pauses the run for HITL confirmation. allowed_tools, allow rules, and bypassPermissions / acceptEdits can skip that callback.

There is no approval helper. Don’t wrap can_use_tool as Guard. permissionDecision: "ask" is HITL, not deny. Use guard_tool on authored @tool handlers you own, or PreToolUse through guard_hooks for unwrapped tools.

guard_tool and guard_hooks default to on_guard_error="deny". If Guard cannot be evaluated, guard_tool returns JSON of ArcjetDenialResult in content with is_error: True and the tool handler does not run. Inbound UserPromptSubmit blocks the prompt.

Set on_guard_error="allow" only when you can accept running the action without a complete security decision. "allow" is a legitimate choice on inbound UserPromptSubmit because failing closed there stops the agent answering during an outage. The helpers reject any other value.

A DENY conclusion always blocks, regardless of on_guard_error.

The core guard() call still fails open. It returns ALLOW, and has_failed_open() returns True. The wrappers that sit around an effect fail closed.

For more information about fail-open versus fail-closed behavior, see Availability and fail behavior.

query() has no conversation or session ID unless you pass one. Pass the UUID session_id that you already have. Don’t mint an ID. claude_agent_context reads hook session_id, then the session_id= fallback. An authored handler has no extra.session_id, so pass session_id= on guard_tool. If you omit a UUID session_id, the call is uncorrelated rather than joined to a generated ID. trace_id is never read.

The Claude Agent SDK requires session_id to be a UUID when you pass one. Anything else exits with an invalid session ID error. Reusing the same id on a second query() exits as already in use.

options = ClaudeAgentOptions(
session_id=conversation_id,
hooks=guard_hooks(
guard=arcjet,
session_id=conversation_id,
inbound={
"action": "message.received",
"rules": lambda arguments: [inbound(arguments["prompt"])],
},
),
)

Derive the ID from a session that the caller already has. A generated ID still joins this run’s events. It does not match an ID that you already search for.

  • There is no inbound helper. Screen prompt injection on guard_hooks inbound in UserPromptSubmit.
  • There is no approval helper. can_use_tool is HITL confirmation, not policy.
  • Don’t raise from guard_tool to signal a denial. Return JSON in content with is_error: True.
  • Don’t depend on structuredContent. Python does not forward it.
  • Don’t apply guard_tool and PreToolUse to the same authored tool.
  • Don’t mint a session ID. If the caller did not pass a UUID session_id, leave the call uncorrelated.
  • Don’t wrap these tools with @arcjet/guard/claude-agent-sdk/v0.

Key the bucket on a trusted identifier you own. Don’t key it on free-text user input.

lookup_limit = TokenBucket(
refill_rate=10,
interval_seconds=60,
max_tokens=10,
bucket="lookups",
)
guard_tool(
guard=arcjet,
tool=lookup_order,
action="order.looked-up",
rules=[lookup_limit(key="orders", requested=1)],
)

Scan a free-text note for sensitive information

Section titled “Scan a free-text note for sensitive information”

Scan a note, reason, or body from the parsed tool arguments. An opaque order_id is not a PII sample, so don’t pass it to LocalDetectSensitiveInfo.

from arcjet.guard import LocalDetectSensitiveInfo, TokenBucket
from arcjet.guard.claude_agent_sdk import guard_tool
detect_pii = LocalDetectSensitiveInfo()
guard_tool(
guard=arcjet,
tool=lookup_order,
action="order.looked-up",
rules=lambda arguments: [
lookup_limit(key="orders", requested=1),
detect_pii(arguments["note"]),
],
)

Pass the caller-owned UUID on ClaudeAgentOptions(session_id=...), guard_hooks, and guard_tool. Don’t mint an ID.