AI agent security

Cloudflare Think security guide

Screen Cloudflare Think text before the turn starts and deny tool calls on beforeToolCall. needsApproval is human-in-the-loop, not a policy. Fail closed substitutes, and the actor comes from the server.

12 min read
In short: Screen Cloudflare Think text before the turn starts and deny tool calls on beforeToolCall. needsApproval is human-in-the-loop, not a policy. Fail closed substitutes, and the actor comes from the server.

How do you secure Cloudflare Think?

To secure Cloudflare Think, screen user text before the turn starts and assign guardHooks().beforeToolCall on the Think subclass. The hook skips execute before the side effect. Take the actor from authenticated application state, and pass typed inputs when a remote policy needs them. needsApproval asks a person. It does not decide the call.

Use this guide on Cloudflare Think (@cloudflare/think >=0.3.0 <1).

Think agents call authored tools from the agent loop. You subclass Think, return tools from getTools(), and the model decides which execute handler to run. Prompt injection is when untrusted text – a pasted ticket, a retrieved document, a tool result from an earlier turn – steers that decision toward a side effect you never intended.

Arcjet Guard is a runtime policy layer for AI agents. You define rules – prompt-injection detectors, rate limits, PII checks, allowlists – and a published remote policy, and Guard allows or denies inbound text and tool calls before side effects run. The following examples use @arcjet/guard/cloudflare-think/v0.

Think uses the Vercel AI SDK under the hood. That shared runtime is not a reason to mix adapters. Don't wrap these tools with @arcjet/guard/vercel-ai/v7. HTTP protect() on a route never sees chat(), a messenger reply, or submitMessages(), so a WAF in front of the Worker doesn't cover the tool call.

Use this checklist to review your application:

  • Patch and lock dependencies. Import @arcjet/guard/cloudflare-think/v0. There is no unversioned alias.
  • Keep model keys off the client and out of chat state.
  • Screen user text with a direct guard() call before the Think turn.
  • Don't treat needsApproval as a policy gate.
  • Assign beforeToolCall from guardHooks. If you also implement the method, call the helper first.
  • Deny with substitute or block. Never throw. Never return void when Guard errors.
  • Pass actor and typed inputs from server state. Never from a model-produced argument.
  • Correlate on an ID you own. Never on toolCallId or a Durable Object id.
  • Catch issues in the editor and in CI before they ship.

For the Guard helpers used in the examples, see the Cloudflare Think agent guard.

How do Think's built-in controls compare with Arcjet Guard?

Think can pause for a person, and beforeToolCall can already return block or substitute. Those are not a remote policy with a trusted actor.

ControlWhat Think doesWhat Arcjet Guard adds
needsApprovalHuman-in-the-loop. The run pauses until a person confirms.

Not a Guard decision. There is no guardApproval. After a human yes, beforeToolCall still runs.

beforeToolCall

A block or substitute decision skips execute. Returning void runs the tool.

guardHooks returns that decision from SDK rules and a remote policy, before the handler runs. It does not throw.

Guard unavailable

A hook that returns void on error executes the tool.

Fail closed. The default returns a substitute envelope with reason ERROR. onDeny: "block" does not apply to unavailability.

Tool arguments

The model produced them, including any field that looks like a user id.

actor comes from authenticated application state. Typed inputs are what a remote policy reads.

Client tools

No server execute.

Not a deny point. Gate the server tools you host.

How do you keep Cloudflare Think dependencies safe?

Apply patches on a schedule you actually keep, and out of band for a critical advisory. Commit a lockfile so every environment installs the same graph, and turn on Dependabot, Renovate, or an equivalent bot for npm. Run npm audit in CI and fail on known high-severity issues you haven't waived.

@cloudflare/think is an optional peer of @arcjet/guard (>=0.3.0 <1), not a dependency of it. If Think is already in your project at that range, install @arcjet/guard on its own so your existing pins don't move. The integration requires Node.js 22.21 or later, or Node.js 24.5 or later. It does not support the 23.x line.

Terminal window
npm install @arcjet/guard @cloudflare/think

Import from the versioned path. The version segment is v0 because Think is pre-1.0, and there is no unversioned alias – @arcjet/guard/cloudflare-think does not resolve.

import {
cloudflareThinkContext,
guardHooks,
} from "@arcjet/guard/cloudflare-think/v0";

Think is pre-1.0, so pin the minor and treat an upgrade as a review event that re-checks the gate still fires. See trivial packages and dependency confusion.

How do you manage Cloudflare Think secrets?

Model credentials and ARCJET_KEY belong on the server, loaded at process start. Launch one Arcjet client at module scope and reuse it.

import { launchArcjet } from "@arcjet/guard";
export const arcjet = launchArcjet({ key: process.env.ARCJET_KEY! });

Don't put credentials in chat state or in a Durable Object field the model can read back. That storage is for caller-owned identifiers – a conversation ID, a tenant ID – not secrets.

Don't log raw tool arguments or the raw prompt. Tool arguments are model-generated from user text. See storing secrets in environment variables and redacting sensitive data from logs.

How do you screen inbound Cloudflare Think text?

There is no inbound hook, so there is no guardInbound. Screen in application code before the Think turn starts – chat(), a messenger reply, or submitMessages() – while the user's text is still a string you control.

Direct guard() fails open: if Guard can't be evaluated, the call returns ALLOW. An ALLOW on its own is not proof the rules ran. Gate on decision.hasFailedOpen() when this call site must fail closed. guardHooks already defaults to fail closed. On a deny, don't start the turn.

import { detectPromptInjection } from "@arcjet/guard";
import { cloudflareThinkContext } from "@arcjet/guard/cloudflare-think/v0";
import { arcjet } from "./arcjet.js";
const inbound = detectPromptInjection();
const appContext = { sessionId: conversationId };
const decision = await arcjet.guard({
label: "message.received",
rules: [inbound(userText)],
...cloudflareThinkContext({ context: appContext }),
});
if (decision.conclusion === "DENY" || decision.hasFailedOpen()) {
throw new Error("message blocked");
}

A direct guard() call takes label. guardHooks takes action for the same slug. Passing label to the wrapper is a type error.

A clean inbound score does not authorize a tool call. Inbound screening decides whether the model sees the prompt. It says nothing about whether the refund tool may run. See prompt injection detection.

Why isn't needsApproval a Cloudflare Think security policy?

needsApproval is human-in-the-loop confirmation. It asks a person to continue. It does not evaluate a rule, and it does not record an allow or deny against an actor and a set of inputs.

This is the same trap under different names across frameworks – LangChain humanInTheLoopMiddleware, Strands event.interrupt(), Genkit interrupt(), OpenAI needsApproval, Mastra requireApproval, TanStack needsApproval, Google ADK requireConfirmation, Claude canUseTool. There is no guardApproval on this adapter.

After a human yes, Guard still runs on the tool call. Don't wrap HITL as Guard, and don't deny by pausing for a person. Policy sits on beforeToolCall only. See human approval is not a security policy.

How do you gate Cloudflare Think tool calls?

There is no guardTool. Wrapping tool({ execute }) is the wrong gate, because Think's skip point is the hook. Think wraps each server-side execute so beforeToolCall can skip it. A Vercel AI SDK wrapper misses that path or double-wraps it.

guardHooks is the Think-wide gate. Assign its beforeToolCall on the subclass. The hook denies by returning { action: "substitute", output } by default, where output is an ArcjetDenialResult. The original execute never runs. Return void to execute. The hook doesn't throw.

Optional onDeny: "block" returns { action: "block", reason } on a real DENY only. Fail-closed unavailability always substitutes.

If the subclass also implements beforeToolCall, call the helper first and return its decision when it is a block or substitute. Don't run execute after a deny.

import { localDetectSensitiveInfo, tokenBucket } from "@arcjet/guard";
import { guardHooks } from "@arcjet/guard/cloudflare-think/v0";
import { Think } from "@cloudflare/think";
import { tool } from "ai";
import { z } from "zod";
import { arcjet } from "./arcjet.js";
// Resolve authenticatedUser from the server session before the turn.
const lookupLimit = tokenBucket({
bucket: "lookups",
refillRate: 10,
intervalSeconds: 60,
maxTokens: 10,
});
const detectPii = localDetectSensitiveInfo({
deny: ["EMAIL", "PHONE_NUMBER", "IP_ADDRESS", "CREDIT_CARD_NUMBER"],
});
const lookupOrderInput = z.object({
orderNumber: z.string(),
note: z.string(),
});
const hooks = guardHooks(arcjet, {
action: "order.looked-up",
sessionId: conversationId,
onGuardError: "deny",
actor: authenticatedUser.id,
rules: ({ toolName, input }) => {
if (toolName !== "lookup_order") {
return [];
}
const { note } = lookupOrderInput.parse(input);
return [
lookupLimit({ key: authenticatedUser.id, requested: 1 }),
detectPii(note),
];
},
});
export class OrderAgent extends Think<Env> {
getModel() {
return "@cf/moonshotai/kimi-k2.7-code";
}
getSystemPrompt() {
return "Look up orders with lookup_order.";
}
getTools() {
return {
lookup_order: tool({
description: "Look up an order by number",
inputSchema: lookupOrderInput,
execute: ({ orderNumber, note }) => ({
orderNumber,
note,
status: "shipped",
}),
}),
};
}
beforeToolCall = hooks.beforeToolCall;
}

guardHooks sees every tool the agent calls, including runtime-discovered tools, so rules receives { toolName, input }. Branch on toolName and return [] for the tools you aren't scoring. The guard call still happens, which is what puts the decision in the Console.

Re-parse input with the tool's own schema. The model produced those arguments. Key a per-user rate limit on authenticatedUser.id. The model supplies orderNumber, so it is a resource selector, not a trusted identity. Changing it must not reset the caller's budget. Authorize access to the selected order in the tool service.

Scan the free-text fields – a note, a reason, a body. An opaque orderNumber or tool-call ID won't trip email, phone, card, or IP detection, so passing it to localDetectSensitiveInfo 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.

Client tools have no server execute. They are not a deny point.

What does a Cloudflare Think denial look like?

On DENY the original tool never runs. The payload is one ArcjetDenialResult. The envelope is a Think ToolCallDecision, not a thrown error. A throw is the wrong envelope, and void on error executes the tool.

import type { ArcjetDenialResult } from "@arcjet/guard/cloudflare-think/v0";
const denial: ArcjetDenialResult = {
arcjetDenied: true,
reason: "RATE_LIMIT", // or PROMPT_INJECTION, SENSITIVE_INFO, ERROR
message:
"Arcjet denied this call (RATE_LIMIT). It may be retried after 30 seconds.",
retryable: true,
retryAfterSeconds: 30,
};
// Default envelope. The model reads `output` as the tool result.
const substitute = { action: "substitute" as const, output: denial };
// Optional. Real DENY only. The model reads `reason` as the tool result.
const block = { action: "block" as const, reason: denial.message };

Rate-limit policy denials can set retryable: true with retryAfterSeconds. Prompt-injection and sensitive-information denials must not be retried.

guardHooks defaults to onGuardError: "deny". When Guard can't be evaluated, beforeToolCall returns { action: "substitute", output } with reason: "ERROR", retryable: true, and retryAfterSeconds: 5. Fail closed always substitutes, even when onDeny is "block". It never throws, and it never returns void on that path.

guardHooks(arcjet, {
sessionId: conversationId,
onDeny: "block",
});

Set onGuardError: "allow" only where executing without a complete security decision is genuinely acceptable, such as a lookup whose data is safe to disclose even without a Guard decision. Direct guard() still fails open, which is why the inbound example gates on hasFailedOpen().

How do remote policies read the actor and inputs?

guardHooks takes actor and inputs, each as a value or a function resolved per call. A resolver receives { toolName, input }, then the Think ToolCallContext. Omit them and a remote rule that declares those names never fires. A resolver that throws is a guard error and follows onGuardError.

Derive actor from authenticated application state, never from a model-produced tool argument. A policy can be conditioned on the actor, so a model that controls it can leave its own policy scope.

Because the helper gates tools it did not wrap, input is unknown at the type level. Narrow it before you read a field, and return nothing for a tool the policy does not cover. Build every input with policyInput.server.* or policyInput.local.*. Plain values are rejected, and the adapter never discovers arguments for you.

import { policyInput } from "@arcjet/guard";
import { guardHooks } from "@arcjet/guard/cloudflare-think/v0";
import { arcjet } from "./arcjet.js";
const hooks = guardHooks(arcjet, {
action: ({ toolName }) => `${toolName}.invoked`,
actor: authenticatedUser.id,
inputs: ({ toolName, input }) => {
if (toolName !== "sendEmail") return {};
const { recipient, body } = input as { recipient: string; body: string };
return {
recipient: policyInput.server.string(recipient),
allowed_recipients: policyInput.server.stringList(
authenticatedUser.allowedRecipients,
),
body: policyInput.local.string(body),
};
},
});

The policy these calls feed declares recipient as a SERVER string, allowed_recipients as a SERVER string list, and body as a LOCAL string, then denies a recipient that isn't on the list. allowed_recipients is application state, not a field the model supplied.

package arcjet.guard
import rego.v1
deny contains "external-recipient" if {
not input.values.recipient in input.values.allowed_recipients
}

See remote policies for the contract.

How do you correlate a Cloudflare Think run?

cloudflareThinkContext reads a caller-owned ID from the object you pass: correlationId first, then sessionId, then conversationId. Prefer cloudflareThinkContext({ context: appContext }). It never mints an ID.

It never reads toolCallId. Think always generates that value. It never reads requestId, traceId, a Durable Object name or id, or a Think-generated session id. Think persists chat in Durable Object SQLite. That storage id isn't a correlation source. Don't derive an ID from it. If you didn't pass an ID, the call is uncorrelated, which is the honest outcome.

A beforeToolCall context that has toolName and toolCallId is treated as a Think envelope, so a top-level sessionId on that object is ignored. Put the same caller-owned ID on inbound guard() and as sessionId on guardHooks.

const appContext = { sessionId: conversationId };
await arcjet.guard({
label: "message.received",
...cloudflareThinkContext({ context: appContext }),
});
const hooks = guardHooks(arcjet, { sessionId: conversationId });

Don't call createAgentContext inside a Think hook.

How do the editor and CI catch Cloudflare Think security bugs?

Turn on TypeScript strict, ESLint, and secret scanning in the editor. Trunk, Semgrep, TruffleHog, and Gitleaks all have editor plugins and CI jobs. Run the same scanners in CI that you run locally – an editor warning that isn't a CI failure gets ignored.

Fail the build on secrets in the diff and on known vulnerable dependencies. TypeScript catches the adapter mistakes that matter most here: label passed to guardHooks, an unversioned @arcjet/guard/cloudflare-think import, a guardTool import from @arcjet/guard/vercel-ai/v7, or a rules callback that destructures input without parsing it.

What automation won't catch is a subclass beforeToolCall that forgets to return the helper's decision, and an actor read off input. A Guard that never runs looks exactly like a Guard that always allows. Make both a review item, and verify them against a real denial.

How do you verify the gate actually fires?

A missing decision is not a denial. If the model asks a clarifying question instead of calling the guarded tool, nothing is sent, no guard call happens, and no decision is returned. From the outside that is indistinguishable from 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 a side effect. Two things make a test agent cooperative: a system prompt telling it to complete the request without follow-up questions, and one telling it to quote retrieved values verbatim. A model that helpfully masks a card number itself leaves the rule nothing to detect, and Guard then correctly allows – which reads as a broken rule when it isn't.

Check the three failure modes explicitly. Confirm a denial when a rate limit is exhausted, confirm a denial when a free-text argument carries PII, and confirm the tool doesn't run when Guard is unreachable and onGuardError is "deny". On that last path the envelope is substitute even if onDeny is "block". See functional testing of security rules.

Use deterministic Guard responses and a handler call counter. A DENY or unavailable evaluation must leave the counter at zero; an authorized ALLOW must call the handler once. Also test another tenant's resource identifier: application authorization must still refuse access even when Guard allows.

What do you do after this guide?

Confirm each item against one sensitive run: a prompt that asks for a refund, an authored lookup that includes a free-text note, and a subclass beforeToolCall that returns the helper's decision before any other work.

Then apply AI agent runtime security for the sequence, budget, and identity controls that aren't Think-specific, and the Google ADK security guide if the agent runs on ADK rather than Think.

Frequently asked questions

What is the Cloudflare Think security guide?

To secure a Cloudflare Think agent, screen user text with a direct guard() call before the turn starts, and assign guardHooks().beforeToolCall on the Think subclass. The hook skips execute by returning a substitute or block decision. Authorize resources in the tool, and take the actor from authenticated application state.

Is needsApproval a Cloudflare Think policy gate?

No. needsApproval is human-in-the-loop confirmation. It asks a person and does not evaluate a policy. After a human yes, Guard still runs on beforeToolCall. There is no guardApproval.

Does Cloudflare Think have a guardTool?

No. Think's skip point is beforeToolCall. Wrapping tool({ execute }) with the Vercel AI SDK guardTool misses that path or double-wraps it. Import @arcjet/guard/cloudflare-think/v0, not @arcjet/guard/vercel-ai/v7.

What happens when Guard cannot be evaluated?

guardHooks defaults to onGuardError deny. beforeToolCall returns a substitute envelope with reason ERROR, retryable true, and retryAfterSeconds 5. Fail closed always substitutes, even when onDeny is block. It never throws and never returns void on that path. Direct guard() still fails open, so inbound screening also checks hasFailedOpen().

Can the model set the actor?

No. Derive actor from authenticated application state, never from a model-produced tool argument. A policy can be conditioned on the actor, so a model that controls it can leave its own policy scope. Build inputs with policyInput.server or policyInput.local. Plain values are rejected.

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.