Agent guards for CrewAI
This example gives an AI agent two tools. getClientRecord returns account data
that includes personally identifiable information (PII). Arcjet guards
sendEmail, so the model can read the record but cannot send its sensitive
fields outside the application. Arcjet checks the model-selected recipient and
generated message body before the email provider runs.
What is Arcjet?
Arcjet is the AI agent runtime security platform. Discover the agents running in your organization, enforce policy across every action, prompt, and tool call, and keep the evidence to prove what happened. Detect prompt injection, authorize agent tool calls, redact PII, and block bots and abuse.Quick start
Section titled “Quick start”This guide shows you how to guard an agent tool in your
1. Configure the policy
Section titled “1. Configure the policy”The policy decides what the guarded tool is allowed to do. Create it once, in the Arcjet Console, before you run the example.
You don’t have to write the conditions. Describe what the action refuses and
Arcjet writes them for you. Go to Policies, choose to guard an action in
your own application rather than a coding agent, enter email.sent as the
Guard label, and paste this description:
Deny when recipient is not in the allowed_recipients list.Deny when body contains a bank account number or a routing number.Arcjet opens a draft in the visual builder with three declared inputs, a
sensitive information detector over body, and two rules in dry run. Review
it, set both rules live, add a test asserting that an address on the allow list
fires no rule and one asserting that an address outside it is denied, then
publish. A live rule can’t publish without at least one stored test.
Have your coding agent configure it
Connect the Arcjet MCP server and give your agent this prompt. It reads and writes the same policies the Console does, so nothing about the result differs.
Create an Arcjet guard policy with the label email.sent.
Declare recipient as a SERVER string, allowed_recipients as a SERVER stringlist, and body as a LOCAL string. Deny when recipient is not a member ofallowed_recipients. Deny when body contains a BANK_ACCOUNT or ROUTING_NUMBERentity.
Add one test for a recipient on the allow list and one for an address outsideit, then publish. Show me the guard() call that sends these inputs.Ask it for the guard call as well as the policy. A policy does nothing until the application sends values under exactly the names it declares, and a mistyped name produces a policy that silently never fires.
Configure it by hand
Declare these inputs:
| Input | Type | Exposure | Required |
|---|---|---|---|
recipient | String | SERVER | Yes |
allowed_recipients | String list | SERVER | Yes |
body | String | LOCAL | Yes |
Declare one detector, body_scan, for sensitive information over body,
denying the BANK_ACCOUNT and ROUTING_NUMBER entity types.
Then declare two live rules:
| Rule | Kind | Decided by |
|---|---|---|
external-recipient | EXPRESSION | The policy’s Rego |
sensitive-body | DETECTOR | body_scan, directly |
external-recipient needs one condition. In the visual builder, add a
condition requiring recipient to be a member of allowed_recipients. The
builder generates this Rego:
package arcjet.guard
import rego.v1
deny contains "external-recipient" if { not input.values.recipient in input.values.allowed_recipients}sensitive-body needs no expression. A detector rule is decided by its
detector, which is what keeps the email body private: the SDK runs the
detection and Arcjet receives the verdict, never the text.
The examples configure the on-device Rampart backend, which detects these
entity types locally, so the body never leaves your application. The first
guarded call loads the Rampart model, so it takes noticeably longer than the
ones after it.
Publish the policy before you run the example. Until you do, the label matches
nothing and every guard call comes back NOT_CONFIGURED, which falls through
to the SDK rule decision rather than denying. For the other statuses, see
Testing and reference.
Framework wrappers take action for the same slug you configured as the policy
label. Direct guard() calls use the field name label.
2. Install Arcjet
Section titled “2. Install Arcjet”In your project root, install the SDK:
The CrewAI integration requires arcjet 1.0.0 or later. There is no
arcjet[crewai] extra. Install official CrewAI yourself.
pip install "arcjet[sensitive-info-rampart]>=1.0.0"pip install "crewai>=1.15.3,<2"Or with uv:
uv add "arcjet[sensitive-info-rampart]>=1.0.0"uv add "crewai>=1.15.3,<2"3. Set your key
Section titled “3. Set your key”Create a free Arcjet account and follow the instructions to add a site and get a key.
Set your environment variables:
# Export your Arcjet API key from https://console.arcjet.comARCJET_KEY=ajkey_yourkeyARCJET_ENV=developmentOPENAI_API_KEY=sk-yourkey4. Wrap the tool
Section titled “4. Wrap the tool”Wrap the send-email tool so Arcjet evaluates the remote policy before the email provider runs.
Guarding one tool only helps if it is the only way to reach the capability. If the same agent session also exposes an unguarded path to sending mail – an MCP server it inherited, a built-in tool, a second tool you didn’t wrap – the model can take that path instead. Keep the session’s tool surface to what you wrapped.
This adapter accepts inputs and actor. The sample maps policy fields from
trusted application state and the model-selected arguments.
Create one client and register PRE_TOOL_CALL with register_arcjet_hooks:
import os
from arcjet.guard import launch_arcjet_sync, local_input, server_inputfrom arcjet.guard.crewai import register_arcjet_hooksfrom arcjet_sensitive_info_rampart import rampartfrom crewai import Agent, Crew, Taskfrom crewai.tools import tool
# CrewAI hooks are synchronous, so use launch_arcjet_sync.arcjet = launch_arcjet_sync( key=os.environ["ARCJET_KEY"], sensitive_info_backend=rampart(),)
class EmailProvider: def send(self, *, to: str, body: str) -> None: return None
# Placeholder for your mail transport.email_provider = EmailProvider()
@tool("get_client_record")def get_client_record() -> dict: """Get the account details on file for the current customer.""" return current_user.record
@tool("send_email")def send_email(recipient: str, body: str) -> str: """Send an email.""" email_provider.send(to=recipient, body=body) return "sent"
# register_arcjet_hooks gates every crew tool call on PRE_TOOL_CALL.register_arcjet_hooks( guard=arcjet, tools=["send_email"], action="email.sent", actor=lambda _arguments, _ctx: current_user.id, inputs=lambda arguments, _ctx: { "recipient": server_input.string(arguments["recipient"]), "allowed_recipients": server_input.string_list( current_user.allowed_recipients ), "body": local_input.string(arguments["body"]), },)
agent = Agent( role="Support clerk", goal="Look up the customer record and send approved email", backstory=( "You send email only to approved recipients. You never ask a " "follow-up question, and you quote any account details you " "retrieve exactly as returned, without masking them." ), tools=[get_client_record, send_email],)task = Task( description="{prompt}", expected_output="The result of the email request", agent=agent,)crew = Crew(agents=[agent], tasks=[task])
def run_email_agent(user, prompt: str) -> str: global current_user current_user = user result = crew.kickoff(inputs={"prompt": prompt}) return str(result)5. Try the policy
Section titled “5. Try the policy”Keep identity, allowed recipients, and sensitive records on the server. The browser sends only the scenario name.
Expose a small server endpoint. The browser sends only the scenario name:
from typing import Literal
from fastapi import FastAPIfrom fastapi.staticfiles import StaticFilesfrom pydantic import BaseModel
from agent import run_email_agent
app = FastAPI()
class User: def __init__(self): self.id = "customer-123" self.allowed_recipients = ["approved@example.com"] self.record = { "name": "Alex Morgan", "bank_account": "0123456789", "routing_number": "022000020", }
user = User()scenarios = { "allowed": ( "Send the message 'Your report is ready' to " "approved@example.com." ), "blocked": ( "Send the message 'Your report is ready' to " "outside@example.net." ), "pii": ( "Email the account details you have on file to " "approved@example.com." ),}
class AgentRequest(BaseModel): scenario: Literal["allowed", "blocked", "pii"]
@app.post("/api/agent")def run_agent(request: AgentRequest): output = run_email_agent(user, scenarios[request.scenario]) return {"output": output}
# Serve the demo page in the next step from public/ at the site root. Mount# it after the route, so /api/agent still resolves.app.mount("/", StaticFiles(directory="public", html=True), name="public")Add a minimal page that calls the endpoint and displays the agent’s response. Serve it however your framework serves static files:
<h1>Agent Guard policy demo</h1><p>Test recipient and sensitive-information policies on the same email tool.</p>
<button data-scenario="allowed">Allowed recipient</button><button data-scenario="blocked">Blocked recipient</button><button data-scenario="pii">Sensitive information</button><pre id="output">Choose a scenario.</pre>
<script> // Send only the selected scenario name to the server. const output = document.querySelector("#output");
for (const button of document.querySelectorAll("button")) { button.addEventListener("click", async () => { output.textContent = "Running agent…"; const response = await fetch("/api/agent", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ scenario: button.dataset.scenario }), }); const result = await response.json(); output.textContent = result.output ?? result.error; }); }</script>Each scenario demonstrates a different result from the same guarded tool:
- Allowed recipient: The recipient is on the allow list and the body has no
sensitive data, so
sendEmailreaches the email provider. - Blocked recipient: The model calls the same tool with an external
address, and the policy’s
external-recipientrule denies the call before the provider runs. The model receives the denial result and explains it. - Sensitive information: The recipient is allowed, but the agent first
calls
getClientRecordand receives test bank account and routing numbers. When the model puts that tool result in the email body, the policy’ssensitive-bodyrule deniessendEmailbefore the data leaves the application. The detector runs in the SDK, so the body is never sent to Arcjet.
Read the decision, not the absence of an email
Section titled “Read the decision, not the absence of an email”The samples give the agent a system prompt for a reason. Without one the
model asks a clarifying question, or masks the account numbers itself,
instead of calling sendEmail with them. Nothing is sent, no guard call is
made, and no decision is returned.
That is the outcome to watch for, because it looks like the guard worked. A reader with a broken policy, a wrong label, or a missing key sees “nothing was sent” and concludes the guard is enforcing. Check the decision in the Arcjet dashboard or in your logs. No decision means the model declined, not that the guard denied.
The same distinction is the reason to guard the tool at all. An unguarded agent often refuses the sensitive-information scenario on its own, because sending bank details over email looks wrong to the model. That refusal is judgement: it is non-deterministic, and a different prompt can talk the model out of it. The blocked-recipient scenario is the deterministic contrast, since nothing about an external address looks unsafe to the model.
What next?
Section titled “What next?”Get help
Section titled “Get help”Need help with anything? Email support@arcjet.com to get support from our engineering team.