Skip to content

Strands Agents Python agent guard

Strands Agents Python Agent workflows call authored @tool handlers from the agent loop. 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 arcjet[strands-agents] extra plus guard_tool, guard_hooks, and strands_agent_context. It is not the JavaScript @arcjet/guard/strands-agents/v1 adapter. Don’t wrap these tools with @arcjet/guard/strands-agents/v1.

Until arcjet.guard.strands_agents is published, pin arcjet to a6308061. Published arcjet 1.0.0 on PyPI does not include this extra.

Terminal window
pip install "arcjet[strands-agents] @ git+https://github.com/arcjet/arcjet-py@a630806169b92757192f3f5cce2e305827b26567"

arcjet[strands-agents] depends on strands-agents>=1.11.0,<2. CPython 3.10 or later.

Import helpers from arcjet.guard.strands_agents:

from arcjet.guard.strands_agents import (
guard_hooks,
guard_tool,
strands_agent_context,
)

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[strands-agents]Yes
An unwrapped or MCP toolguard_hooks (BeforeToolCallEvent)arcjet[strands-agents]Yes
A session or conversation ID you already havestrands_agent_contextarcjet[strands-agents]No

guard_tool wraps an authored @tool / DecoratedFunctionTool. On DENY, or when Guard cannot be evaluated and on_guard_error is "deny", the helper returns a plain ArcjetDenialResult dict. The tool handler does not run. Don’t raise. A raise is swallowed into Error: {Type} - {message}.

guard_hooks is a hook provider. Pass it on Agent(hooks=[...]). On DENY it sets event.cancel_tool to a JSON string of ArcjetDenialResult. The SDK also accepts True for the default cancel text. That cancels the tool call. Policy sits on per-tool cancel_tool only. Don’t set BeforeToolsEvent.cancel. That skips per-tool hooks. It does not call event.interrupt(). Fail closed always sets cancel_tool on error.

strands_agent_context reads a caller-owned correlationId, then sessionId, then requestId from invocation_state or a bare mapping. Snake-case aliases correlation_id, session_id, and request_id are also read. Keyword fallbacks are correlation_id=, session_id=, and request_id=. It never mints an ID. It never reads trace_id or traceId. It never reads agent.id. If the caller did not pass one of those keys, the call is uncorrelated rather than joined to a generated ID.

There is no inbound helper. Screen user text with a direct guard() call before the agent runs. There is no approval helper. event.interrupt() is human-in-the-loop (HITL) confirmation, not policy.

Don’t also wrap these tools with @arcjet/guard/strands-agents/v1.

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.
correlation_idNoCaller-owned fallback when the object you pass to strands_agent_context has no ID. Never minted.
session_idNoSame, when the application calls the ID a session. Ignored when correlation_id is set.
request_idNoSame, when the application calls the ID a request. Ignored when correlation_id or session_id is set.
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 tool-call envelope. Defaults to {tool_name}.invoked.
rulesNoBound SDK rule inputs, or a function of tool_use.input plus tool_name. Omit to submit none.
correlation_idNoCaller-owned fallback when invocation_state has no ID. Never minted.
session_idNoSame, when the application calls the ID a session. Ignored when correlation_id is set.
request_idNoSame, when the application calls the ID a request. Ignored when correlation_id or session_id is set.
on_guard_errorNo"deny" (default) or "allow". A real DENY always blocks.

Inbound screening uses direct guard(), which takes label (not action) and fails open. See the following section.

strands_agent_context reads a caller-owned correlationId, then sessionId, then requestId, plus the snake-case aliases. It never mints an ID. It never reads trace_id or traceId. It never reads agent.id.

There is no inbound hook, so there is no inbound helper. Put prompt-injection and other inbound rules in the application before you call the agent.

Direct guard() fails open, so an ALLOW is not proof the rules ran. Gate inbound on decision.has_failed_open() if this call site must fail closed. guard_tool and guard_hooks already default to that.

On DENY, don’t call the agent.

from strands import Agent
from arcjet.guard import DetectPromptInjection, launch_arcjet
from arcjet.guard.strands_agents import (
guard_hooks,
strands_agent_context,
)
arcjet = launch_arcjet(key=ARCJET_KEY)
inbound = DetectPromptInjection()
async def run_agent(conversation_id: str, user_text: str):
app_context = {"session_id": conversation_id}
derived = strands_agent_context(app_context)
decision = await arcjet.guard(
label="message.received",
rules=[inbound(user_text)],
correlation_id=derived.correlation_id,
)
if decision.conclusion == "DENY" or decision.has_failed_open():
raise RuntimeError("message blocked")
agent = Agent(
tools=[lookup_order],
hooks=[guard_hooks(guard=arcjet, session_id=conversation_id)],
)
return agent(user_text)

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

On DENY the handler does not run. The helper returns a plain ArcjetDenialResult dict:

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

Don’t raise from the wrapper. A raise is swallowed into Error: {Type} - {message}.

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

from strands import tool
from arcjet.guard import (
LocalDetectSensitiveInfo,
TokenBucket,
launch_arcjet,
)
from arcjet.guard.strands_agents 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
def lookup_order(order_id: str, note: str) -> dict:
"""Look up an order by ID."""
return {"order_id": order_id, "note": note, "status": "shipped"}
guarded_lookup = 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 wrapped tools on the Agent tools list.

guard_hooks is a hook provider. Pass it on Agent(hooks=[...]). BeforeToolCallEvent is the per-tool gate. The callback denies by setting event.cancel_tool to a JSON string of ArcjetDenialResult. The SDK also accepts True for the default cancel text. The tool does not run. Policy sits on per-tool cancel_tool only. Don’t set BeforeToolsEvent.cancel. That skips per-tool hooks. It skips branded (guard_tool) tools. Tools that are not branded are still gated. AfterToolCallEvent is capture only. Don’t call event.interrupt().

MCP and vended tools skip an unwrapped handler. Those tools are not a @tool deny. guard_hooks still gates a tool call that Strands executes through BeforeToolCallEvent. Branded guard_tool wrappers are skipped on the before path.

This is not the JavaScript @arcjet/guard/strands-agents/v1 adapter. Don’t pass guardHooks from that package to a Python Agent.

from strands import Agent
from arcjet.guard.strands_agents import guard_hooks
from arcjet.guard import launch_arcjet
arcjet = launch_arcjet(key=ARCJET_KEY)
agent = Agent(
tools=[guarded_lookup],
hooks=[guard_hooks(guard=arcjet, session_id=conversation_id)],
)

event.interrupt() pauses the run for human-in-the-loop (HITL) confirmation. It is not a policy gate. Same trap as CrewAI human_input, LangChain JS humanInTheLoopMiddleware, Mastra requireApproval, Claude can_use_tool, LangGraph interrupt(), Genkit interrupt(), and OpenAI needs_approval.

There is no approval helper. Don’t wrap event.interrupt() as Guard. Use guard_tool on authored @tool handlers you own, or cancel_tool 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 a plain ArcjetDenialResult dict and the tool handler does not run. guard_hooks sets event.cancel_tool instead of calling the tool.

Set on_guard_error="allow" only when you can accept running the action without a complete security decision. 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.

The agent has no conversation or session ID unless you put one on the object you already have. Pass the ID that you already have. Don’t mint an ID.

strands_agent_context reads a caller-owned correlationId first, then sessionId, then requestId from invocation_state or a bare mapping. Snake-case aliases correlation_id, session_id, and request_id are also read. It never mints an ID. It never reads trace_id or traceId. It never reads agent.id. If nothing is a valid ID, the call is uncorrelated rather than joined to a generated ID.

app_context = {"session_id": conversation_id}
agent(user_text)

Derive the ID from a session the caller already has. A generated ID still joins this run’s events, but it builds a Sequence that nobody searches for.

  • There is no inbound helper. Screen prompt injection before you call the agent.
  • There is no approval helper. event.interrupt() is human HITL confirmation, not policy.
  • Don’t raise from guard_tool or the BeforeToolCallEvent callback to signal a denial. Denial is cancel_tool (True or a string) on the hooks, or the ArcjetDenialResult dict from guard_tool. A raise is swallowed into Error: {Type} - {message}.
  • Don’t treat event.interrupt() as Guard. Policy sits on BeforeToolCallEvent.cancel_tool only.
  • Don’t set BeforeToolsEvent.cancel. That skips per-tool hooks.
  • Don’t mint a session or conversation ID. Don’t read trace_id or traceId. Don’t read agent.id. If the caller did not pass a correlationId, sessionId, or requestId (or the snake-case aliases), leave the call uncorrelated.
  • Don’t wrap these tools with @arcjet/guard/strands-agents/v1.
  • Don’t use this page for the JavaScript Strands Agents adapter. For that adapter, see Strands Agents agent guard.

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.strands_agents 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"]),
],
)

Put {"session_id": conversation_id} on the object you pass to strands_agent_context. Don’t mint an ID.