Agent guards testing and reference
Test guard enforcement
Section titled “Test guard enforcement”Test at the action boundary and assert both the decision behavior and the side effect. For each guarded tool, cover:
| Scenario | Expected result |
|---|---|
| Allowed actor and inputs | Tool executes once |
Policy rule denial in LIVE | Tool does not execute |
Same rule in DRY_RUN | Result is recorded, but the rule does not block |
| Missing or wrong typed input | Policy is incomplete; sensitive tools do not execute |
| Guard transport or evaluation failure | Your selected fail behavior is applied |
| Local sensitive value | Raw local string is not sent as a policy input |
| Capture after an allowed action | Event 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.
Direct Guard API
Section titled “Direct Guard API”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,});| Option | Description |
|---|---|
label | Required action identifier and remote-policy selector |
actor | Optional trusted, application-asserted identity |
inputs | Named values created with policyInput.server.* or policyInput.local.* |
rules | Optional SDK rule submissions; an empty list still calls Guard |
metadata | Nested JSON for analytics and debugging. See Metadata. |
correlationId | Identifier shared across related calls |
timeoutSeconds | Guard request timeout; defaults to 2 seconds |
signal | Optional cancellation signal |
decision = await arcjet.guard( label="email.sent", actor=user.id, inputs={"recipient": server_input.string(to)}, rules=[], metadata={"workflow": "support"}, correlation_id=run_id,)| Parameter | Description |
|---|---|
label | Required action identifier and remote-policy selector |
actor | Optional trusted, application-asserted identity |
inputs | Named values created with server_input.* or local_input.* |
rules | Optional SDK rule submissions; an empty sequence still calls Guard |
metadata | Nested JSON for analytics and debugging. See Metadata. |
correlation_id | Identifier shared across related calls |
The client request timeout is set on launch_arcjet() /
launch_arcjet_sync() as timeout_ms and defaults to 2000 ms.
decision, err := guard.Guard(ctx, arcjet.GuardRequest{ Label: "email.sent", Actor: &userID, Inputs: map[string]arcjet.GuardPolicyInput{ "recipient": arcjet.GuardPolicyServerString(to), }, Metadata: arcjet.Metadata{ "workflow": "support", "invoice": map[string]any{"id": "inv_123", "amount": 4200}, }, CorrelationId: runID,})| Field | Description |
|---|---|
Label | Required action identifier and remote-policy selector |
Actor | Optional trusted, application-asserted identity |
Inputs | Named values created with GuardPolicyServer* or GuardPolicyLocal* |
Rules | Optional SDK rule submissions; an empty list still calls Guard |
Metadata | Nested JSON for analytics and debugging. See Metadata. |
CorrelationId | Identifier shared across related calls |
Metadata
Section titled “Metadata”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:
| Limit | Value |
|---|---|
| Top-level keys | 128 |
| Serialized bytes per value | 4 KiB |
| Nesting depth per value | 10 |
| Key names | letters, 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
AJ1017warning naming them. On Guard, that warning is added to the decision and reported to the server. Onprotect(), Go surfaces it onDecision.Warnings; Python logs it atWARNINGbecauseprotect()has no warnings field; JavaScript reports it to Arcjet rather than throwing. - A
metadatavalue that is not a plain object or map is ignored entirely.
What the SDK cannot encode differs by language:
| Language | Dropped with AJ1017 | Notes |
|---|---|---|
| JavaScript | undefined, functions, BigInt, circular references | Numbers are IEEE-754 doubles. Pass integers above Number.MAX_SAFE_INTEGER as strings. Objects with toJSON(), including Date, serialize through toJSON(). |
| Python | datetime, sets, NaN, circular references | Integers are sent verbatim, including values past 2^53. Convert datetimes explicitly (datetime.isoformat()). |
| Go | channels, funcs, cycles, NaN, invalid UTF-8 strings | Exact 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.
Capture
Section titled “Capture”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.
Decisions and policy results
Section titled “Decisions and policy results”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_resultscontains keyed remote rule results with policy ID, revision, rule ID,LIVEorDRY_RUNmode,SDKorSERVERexecution, and the typed result.resultsremains 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.
Policy evaluation status
Section titled “Policy evaluation status”policyEvaluation / policy_evaluation reports which policy revision Arcjet
evaluated and its status:
| Status | What it means | When you may see it | How to handle it |
|---|---|---|---|
NOT_CONFIGURED | No 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. |
APPLIED | Arcjet 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. |
INCOMPLETE | A 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. |
UNAVAILABLE | Remote policy evaluation was temporarily unavailable. | Arcjet could not complete a required policy evaluation. | Retry where appropriate or apply your chosen availability behavior. |
UNKNOWN | The 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.
Availability and fail behavior
Section titled “Availability and fail behavior”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.
if decision.conclusion == "DENY" or decision.has_failed_open(): raise RuntimeError("Action blocked")Use decision.error_results() 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.
Register a client and the test client
Section titled “Register a client and the test client”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.
from arcjet.guard import launch_arcjet, register_arcjet
register_arcjet(launch_arcjet(key=os.environ["ARCJET_KEY"]))capture() is one function for both client flavors. guard() and flush()
come in pairs:
| Registered client | Guard | Flush |
|---|---|---|
launch_arcjet() | await guard(...) | await flush() |
launch_arcjet_sync() | guard_sync(...) | flush_sync() |
Calling the wrong pair fail-opens and reports AJ3007 on the registered
client’s logger.
from arcjet.guard.testing import register_test_client
async def test_refund_captures_an_event(): with register_test_client() as arcjet: await refund("inv_1") assert arcjet.captures[0].action == "refund.issued"The test client answers both guard() and guard_sync(). It records the call
and returns a fail-open ALLOW. It is not a mock server and does not stub
per-rule verdicts.
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.