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

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.

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

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.

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.

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.

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

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, 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"}
}

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

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

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.

Arcjet has two kinds of remote configuration, and a different SDK call evaluates each one:

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

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.