@arcjet/skills
Version:
Versioned Agent Skills for the Arcjet JavaScript SDK, shipped with TanStack Intent
149 lines (114 loc) • 6.13 kB
Markdown
# Guard non-HTTP JavaScript with Arcjet
Source documentation for the `guard` Agent Skill. JavaScript and TypeScript
only. For HTTP routes, see `docs/protect.md`.
## When to use Guard
Use `/guard` when there is no HTTP request object: agent tool calls,
MCP server handlers, queue workers, and background jobs.
The word "server" on MCP is misleading. MCP tools are invoked over stdio or
SSE and do not receive an HTTP request.
## Client
```ts
import { launchArcjet } from "@arcjet/guard";
export const arcjet = launchArcjet({ key: process.env.ARCJET_KEY! });
```
Create the client once at module scope. Prefer passing the client explicitly.
`registerArcjet` enables free `guard()` / `capture()` / `flush()` when you
cannot thread a client. Free `guard()` fail-opens if nothing is registered —
check `hasFailedOpen()` and do not treat that ALLOW as a pass.
Declare rules at module scope so `.deniedResult(decision)` and
`.errorResult(decision)` have a stable reference.
## One `guard()` per operation
Call `guard()` where you already know the operation. Hardcode the `label`.
Do not interpolate names in a generic dispatcher.
Hardcode labels as slugs. Prefer lowercase letters, digits, `-`, and `.`
(`tools.get-weather`) so every SDK and the public docs accept them. Start
and end with a letter or digit; max 256 bytes. Do not interpolate user input.
```ts
const decision = await arcjet.guard("tools.get-weather", {
rules: [/* ... */],
metadata: { user: { id: userId } },
});
if (decision.conclusion === "DENY") {
const rateLimited = toolCallLimit.deniedResult(decision);
if (rateLimited) {
throw new Error(`rate limited – retry after unix ${rateLimited.resetAtUnixSeconds}`);
}
if (decision.reason === "PROMPT_INJECTION") {
throw new Error("input flagged as prompt injection");
}
return { error: decision.reason };
}
```
`decision.reason` is a flat string on DENY (`"RATE_LIMIT"`,
`"PROMPT_INJECTION"`, `"SENSITIVE_INFO"`, `"MODERATE_CONTENT"`, …) and
`undefined` on ALLOW. Branch on which rule denied, not only on `DENY`.
`metadata` is nested JSON for the Console. It does not affect the decision.
Do not put secrets or PII in it.
Every rate-limit rule needs a `key` and a `bucket`. Use a user or session id
when you have one; otherwise a stable identifier you control (`"default"`,
deployment name). Do not pass an empty string.
A denial by one rule still spends the others' budget in the same `guard()`
call. Split rules across two calls if a PII false positive must not drain a
rate-limit bucket.
`guard()` never throws for runtime degradation. `hasFailedOpen()` is true
when ALLOW means a rule or the decision could not be processed — that is
the fail-closed gate. `warnings` are request-validation diagnostics; they
never change the conclusion. Prefer `hasFailedOpen()` over deprecated
`hasError()`.
Pass `correlationId` to join a guard decision with a request or agent
trace. It is a dedicated field, not metadata.
`localDetectSensitiveInfo()` defaults to WASM and matches four types:
card, email, phone, IP. Names, addresses, and government / financial
identifiers need `/sensitive-info-rampart` (`backend: rampart()`).
Listing a Rampart-only entity without that backend never matches.
`moderateContent()` is Guard-only.
## Framework wrappers
Prefer the versioned Guard namespaces over hand-wrapping every tool:
| SDK | Import |
| --- | ------ |
| Vercel AI SDK v7 | `/guard/vercel-ai/v7` |
| Vercel Eve | `/guard/vercel-eve/v0` |
| Mastra | `/guard/mastra/v1` |
| Claude Agent SDK | `/guard/claude-agent-sdk/v0` |
| Claude Managed Agents | `/guard/claude-managed-agents/v0` |
| LangGraph JS | `/guard/langgraph/v1` |
| LangChain JS `createAgent` | `/guard/langchain/v1` |
| OpenAI Agents | `/guard/openai-agents/v0` |
| Genkit JS | `/guard/genkit/v1` |
| Google ADK JS | `/guard/google-adk/v2` |
| Strands Agents JS | `/guard/strands-agents/v1` |
| TanStack AI | `/guard/tanstack-ai/v0` |
Unversioned aliases do not resolve. Load the matching
`/guard#integrate-arcjet-guard-*` skill for the integration you are
implementing. Those skills ship in the `/guard` package.
Framework wrappers fail closed by default when Guard is unavailable. Core
`guard()` fails open — check `hasFailedOpen()`.
JS LangChain `createAgent` is `/guard/langchain/v1`, not LangGraph.
JS Google ADK is `guardPlugin` on the Runner — there is no `guardTool`.
TanStack AI is `guardMiddleware` — do not use `guardTool` (an `execute`
throw is swallowed). JS Strands Agents is official `-agents/sdk`,
not Python `strands`.
Every JS wrapper policy accepts optional `actor` and `inputs` (static values
or a resolver over that adapter's native call — parsed input plus trusted
runtime/context, like Vercel AI's `(input, ctx)`). Build each
input with `policyInput` so a remote policy that declares those names can
evaluate. Omit them and the remote policy has nothing to read — its rules do
not fire.
Every adapter uses one `ArcjetDenialResult` payload
(`{ arcjetDenied: true, reason, message, retryable }`). Delivery is
per-framework: return the object (AI SDK, Mastra, OpenAI Agents, Genkit
`toolResponse.output`, LangGraph, LangChain `guardTool`, Google ADK plugin
dict, Strands `guardTool`); wrap it (Claude Agent SDK `isError: true`,
LangChain `guardMiddleware` `ToolMessage`, Strands `guardHooks` cancel
string, TanStack `onBeforeToolCall` skip); or throw `ArcjetDeniedError`
(Eve, unless `onDeny: "result"`). A throw from a return-style adapter
drops the fields.
Human-in-the-loop APIs (`needsApproval`, `interrupt()`, `requireApproval`,
`requireConfirmation`, `canUseTool`) are not policy gates. After a human
yes, Guard still runs.
## Capture and flush
`capture()` records that something happened. It never denies. Call `flush()`
on shutdown. On serverless, pass a platform `waitUntil`.
## Verify
Invoke the protected function directly. Do not `curl` an HTTP route to test
Guard. Confirm with `npx /cli guards list --site-id <id>`.