Skip to content

Agent guards testing and reference

Test at the action boundary and assert both the decision behavior and the side effect. For each guarded tool, cover:

ScenarioExpected result
Allowed actor and inputsTool executes once
Policy rule denial in LIVETool does not execute
Same rule in DRY_RUNResult is recorded, but the rule does not block
Missing or wrong typed inputPolicy is incomplete; sensitive tools do not execute
Guard transport or evaluation failureYour selected fail behavior is applied
Local sensitive valueRaw local string is not sent as a policy input
Capture after an allowed actionEvent is recorded with the expected action

Use deterministic inputs for each rule: a recipient outside an allowed list, a string outside the configured byte-length range, known sensitive test data, and a clear prompt-injection fixture. Review policyResults / policy_results to identify the specific remote rule that matched.

DRY_RUN is useful for evaluating a policy against real calls before enabling enforcement, but it does not replace a test that proves the side effect is skipped on a LIVE denial.

For HTTP routes, see Testing Arcjet. For in-process Guard and capture tests, use the SDK test client.

const decision = await arcjet.guard({
label: "email.sent",
actor: user.id,
inputs: {
recipient: policyInput.server.string(to),
},
rules: [],
metadata: { workflow: "support" },
correlationId: runId,
timeoutSeconds: 2,
signal,
});
OptionDescription
labelRequired action identifier and remote-policy selector
actorOptional trusted, application-asserted identity
inputsNamed values created with policyInput.server.* or policyInput.local.*
rulesOptional SDK rule submissions; an empty list still calls Guard
metadataNested JSON for analytics and debugging. See Metadata.
correlationIdIdentifier shared across related calls
timeoutSecondsGuard request timeout; defaults to 2 seconds
signalOptional cancellation signal

guard(), protect(), and every Guard rule accept metadata: string keys mapped to any JSON-serializable value, including nested objects, arrays, numbers, booleans, and null. Each top-level value is JSON-encoded by the SDK and stored verbatim. Metadata is excluded from fingerprinting and from the decision cache key. Nothing here can fail a call or change a decision.

metadata: {
user: { id: userId, plan: "pro" },
toolName: "get_weather",
durationMs: 160,
success: true,
}

Server-enforced limits. Over a limit, that key is dropped:

LimitValue
Top-level keys128
Serialized bytes per value4 KiB
Nesting depth per value10
Key namesletters, digits, -, ., _

The SDK also drops keys once one request’s metadata exceeds 768 KiB in total (keys plus JSON-encoded values, counted before compression). That ceiling exists so oversized metadata cannot push a request past the 1 MiB protocol limit, where it would be rejected and fail open.

Dropped keys are reported, not silent:

  • Server-side drops arrive on decision.warnings / decision.Warnings, one per key.
  • Keys the SDK cannot encode are collected into a single AJ1017 warning naming them. On Guard, that warning is added to the decision and reported to the server. On protect(), Go surfaces it on Decision.Warnings; Python logs it at WARNING because protect() has no warnings field; JavaScript reports it to Arcjet rather than throwing.
  • A metadata value that is not a plain object or map is ignored entirely.

What the SDK cannot encode differs by language:

LanguageDropped with AJ1017Notes
JavaScriptundefined, functions, BigInt, circular referencesNumbers are IEEE-754 doubles. Pass integers above Number.MAX_SAFE_INTEGER as strings. Objects with toJSON(), including Date, serialize through toJSON().
Pythondatetime, sets, NaN, circular referencesIntegers are sent verbatim, including values past 2^53. Convert datetimes explicitly (datetime.isoformat()).
Gochannels, funcs, cycles, NaN, invalid UTF-8 stringsExact int64 values survive. Keys are processed in sorted order because Go maps have no insertion order.

Rule-level metadata is merged with guard()-level metadata shallowly: a duplicate key’s whole value is replaced, never deep-merged.

Metadata is untrusted and is not redacted – do not put secrets or PII in it.

Use capture() / Capture to record that an allowed action happened. Captures are visibility data: they never change a conclusion and never set hasFailedOpen(). For options, batching, flush(), and serverless waitUntil, see Capture events.

Every decision has an id, conclusion, SDK results, warnings, and error accessors. A denial also has a broad reason. Remote policy status and results are additive and may be absent when the server does not provide policy data.

Remote policy data is separate from SDK-submitted rule results:

  • policyResults / policy_results contains keyed remote rule results with policy ID, revision, rule ID, LIVE or DRY_RUN mode, SDK or SERVER execution, and the typed result.
  • results remains the positional list for rules submitted by the SDK call.

The Arcjet server combines enforced SDK and remote rules into the final ALLOW or DENY; the SDK does not recompute that aggregate conclusion.

Every rule in a call is evaluated, so a denial by one rule still consumes another’s budget. A call that trips detectSensitiveInfo also spends a token from a tokenBucket in the same rules array – the decision reads DENY overall while its TOKEN_BUCKET result reads ALLOW with a decremented remaining. That is usually what you want, since a caller sending PII is one you are happy to slow down. Split the rules across two guard calls when a false positive must not drain a legitimate caller’s budget.

policyEvaluation / policy_evaluation reports which policy revision Arcjet evaluated and its status:

StatusWhat it meansWhen you may see itHow to handle it
NOT_CONFIGUREDNo published remote policy matched the guard label.A label intentionally has no policy, the label is wrong, or the policy has not been published.If a policy is expected, verify the label and publish it. Otherwise handle the SDK rule decision normally.
APPLIEDArcjet completely evaluated the matching remote policy.The supplied actor and inputs satisfy the policy contract and all required evaluation is available.Enforce the returned ALLOW or DENY conclusion.
INCOMPLETEA matching policy could not be completely evaluated.A required actor or input is missing or invalid, or required local evaluation could not complete.Treat the security check as incomplete and apply your chosen availability behavior.
UNAVAILABLERemote policy evaluation was temporarily unavailable.Arcjet could not complete a required policy evaluation.Retry where appropriate or apply your chosen availability behavior.
UNKNOWNThe SDK does not recognize the policy status from Arcjet.A newer server returns a status this SDK version does not understand.Upgrade the SDK and, until then, treat the security check as incomplete.

The field may be absent when the server does not return remote-policy status. If your application expects a policy, inspect the decision’s error results and verify the SDK and policy configuration.

The direct Guard client fails open when a transport, timeout, response, local evaluation, or remote-policy completeness error prevents a full evaluation. It returns ALLOW with an error result instead of treating the incomplete check as a policy denial.

if (decision.conclusion === "DENY" || decision.hasFailedOpen()) {
throw new Error("Action blocked");
}

Use decision.errorResults() for diagnostics.

Agent framework wrappers have a stricter default: the wrapped tool does not execute on either a real DENY or unavailable evaluation. Use onGuardError: "allow" / on_guard_error="allow" to opt a wrapper into execution when the security check cannot be completed.

JavaScript guardTool wrappers share one ArcjetDenialResult payload. The envelope is per-framework. guardAction still throws so application code can catch. For the envelope table, see Framework integrations: Denial responses.

Warnings are informational and do not mean the decision failed open. Log them to correct request data, but do not treat a warning alone as a denial.

Passing the client explicitly is the recommended path. Registration is a shortcut for code too deep to receive a handle, where capture() is often most useful.

launchArcjet() / launch_arcjet() never touches global state. Registering is a separate, explicit call. Free guard(), capture(), and flush() then reach the registered client.

If nothing is registered, free guard() fail-opens. It returns ALLOW with an error result, so hasFailedOpen() / has_failed_open() is true. It does not throw. Treat that as “policy did not run”, not as a pass. Free capture() drops the event silently, and flush() returns immediately.

import { launchArcjet, registerArcjet } from "@arcjet/guard";
const arcjet = launchArcjet({ key: process.env.ARCJET_KEY! });
registerArcjet(arcjet);
import { capture, guard } from "@arcjet/guard";
const decision = await guard({ label: "refund", rules: [limit(input)] });
if (decision.hasFailedOpen()) {
// No rule was evaluated.
}
capture({ action: "refund.issued", metadata: { invoice: id } });

A second client does not displace the first (AJ3004 on the incumbent’s logger). unregisterArcjet() clears whatever is registered; libraries must not call it.

@arcjet/guard/testing registers an in-memory client that records calls and talks to nothing:

import { registerTestClient } from "@arcjet/guard/testing";
test("refund captures an event", async () => {
using arcjet = registerTestClient();
await refund("inv_1");
assert.equal(arcjet.captures[0]?.action, "refund.issued");
});

using unregisters at the end of the block. On Node.js 22, call arcjet.unregister() from a finally instead. registerTestClient() throws if a client is already registered. Recording is synchronous – once the code under test reaches capture(), the event is there with no flushing.

guard() on the test client records the call and returns a fail-open ALLOW, because no rule ran. Helpers that fail closed on a failed-open decision – guardTool, guardAction – therefore deny against this client.

Go does not have a process-wide registry. Pass the GuardClient explicitly and use Capture / Flush on that client.

For the HTTP Newman examples and a fuller walkthrough of the test client, see Testing Arcjet.