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 that the policy declares. A rule doesn’t fire on a name that the application never sends, so generate the guard call from the policy instead of copying names by hand. 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
ASCII letters, digits, dashes, underscores, and dots, must start and end with a
letter or digit, are case-sensitive, and are limited to 256 bytes. Uppercase
letters need @arcjet/guard 1.14.1, arcjet 1.4.0 for Python, or arcjet-go
v1.2.0 or later. One policy owns a label, so you can’t save a policy whose
label differs from another live policy’s label only in case.
Arcjet doesn’t match an invalid label to a policy, so the guard doesn’t run: the
decision is ALLOW, and hasFailedOpen() / has_failed_open() /
HasFailedOpen() is false. To catch an invalid label early, the adapter
factories throw or raise ArcjetInvalidLabelError (@arcjet/guard 1.13.0 or
later, arcjet 1.2.0 or later), or return ErrInvalidLabel in Go. capture()
warns AJ1023 and still sends the event. To check a label that you build
yourself, call 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”You name and construct every policy input explicitly, so you decide exactly what each policy receives. The SDK rejects plain values, and it doesn’t inspect the tool schema or discover arguments for you.
| Exposure | Type | JavaScript / TypeScript | Python | Go | Example value |
|---|---|---|---|---|---|
SERVER | String | policyInput.server.string(value) | server_input.string(value) | arcjet.GuardPolicyServerString(value) | "customer@example.com" |
SERVER | Boolean | policyInput.server.boolean(value) | server_input.boolean(value) | arcjet.GuardPolicyServerBoolean(value) | true |
SERVER | Integer | policyInput.server.integer(value) | server_input.integer(value) | arcjet.GuardPolicyServerInteger(value) | 3 |
SERVER | Number | policyInput.server.number(value) | server_input.number(value) | arcjet.GuardPolicyServerNumber(value) | 49.95 |
SERVER | String list | policyInput.server.stringList(value) | server_input.string_list(value) | arcjet.GuardPolicyServerStringList(value) | ["a@example.com", "b@example.com"] |
LOCAL | String | policyInput.local.string(value) | local_input.string(value) | arcjet.GuardPolicyLocalString(value) | "Email body to inspect" |
In Go, an integer input takes an int64 and a number input takes a float64.
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),}Each call accepts up to 64 inputs. A string input can be about 100 KB, and a string list can hold 256 items and about 100 KB in total. Quotes, backslashes, and line breaks each count as more than one byte toward these limits.
Mark an input required when a rule depends on it. An expression over an input that the application didn’t send is undefined, and a rule with an undefined statement doesn’t fire, so a required input makes the dependency explicit.
Arcjet checks each call’s inputs against the policy before it evaluates a rule.
If a required input is missing, or a declared input has the wrong kind,
exposure, or size, the call’s inputs are invalid, and a policy with live rules
fails closed. The expression rules report AJP1003 naming the input, and a
detector that reads it reports its own error. A malformed call can’t pass a
rule that it would otherwise fail.
An input that the policy doesn’t declare can’t affect any rule, so Arcjet drops
it, evaluates the policy, and adds an AJ1060 warning that names the input. As
a result, renaming an input in a policy doesn’t block every deployed
application, but a rule can end up reading an input that no application sends.
After a rename, log decision.warnings in your application and check for
AJ1060. The Arcjet Console doesn’t show warnings.
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 follows from what reads the input:
the policy expression and server-side detectors read SERVER inputs, and the
SDK-local sensitive information detector reads LOCAL inputs.
SERVER inputs
Section titled “SERVER inputs”The SDK sends the typed value 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. Instead, it reads a bounded result that Arcjet produces, so evaluation stays predictable. Declare a detector, give it an ID, and name the input that 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, so it works when no SDK is in the
call, such as from 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. Arcjet also scores a host as risk none when a DNS or IP lookup fails,
so if a rule must deny a host that Arcjet can’t verify, combine this detector
with another condition, such as an allow list. Each call can name up to 30
distinct public hosts. A call that names more reports AJP1009, and a live
rule fails closed. For signal fields, allowlist-bypass patterns, and the coding
agent starter, see
Threat detection.
The server-side sensitive information detector screens values up to about
100 KB. In each call, it screens up to two SERVER inputs longer than
2 KB. Inputs that hold the same string count once. If Arcjet can’t screen
an input, including a third long one, a live rule fails closed with AJP1004.
A value over 2 KB can take up to 3 seconds to screen, which is longer than the Guard clients’ 2-second default timeout. When you send long values, set the client timeout above 3 seconds. For the timeout settings, see Direct Guard API.
Both sensitive information kinds write their result to
input.signals.sensitive_info, so you can move a policy between them by
changing the input’s exposure and the detector’s kind, with no change to any
rule. A detector that lists PHI types must stay server-side.
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"}}Sensitive information taxonomy versions
Section titled “Sensitive information taxonomy versions”The sensitive information taxonomy is the list of entity types that Arcjet detects. When Arcjet supports new entity types, the taxonomy version moves forward. For every entity type and the version that added it, see Entity types.
A server-side sensitive information detector stays on the version it was
written against, which the policy records in sensitiveInfo.taxonomyVersion.
Arcjet ignores a finding of a type from a later version, so newly supported
types don’t trigger unexpected detections in a policy that you already
published. A detector that you add uses the current version, and you can move
an existing detector forward when you want its new types. The SDK-local kind
doesn’t use a version.
Validating or saving a policy warns you about a filter entry that the detector’s version doesn’t cover. The entry has no effect until you fix it.
A rule has a stable identity, a mode, and a kind that sets 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 counts toward the decision, and the action doesn’t run.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.
A rule’s kind and detector decide where it runs. SDK-local sensitive information runs in the SDK, and everything else runs 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 outside the application, often by a security team. 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. In JavaScript and
Python, every adapter takes actor and inputs, each as a value or as a
function that the adapter resolves on each call. The adapter maps them into the
policy before the framework runs 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. Each server input that you map is sent to Arcjet and kept as evidence for the decision.
Policies and remote rules
Section titled “Policies and remote rules”Arcjet has two kinds of remote configuration, and a different SDK call evaluates each one:
| 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 an application deployment to change. 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 policy’s local projection on first use, keeps it in memory only, and rechecks it 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 a decision that reports it failed open, 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