Policy contract
An agent guard policy is the remotely managed half of an enforcement point. Developers own the guard call and map trusted application values into it. Authorized site members, such as a security team, own the policy that decides on those values. Neither side needs the other to deploy.
This page describes what a policy declares. For the language, see Write policies in Rego. For building, testing, and publishing one, see Author and publish policies.
What a policy declares
Section titled “What a policy declares”For each guarded action, the security and development teams agree on five things:
- A stable
label, such asemail.sent, that selects the policy. - Whether the policy requires an
actor. - Named inputs, each with a type and
SERVERorLOCALexposure. - Detectors to run, and which input each one reads.
- Stable rule identities, each
LIVEorDRY_RUN, decided either by a detector or by the policy’s expression.
The application sends values under exactly the names the policy declares. A mistyped name produces a policy that silently never fires, so generate the guard call from the policy rather than transcribing it. For more information, see Generate the SDK call.
label identifies the action boundary and selects its policy. Use a hardcoded,
past-tense action name such as email.sent or refund.issued. Labels use
lowercase letters, digits, dashes, underscores, and dots, must start and end
with a lowercase letter or digit, and are limited to 256 bytes. One policy owns
a label.
A label the service will not match reads as ALLOW with hasFailedOpen() /
has_failed_open() / HasFailedOpen() false, so the guard does not run.
Adapter factories throw or raise ArcjetInvalidLabelError
(@arcjet/guard >= 1.13.0, arcjet >= 1.2.0), or return ErrInvalidLabel.
capture() warns AJ1023 and still sends. Check a label you build yourself
with validateGuardLabel / validate_guard_label / ValidateGuardLabel.
The Python adapters derive a default action from a snake_case tool name, so
send_email.invoked is a label you can publish a policy for.
Framework wrappers take the same slug as action. Direct guard() calls take
it as label.
A policy that guards a coding agent isn’t selected by a label. It declares what it executes on, and Arcjet runs every such policy at that moment. For more information, see Coding agent policies.
actor is an opaque string asserted by trusted application code. It can
represent the authenticated user, service, or tenant responsible for the
action.
Derive it from authenticated server-side state, never from a request, a model,
or a tool argument. The SDK doesn’t derive it for you, and Arcjet doesn’t
authenticate the value on your behalf. A policy can require an actor, and a
call that doesn’t carry one then reports AJP1002.
Typed inputs
Section titled “Typed inputs”Every policy input is named and explicitly constructed. Plain values are rejected; the SDK doesn’t inspect the tool schema or discover arguments for you.
| Exposure | Type | JavaScript / TypeScript | Python | Example value |
|---|---|---|---|---|
SERVER | String | policyInput.server.string(value) | server_input.string(value) | "customer@example.com" |
SERVER | Boolean | policyInput.server.boolean(value) | server_input.boolean(value) | true |
SERVER | Integer | policyInput.server.integer(value) | server_input.integer(value) | 3 |
SERVER | Number | policyInput.server.number(value) | server_input.number(value) | 49.95 |
SERVER | String list | policyInput.server.stringList(value) | server_input.string_list(value) | ["a@example.com", "b@example.com"] |
LOCAL | String | policyInput.local.string(value) | local_input.string(value) | "Email body to inspect" |
An email policy can receive a selected recipient, the trusted allow list to compare it with, and a message body that stays local:
inputs: { recipient: policyInput.server.string(recipient), allowed_recipients: policyInput.server.stringList(allowedRecipients), body: policyInput.local.string(body),}inputs={ "recipient": server_input.string(recipient), "allowed_recipients": server_input.string_list(allowed_recipients), "body": local_input.string(body),}A call is bounded at 64 inputs, 128 KiB per string, and 256 items per list.
Mark an input required when a rule depends on it. An expression over an input the application didn’t send is undefined, and a rule with an undefined statement doesn’t fire.
A missing required input, or a declared input with the wrong kind, exposure,
or size, reports AJP1003 naming the input, and a live rule fails closed. An
input the policy doesn’t declare reaches no rule, so Arcjet drops it, evaluates
the policy, and adds an AJ1060 warning naming it to the decision. Renaming an
input in the policy therefore can’t fail every deployed SDK closed, but it can
leave a rule reading an input nobody sends, so check the warnings after a
rename.
Where inputs are evaluated
Section titled “Where inputs are evaluated”SERVER and LOCAL describe where a policy evaluates an input and whether its
raw value is sent to Arcjet. The exposure an input needs follows from what
reads it, so it isn’t a preference.
SERVER inputs
Section titled “SERVER inputs”The typed value is sent to Arcjet for server-side evaluation. The policy
expression reads server inputs under input.values, and server-side detectors
read them directly. Use a server input only for a value Arcjet is allowed to
receive.
LOCAL inputs
Section titled “LOCAL inputs”A LOCAL value stays in SDK memory. The SDK evaluates the downloaded policy
projection locally and sends a domain-separated SHA-256 digest plus a rule
attestation naming the entity types that matched and where, not the matched
values. The expression can’t read a LOCAL value, because Arcjet never
receives one.
Detectors
Section titled “Detectors”Detectors are native Arcjet operations. A policy can’t make a network call or a model call; it reads a bounded result Arcjet produced. Declare a detector, give it an ID, and name the input it reads:
| Detector | Reads | Runs on | Options |
|---|---|---|---|
| Prompt injection | A SERVER string | The server | – |
| Sensitive information (SDK-local) | A LOCAL string | The SDK | Allowed or denied entity types |
| Sensitive information (server-side) | A SERVER string | The server | Allowed or denied entity types |
| Arcjet threat intel (IP threat) | A SERVER string or string list of hosts | The server | – |
Prompt injection runs on the server because it needs the raw value. The two
sensitive information kinds differ only in where the value is read. The
SDK-local kind runs in your process so the raw value never arrives. The
server-side kind reads the value on Arcjet, which is what makes it usable with
no SDK in the call, such as a coding agent hook.
Declaring a detector against the wrong exposure is a compile error, AJV2007.
Arcjet threat intel scores the hosts a call would contact with the same
assessment Protect uses for the caller of an HTTP request. The input must be
SERVER exposure – there is no LOCAL variant. An empty value is risk
none. A lookup that cannot be completed fails a live rule closed
(AJP1009). For signal fields, allowlist-bypass patterns, and the coding agent
starter, see
Threat detection.
The server-side sensitive information detector screens values up to 2 KB. A larger value is
allowed unscreened with scanned: false on the result, so treat
detected: false alone as inconclusive for an input that can exceed that
size. Prefer the SDK-local kind wherever an SDK is in the call.
Both sensitive information kinds write their result to input.signals.sensitive_info, so moving a
policy between them changes the input’s exposure and the detector’s kind and
no rule.
An expression reads a detector’s result under input.signals, keyed by the
detector ID:
deny contains "injection-on-destructive-tool" if { input.signals.prompt_injection.message_check.detected input.values.destructive}
deny contains "malicious-destination" if { input.signals.ip_threat.dest.risk_level in {"high", "critical"}}A rule is a stable identity, a mode, and how it’s decided.
| Rule kind | Decided by | Use it when |
|---|---|---|
EXPRESSION | The policy’s Rego | The condition combines values, or combines a finding with a value |
DETECTOR | A declared detector, directly | The finding is the whole condition |
A DETECTOR rule needs no expression and no stored test. It’s also what keeps
SDK-local sensitive information detection private: the SDK decides, and Arcjet
never sees the value.
Each rule is in one of two modes:
LIVE. A denial contributes to the decision and the action is stopped.DRY_RUN. The rule is evaluated and recorded without blocking.
New rules default to dry run, so publishing a draft can’t start denying production traffic before you’ve seen it evaluate.
Where a rule executes follows from its kind and detector rather than being chosen. SDK-local sensitive information executes in the SDK; everything else executes on the server.
An expression can only add rule IDs the policy declares. The compiler proves that statically, so a typo fails publication instead of producing a rule that silently never fires.
Choose SDK rules, a policy, or both
Section titled “Choose SDK rules, a policy, or both”An SDK rule belongs to the application and ships with it. A policy is owned and changed elsewhere. Choose by who has to change the rule and which lifecycle governs it.
| SDK rules in code | Agent guard policies | |
|---|---|---|
| Primary owner | Application engineering | Authorized site members, often security or platform teams |
| Authored in | Application source code | The Arcjet control plane |
| Change lifecycle | Code review, tests, build, and deployment | Publish without an application deployment |
| Best fit | Application-specific logic and engineering invariants | Independent policy changes and rapid response |
| Application responsibility | Configure the rule and enforce the decision | Declare the label and typed inputs, then enforce the decision |
| Used together | Yes. Submit SDK rules with the guard call | Yes. The matching published policy is evaluated on that call |
Keep stable application invariants in code and put policy that security must change independently in the control plane. Don’t duplicate a rule in both places unless one is an intentional defense-in-depth backstop with clear precedence and separate tests.
For the architecture and the ownership trade-offs, read Application-Native vs Remote Security Policies.
Map inputs in an agent framework
Section titled “Map inputs in an agent framework”One email.sent policy can carry every rule for the action. Every adapter
takes actor and inputs, in JavaScript and Python, each as a value or a
function resolved per call, and maps them into the policy before the framework
executes the tool. For what a resolver receives in each adapter, see
Send an actor and policy inputs.
const sendEmail = guardTool(arcjet, sendEmailTool, { action: "email.sent", actor: currentUser.id, inputs: ({ recipient, body }) => ({ recipient: policyInput.server.string(recipient), allowed_recipients: policyInput.server.stringList( currentUser.allowedRecipients, ), body: policyInput.local.string(body), incoming_message: policyInput.server.string(incomingMessage), }),});
const tools = { sendEmail };const context = createAgentContext({ correlationId: runId });
const result = await generateText({ model: "openai/gpt-4o-mini", prompt: incomingMessage, tools, toolsContext: aiToolsContext(context, tools),});guarded_send_email = guard_tool( guard=arcjet, tool=send_email_tool, action="email.sent", actor=current_user.id, inputs=lambda arguments, _config: { "recipient": server_input.string(arguments["recipient"]), "allowed_recipients": server_input.string_list( current_user.allowed_recipients ), "body": local_input.string(arguments["body"]), "incoming_message": server_input.string(incoming_message), },)
agent = create_agent( model="openai:gpt-4o-mini", tools=[guarded_send_email],)result = await agent.ainvoke( {"messages": [{"role": "user", "content": incoming_message}]})The actor, the allowed recipients, and the incoming message come from
application-owned context. Only recipient and body come from the model’s
validated tool call. If any live rule denies, the integration prevents the tool
from executing.
Map only what a policy needs. Every server input you map is a value sent to Arcjet and retained as evidence.
Policies and remote rules
Section titled “Policies and remote rules”Arcjet has two remote configuration systems, and the SDK call that evaluates them separates them:
| Agent guard policies | Request remote rules | |
|---|---|---|
| SDK call | guard() | protect() |
| Scope | One action label | A site and its HTTP requests |
| Reads | The actor and explicit typed inputs | Request metadata and supported request signals |
| Configured in | Policies | Remote rules |
Neither needs a deployment. A remote rule runs only where the application
already calls protect(), so a queue job, a workflow step, or a tool call is
out of its reach. A rate limit written as a policy needs a guard() call the
application doesn’t make, and a spend cap written as a filter rule can’t see
the amount.
Policy updates and availability
Section titled “Policy updates and availability”The SDK fetches a local projection lazily, caches it in memory, never persists it, and rechecks every five minutes. A temporary connectivity problem doesn’t discard a usable policy. Arcjet rechecks the published policy at the edge every minute, and a published policy stays active until a replacement or a deletion supersedes it.
A policy revision mismatch refreshes the local projection, re-evaluates, and retries the guard call once.
If policy evaluation is incomplete or unavailable, the direct client returns an observable failed-open decision, and framework wrappers fail closed by default. For more information, see Availability and fail behavior.
Related
Section titled “Related”- Write policies in Rego – the input document, the profile, and its exclusions
- Policy examples – worked policies for common agent actions
- Threat detection – Arcjet threat intel signals and allowlist-bypass patterns
- Author and publish policies – the builder, plain English, tests, and publication
- Policy error codes – what a rule reports when it can’t be evaluated