Skip to content

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.

For each guarded action, the security and development teams agree on five things:

  1. A stable label, such as email.sent, that selects the policy.
  2. Whether the policy requires an actor.
  3. Named inputs, each with a type and SERVER or LOCAL exposure.
  4. Detectors to run, and which input each one reads.
  5. Stable rule identities, each LIVE or DRY_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.

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.

ExposureTypeJavaScript / TypeScriptPythonExample value
SERVERStringpolicyInput.server.string(value)server_input.string(value)"customer@example.com"
SERVERBooleanpolicyInput.server.boolean(value)server_input.boolean(value)true
SERVERIntegerpolicyInput.server.integer(value)server_input.integer(value)3
SERVERNumberpolicyInput.server.number(value)server_input.number(value)49.95
SERVERString listpolicyInput.server.stringList(value)server_input.string_list(value)["a@example.com", "b@example.com"]
LOCALStringpolicyInput.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),
}

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.

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.

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.

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 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:

DetectorReadsRuns onOptions
Prompt injectionA SERVER stringThe server–
Sensitive information (SDK-local)A LOCAL stringThe SDKAllowed or denied entity types
Sensitive information (server-side)A SERVER stringThe serverAllowed or denied entity types
Arcjet threat intel (IP threat)A SERVER string or string list of hostsThe 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 kindDecided byUse it when
EXPRESSIONThe policy’s RegoThe condition combines values, or combines a finding with a value
DETECTORA declared detector, directlyThe 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.

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 codeAgent guard policies
Primary ownerApplication engineeringAuthorized site members, often security or platform teams
Authored inApplication source codeThe Arcjet control plane
Change lifecycleCode review, tests, build, and deploymentPublish without an application deployment
Best fitApplication-specific logic and engineering invariantsIndependent policy changes and rapid response
Application responsibilityConfigure the rule and enforce the decisionDeclare the label and typed inputs, then enforce the decision
Used togetherYes. Submit SDK rules with the guard callYes. 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.

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),
});

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.

Arcjet has two remote configuration systems, and the SDK call that evaluates them separates them:

Agent guard policiesRequest remote rules
SDK callguard()protect()
ScopeOne action labelA site and its HTTP requests
ReadsThe actor and explicit typed inputsRequest metadata and supported request signals
Configured inPoliciesRemote 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.

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.