diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b09b7d..7010f4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,32 @@ this project adheres to [Semantic Versioning](https://semver.org/). ### Changed +- Synced the vendored skill tree with + [arcjet/skills](https://github.com/arcjet/skills) `main` at + `677f177b` (2026-09-03, + [arcjet/skills#58](https://github.com/arcjet/skills/pull/58)). Follow-up to + the #20 sync of `116d08b5` (skills#51). Files were fetched from GitHub at + that SHA, then this repo’s `dprint` formatter was applied. The tip + includes [arcjet/skills#55](https://github.com/arcjet/skills/pull/55) + (Google ADK JS + Python Strands Agents), + [arcjet/skills#54](https://github.com/arcjet/skills/pull/54) (client IP + provenance), + [arcjet/skills#57](https://github.com/arcjet/skills/pull/57) (TanStack AI, + JS Strands Agents, Python Claude Agent SDK, Python OpenAI Agents), + [arcjet/skills#56](https://github.com/arcjet/skills/pull/56) (Claude + Managed Agents JS + Python), and + [arcjet/skills#58](https://github.com/arcjet/skills/pull/58) (Python Guard + adapters split into dedicated skills). Canonical copy is + `plugins/arcjet/skills/` (`skills/` is the inbound symlink). New skill + dirs: `integrate-arcjet-guard-langchain-py`, + `integrate-arcjet-guard-crewai`, + `integrate-arcjet-guard-openai-agents-py`, + `integrate-arcjet-guard-claude-agent-sdk-py`, + `integrate-arcjet-guard-claude-managed-agents-py`, + `integrate-arcjet-guard-strands-agents-py`. Skills `main` has no JS + `integrate-arcjet-guard-*` dirs at this SHA — JS adapters stay in + `arcjet/`. No extra remote-policy teaching (`actor` / `inputs` / + `policyInput`). Deprecated alias skill directories are unchanged. - Synced `skills/arcjet/` with [arcjet/skills](https://github.com/arcjet/skills) `main` at `116d08b5` (2026-08-25, diff --git a/plugins/arcjet/skills/arcjet/SKILL.md b/plugins/arcjet/skills/arcjet/SKILL.md index 7d7fdd0..62840d8 100644 --- a/plugins/arcjet/skills/arcjet/SKILL.md +++ b/plugins/arcjet/skills/arcjet/SKILL.md @@ -1,7 +1,7 @@ --- name: arcjet license: Apache-2.0 -description: Add Arcjet security protection to any code path – HTTP route handlers, API endpoints, AI agent tool calls, MCP servers, background jobs, and queue workers. Covers rate limiting, bot detection, email validation, prompt injection detection, sensitive information blocking (including Rampart NER), content moderation, capture/flush, and abuse prevention. Works with JavaScript/TypeScript, Python, and Go across Next.js, Express, Fastify, SvelteKit, Remix, Bun, Deno, NestJS, FastAPI, Flask, net/http, Vercel AI SDK, Vercel Eve, Mastra, LangChain (Python and JS createAgent), LangGraph, CrewAI, OpenAI Agents, Genkit, and other non-HTTP contexts. Use this skill when the user wants to add security, rate limiting, bot protection, or abuse prevention to any part of their application – whether they say "protect my API," "rate limit tool calls," "block bots," "secure my endpoint," "add security to my MCP server," "guard this Mastra/Eve/LangGraph/LangChain createAgent/CrewAI/OpenAI Agents/Genkit/AI SDK agent," or "prevent abuse" without mentioning Arcjet specifically. +description: Add Arcjet security protection to HTTP routes, AI agent tool calls, MCP servers, background jobs, and queue workers. Covers rate limiting, bot detection, email validation, prompt injection, sensitive information blocking (including Rampart NER), content moderation, capture/flush, and abuse prevention. Works in JavaScript/TypeScript, Python, and Go across Next.js, Express, Fastify, SvelteKit, Remix, Bun, Deno, NestJS, FastAPI, Flask, net/http, Vercel AI SDK, Eve, Mastra, LangChain JS, LangGraph, OpenAI Agents JS, Genkit, Google ADK, JS Strands Agents, TanStack AI, Claude Agent SDK JS, Claude Managed Agents JS, and other non-HTTP contexts. Official Python LangChain, CrewAI, OpenAI Agents, Claude Agent SDK, Claude Managed Agents, and Strands Agents have dedicated integrate-arcjet-guard skills. Use when the user wants security, rate limiting, bot protection, or abuse prevention – "protect my API," "rate limit tool calls," "block bots," "secure my endpoint," or "prevent abuse" – even without naming Arcjet. metadata: author: arcjet --- @@ -14,6 +14,8 @@ metadata: - [Choose protections](#choose-protections) - [Resources](#resources) +Python Guard adapters for LangChain, CrewAI, OpenAI Agents, Claude Agent SDK, Claude Managed Agents, and Strands Agents are dedicated skills (see Step 3). Shared Python Guard fundamentals stay in [references/guards_python.md](references/guards_python.md). + ## Add Arcjet protection to your app ### Checklist @@ -73,7 +75,7 @@ Determine which protection type applies: | **Go SDK** | `github.com/arcjet/arcjet-go` (with `NewClient`) | `github.com/arcjet/arcjet-go` (with `NewGuardClient`) | | **Entry point** | `protect(request)` / `Protect(ctx, r)` | `guard(label, rules)` / `Guard(ctx, request)` | -A single project can use both – for example, request-based on API routes and Guard on agent tool calls. If the project already uses Vercel AI SDK, Vercel Eve, Mastra, LangChain, LangGraph, CrewAI, OpenAI Agents, or Genkit, prefer the versioned Guard wrappers in the language reference over hand-wrapping every tool. In Python, that is `guard_action` / `guard_tool` / `ArcjetMiddleware` / `arcjet.guard.crewai` (see the Python Guard reference) – not a raw `guard()` around every callable. JS LangChain `createAgent` is `@arcjet/guard/langchain/v1`, not the LangGraph Graph API adapter. +A single project can use both – for example, request-based on API routes and Guard on agent tool calls. If the project already uses Vercel AI SDK, Vercel Eve, Mastra, LangChain, LangGraph, CrewAI, OpenAI Agents, Genkit, Google ADK, Python Strands Agents, JS Strands Agents, TanStack AI, the Claude Agent SDK, or Claude Managed Agents, prefer the versioned Guard wrappers over hand-wrapping every tool. In Python, load the dedicated skill for that adapter (table below) — not a raw `guard()` around every callable, and not the JS `@arcjet/guard/...` path. JS LangChain `createAgent` is `@arcjet/guard/langchain/v1`, not the LangGraph Graph API adapter. JS Google ADK is `@arcjet/guard/google-adk/v2` (docs `/guards/google-adk/`). JS Strands Agents is `@arcjet/guard/strands-agents/v1` (docs `/guards/strands-agents/`). TanStack AI is `@arcjet/guard/tanstack-ai/v0` (`guardMiddleware` + `tanstackAiContext` only), not Vercel AI SDK and not TanStack Start HTTP `protect()`. JS Claude Agent SDK is `@arcjet/guard/claude-agent-sdk/v0` (docs `/guards/claude-agent-sdk/`). JS OpenAI Agents is `@arcjet/guard/openai-agents/v0` (docs `/guards/openai-agents/`). JS Claude Managed Agents is `@arcjet/guard/claude-managed-agents/v0` (hosted `@anthropic-ai/sdk` sessions), not `@arcjet/guard/claude-agent-sdk/v0` (local `query()`). **Common misclassifications to watch for:** @@ -91,6 +93,17 @@ Read the appropriate reference: - **Guard Python**: [references/guards_python.md](references/guards_python.md) - **Guard Go**: [references/guards_go.md](references/guards_go.md) +When the project already uses an official Python agent framework, load the dedicated skill instead of the long adapter sections that used to live in the Python Guard reference: + +| Python framework | Import | Skill | +| --------------------------------------- | ------------------------------------ | -------------------------------------------------------------------------------------------------------------- | +| LangChain (`BaseTool` / `create_agent`) | `arcjet.guard.langchain` | [integrate-arcjet-guard-langchain-py](../integrate-arcjet-guard-langchain-py/SKILL.md) | +| CrewAI | `arcjet.guard.crewai` | [integrate-arcjet-guard-crewai](../integrate-arcjet-guard-crewai/SKILL.md) | +| OpenAI Agents | `arcjet.guard.openai_agents` | [integrate-arcjet-guard-openai-agents-py](../integrate-arcjet-guard-openai-agents-py/SKILL.md) | +| Claude Agent SDK | `arcjet.guard.claude_agent_sdk` | [integrate-arcjet-guard-claude-agent-sdk-py](../integrate-arcjet-guard-claude-agent-sdk-py/SKILL.md) | +| Claude Managed Agents | `arcjet.guard.claude_managed_agents` | [integrate-arcjet-guard-claude-managed-agents-py](../integrate-arcjet-guard-claude-managed-agents-py/SKILL.md) | +| Strands Agents | `arcjet.guard.strands_agents` | [integrate-arcjet-guard-strands-agents-py](../integrate-arcjet-guard-strands-agents-py/SKILL.md) | + These references explain architectural decisions and patterns that can't be inferred from the source code alone. For exact API signatures, read the installed package's types and doc comments. ### Step 4: Implement protection @@ -106,7 +119,8 @@ Follow the patterns in the reference file from Step 3. Key principles: - Call `protect()` / `Protect()` inside each route handler (not in app-level middleware), once per request. - Map denial reasons to HTTP responses. Only branch on reasons that produce a _different_ response – there is no point in a Shield-specific arm that returns the same status as the default 403. - Put the language's `userId` characteristic selector on the specific rule that needs it, then pass a **trusted, authenticated** user ID at protection time. Never rate limit by a client-controlled header unless a trusted proxy strips and rewrites it. -- If the application already has a trusted client IP, pass it explicitly: `ipSrc` (JS), `ip_src` (Python – also set `disable_automatic_ip_detection=True`), `WithIPSrc` (Go). The SDK trusts the value; do not pass a client-controlled header. +- Treat client-IP provenance as security configuration. JavaScript, Python, and Go may fall back to common forwarding headers when no usable public address is available and produce one `client_ip_provenance="unverified-header"` warning for the lifetime of each SDK client instance. Configure every trusted proxy and verify the application is reachable only through infrastructure that overwrites or safely appends forwarding headers. Never silence the warning by copying a client-controlled header into a manual override. +- If the application already has an independently trusted client IP, pass it explicitly: `ipSrc` (JS), `ip_src` (Python; also set `disable_automatic_ip_detection=True` when constructing the client), or `WithIPSrc` (Go). The SDKs reject malformed values, although JS treats an empty `ipSrc` as omitted. Syntax validation does not prove provenance. Before shipping, inspect representative requests with JS `clientIpDetails()` / `findIpDetails()`, Python `client_ip_details()`, or Go `ClientIPDetails()`. - `protect()` accepts nested-JSON `metadata` (same shape as Guard). It does not affect fingerprinting. Do not put secrets or PII in it. When present, request decisions also expose optional IP threat intelligence (`decision.ip.threat` / `ip_details.threat` / `IP.Threat`). #### Guard (non-HTTP code): @@ -119,13 +133,22 @@ Follow the patterns in the reference file from Step 3. Key principles: - **Pass `metadata` on the `guard()` call** when you have useful auditing context. It is nested JSON – objects, arrays, numbers, booleans – not a flat string map (`metadata={ user: { id: userId }, requestId }`). It appears in the Console and does not affect the decision. Do not put secrets or PII in it. - **`capture()` records what happened** after an action (refund issued, tool completed). It is visibility data, never a security decision – it does not deny and never sets `hasFailedOpen()`. Call `flush()` on shutdown so the last batch is not lost. On serverless, pass a platform `waitUntil` (JS) or flush at the end of the invocation. On Python `guard_action` / `guard_tool` / `ArcjetMiddleware`, `success` is not "the action ran" – see the Python Guard reference. - **Optional registration (JS/Python only):** `registerArcjet` / `register_arcjet` is a separate call from launch. It enables free `guard()` / `capture()` / `flush()` when you cannot thread a client. Free `guard()` fail-opens if nothing is registered – check `hasFailedOpen()` / `has_failed_open()`; do not treat that ALLOW as a pass. Go has no registration API; pass the client. Prefer an explicit client everywhere you can. -- **JS framework wrappers** (`@arcjet/guard/vercel-ai/v7`, `@arcjet/guard/vercel-eve/v0`, `@arcjet/guard/mastra/v1`, `@arcjet/guard/langgraph/v1`, `@arcjet/guard/langchain/v1`, `@arcjet/guard/claude-agent-sdk/v0`, `@arcjet/guard/openai-agents/v0`, `@arcjet/guard/genkit/v1`) fail closed by default when Guard is unavailable. Import the versioned path – unversioned aliases do not resolve. JS `createAgent` (`@arcjet/guard/langchain/v1`) is not Python LangChain and not LangGraph JS. -- **Python framework wrappers** (`guard_action` / `guard_tool` / `ArcjetMiddleware` / `ArcjetCaptureHandler` / `arcjet.guard.crewai`) fail closed by default when Guard is unavailable. Pick the helper that matches what you hold – any callable → `guard_action` / `guard_action_sync` (core `arcjet.guard`); a LangChain `BaseTool` you call → `guard_tool` (`arcjet[langchain]`, `langchain-core>=1.2.5,<2`); `create_agent` → `ArcjetMiddleware` + `ToolPolicy` (`arcjet[langchain-agents]`, `langchain>=1.3` / `langgraph>=1.2`); observe a chain → `ArcjetCaptureHandler` (`arcjet[langchain]` – cannot deny); official CrewAI crew / LiteAgent / MCP → `register_arcjet_hooks` + `ToolPolicy`; a CrewAI `BaseTool` you call yourself → `guard_tool` (`arcjet.guard.crewai`). There is no `arcjet[crewai]` extra (CrewAI pulls `chromadb`, CVE-2026-45829) — install `crewai>=1.15.3,<2` yourself. Importing `arcjet.guard.langchain` does not load LangGraph. Importing `arcjet.guard.crewai` does not load LangChain. +- **JS framework wrappers** (`@arcjet/guard/vercel-ai/v7`, `@arcjet/guard/vercel-eve/v0`, `@arcjet/guard/mastra/v1`, `@arcjet/guard/langgraph/v1`, `@arcjet/guard/langchain/v1`, `@arcjet/guard/claude-agent-sdk/v0`, `@arcjet/guard/claude-managed-agents/v0`, `@arcjet/guard/openai-agents/v0`, `@arcjet/guard/genkit/v1`, `@arcjet/guard/google-adk/v2`, `@arcjet/guard/tanstack-ai/v0`, `@arcjet/guard/strands-agents/v1`) fail closed by default when Guard is unavailable. Import the versioned path – unversioned aliases do not resolve. JS `createAgent` (`@arcjet/guard/langchain/v1`) is not Python LangChain and not LangGraph JS. JS Google ADK (`@arcjet/guard/google-adk/v2`) is not a `guardTool` adapter. TanStack AI (`@arcjet/guard/tanstack-ai/v0`) is not Vercel AI SDK and not TanStack Start HTTP `protect()`. JS Strands Agents (`@arcjet/guard/strands-agents/v1`) is official `@strands-agents/sdk`, not Python `strands`. JS Claude Managed Agents (`@arcjet/guard/claude-managed-agents/v0`) is not Claude Agent SDK (`@arcjet/guard/claude-agent-sdk/v0`). +- **Python framework wrappers** fail closed by default when Guard is unavailable. `guard_action` / `guard_action_sync` is core `arcjet.guard` (no extra) for any callable. Official LangChain, CrewAI, OpenAI Agents, Claude Agent SDK, Claude Managed Agents, and Strands Agents adapters live in the dedicated skills listed in Step 3 — load that skill for extras, pins, denial envelopes, and HITL traps. Do not mix adapters. Do not import a JS `@arcjet/guard/...` path from Python. - **Eve `guardApproval`:** `approval` is one field – a function (request-time only) or `{ request, response }`. Optional peer `eve` `>=0.34.0 <1` (still 0.x); Node.js ≥ 24 still applies. Do not compose with Eve `always()` / `once()` / `never()`. Request/response + HITL details are in the JS Guard reference. - **OpenAI Agents:** import `@arcjet/guard/openai-agents/v0` (`guardTool` + `openaiAgentsContext` only). Text `Agent` + `run()` + authored `tool()` – not Realtime, Sandbox, hosted, MCP, or `asTool`. Screen inbound before `run()` and act on that `guard()` decision: core `guard()` fails open, so ALLOW is not proof the rules ran. Details in the JS Guard reference. - **Genkit:** import `@arcjet/guard/genkit/v1` (`guardTool` + `guardMiddleware` + `genkitContext` only). JS `genkit()` + `ai.defineTool` + `ai.generate` – not Go / Python Genkit. Authored tools via `guardTool`; filesystem / MCP / unwrapped via `guardMiddleware` on `generate({ use })` (needs Genkit >= 1.33). Screen inbound before `generate()` / `chat.send()` and act on that `guard()` decision: core `guard()` fails open, so ALLOW is not proof the rules ran. Details in the JS Guard reference. +- **Google ADK:** import `@arcjet/guard/google-adk/v2` (`guardPlugin` + `googleAdkContext` only — **no `guardTool`**). JS `@google/adk` `>=2 <3` `Runner` + `LlmAgent` + `FunctionTool`. Gate is Runner `BasePlugin.beforeToolCallback`: a deny dict skips `runAsync`; `undefined` executes. Fail closed = always return a deny dict on error. Put the Arcjet plugin first. HITL (`requireConfirmation` / `requestConfirmation` / `adk_request_confirmation`) is not a policy gate. Correlation from a caller-owned session id via `googleAdkContext` — never mint, never `traceId`. No unversioned `@arcjet/guard/google-adk` alias. Until-published — pin `@arcjet/guard` to git SHA `41ef36816e7174f1b0288d28217e63fa14114307`; not in npm 1.10.0. Details in the JS Guard reference; docs https://docs.arcjet.com/guards/google-adk/. Example: [`examples/google-adk-agent`](https://github.com/arcjet/examples/tree/main/examples/google-adk-agent) (pins `41ef3681`; no verify fixture). - **LangChain JS `createAgent`:** import `@arcjet/guard/langchain/v1` (`guardTool` + `guardMiddleware` + `langchainContext` only). JS `createAgent` + `createMiddleware({ wrapToolCall })` – not LangGraph `StateGraph`/`ToolNode`, not Python LangChain (`/guards/langchain/`). Optional peers `langchain` `>=1.2.0 <2` and `@langchain/core` `>=1 <2`; no `@langchain/langgraph` peer; no unversioned alias. Authored tools via `guardTool` (plain `ArcjetDenialResult`); unwrapped / MCP via `guardMiddleware` (`wrapToolCall` short-circuit returns a real `ToolMessage`, JSON content, default status). Policy on `wrapToolCall` only. `wrapToolCall` only sees `runtime.configurable.thread_id` as of langchain 1.2.34. `humanInTheLoopMiddleware` is HITL, not a policy gate. A resumed run keeps its `thread_id` (`agent.invoke(new Command({ resume }), config)` — same config, same Sequence); do not mint an id or derive one from the interrupt / resume payload. Until-published — pin `@arcjet/guard` to git SHA `c49abcc1f9afce7d284b6c294d0dcee5916ada86` ([#6248](https://github.com/arcjet/arcjet-js/pull/6248)); not in npm 1.10.0. Details in the JS Guard reference; docs https://docs.arcjet.com/guards/langchain-js/. -- **CrewAI:** import `arcjet.guard.crewai`. There is no `arcjet[crewai]` extra (CrewAI pulls `chromadb`, CVE-2026-45829) — install `crewai>=1.15.3,<2` yourself. Official `crewai` only. Once-per-process `register_arcjet_hooks` / `unregister_arcjet_hooks` / `ArcjetCrewAIHooks` on `PRE_TOOL_CALL` only (POST is never registered; sync; `launch_arcjet_sync`). DENY + fail-closed unavailable raise `HookAborted(reason=..., source="arcjet")`. Agent always sees `Tool execution blocked by hook. Tool: {name}`. Optional `guard_tool` for a standalone `BaseTool` you call yourself (only path that raises `ArcjetDeniedError` / `ArcjetUnavailableError`). `ToolPolicy` / `sanitize_tool_name` / `free_text_arguments`. No `guard_crew`. No inbound/approval helper — screen with core `guard` / `guard_sync` before `crew.kickoff`. `human_input` is HITL. Until-published — pin `arcjet` to git SHA `b1253640ce676b948594beed5fe62450d0e1c77d` ([#224](https://github.com/arcjet/arcjet-py/pull/224)); not in PyPI 0.9.0. Details in the Python Guard reference; docs https://docs.arcjet.com/guards/crewai/. +- **CrewAI (Python):** load [integrate-arcjet-guard-crewai](../integrate-arcjet-guard-crewai/SKILL.md). Official `crewai` only; no `arcjet[crewai]` extra. +- **Claude Managed Agents (JS):** hosted harness (`client.beta.sessions`) — Anthropic runs the loop and built-in tools. Not Claude Agent SDK local `query()` / `PreToolUse`. Import `@arcjet/guard/claude-managed-agents/v0` (`guardCustomTool` + `guardEvents` + `claudeManagedAgentsContext` only). Until-published — not in npm 1.10.0; pin `@arcjet/guard` to git SHA `cb35c8f92c3a2fb63fbeb9b386d79b1878c19d92`. Optional peer `@anthropic-ai/sdk` `>=0.86.0 <1` — not `@anthropic-ai/claude-agent-sdk`. JS worked example: [`examples/claude-managed-agents`](https://github.com/arcjet/arcjet-js/tree/main/examples/claude-managed-agents). Default `always_allow` means no customer pre-exec for bash/files. Real gates: inbound `user.message` (`guardEvents` — DENY does not send) and custom tools on `agent.custom_tool_use` (`guardCustomTool`; DENY posts `user.custom_tool_result` with `is_error` true). `always_ask` + `user.tool_confirmation` is opt-in confirmation, not HITL-as-policy. Never mint. Do not correlate on Anthropic `session.id` / `sevt_…`. Details in the JS Guard reference; docs https://docs.arcjet.com/guards/claude-managed-agents/. +- **Claude Managed Agents (Python):** load [integrate-arcjet-guard-claude-managed-agents-py](../integrate-arcjet-guard-claude-managed-agents-py/SKILL.md). Not `arcjet.guard.claude_agent_sdk`. +- **TanStack AI:** import `@arcjet/guard/tanstack-ai/v0` (`guardMiddleware` + `tanstackAiContext` only — do **not** use `guardTool`; TanStack swallows an `execute` throw). Official `@tanstack/ai` `chat({ middleware })` + authored `tool({ execute })` – not Vercel AI SDK (`ai` / `@arcjet/guard/vercel-ai/v7`), not TanStack Start HTTP `protect()`, not TanStack's own `contentGuardMiddleware`. Default DENY is `onBeforeToolCall` skip with `ArcjetDenialResult`. Optional `onDeny: "abort"` returns `{ type: "abort", reason }` and stops the run (real DENY only; unavailable stays skip; the model does not get `ArcjetDenialResult`). Put Arcjet first in `middleware` (`onBeforeToolCall` is first-win). Inbound `guard()` before `chat()` does not brand tools and does not skip the middleware. `needsApproval` / `defineInterrupt` / `onInterruptBoundary` is HITL, not a policy gate (after a human yes, Guard still runs). No `guardInbound` — screen with `guard()` before `chat()` and act on that decision: core `guard()` fails open, so ALLOW is not proof the rules ran. Correlation from caller-owned context id only — never mint, never `threadId` / `requestId` / `streamId` / `traceId`. Optional peer `@tanstack/ai` `>=0.8.0 <1`; no unversioned alias. Fail closed (`onGuardError: "deny"`). The existing `tanstack-agent` example stays with Runtime. Until-published — pin `@arcjet/guard` to git SHA `d730d57a124f03843f085d41f64b0355a09d1eab` ([#6260](https://github.com/arcjet/arcjet-js/pull/6260)); not in npm 1.11.0. Details in the JS Guard reference; docs https://docs.arcjet.com/guards/tanstack-ai/. +- **Strands Agents (JS):** import `@arcjet/guard/strands-agents/v1` (`guardTool` + `guardHooks` + `strandsAgentContext` only). Official `@strands-agents/sdk` `Agent` + `invoke()` / `stream()` + authored `tool({ callback })` – not Python `strands`. Authored tools via `guardTool` (plain `ArcjetDenialResult`; `FunctionTool` wraps it in a `JsonBlock`). Unwrapped / MCP / vended via `guardHooks` (a Plugin on `Agent({ plugins })`). Gate is `BeforeToolCallEvent.cancel` (string = JSON of `ArcjetDenialResult`). Do not use `BeforeToolsEvent.cancel` (skips per-tool hooks). `event.interrupt()` is HITL, not a policy gate. No `guardInbound` — screen with `guard()` before `invoke()` / `stream()` and act on that decision: core `guard()` fails open, so ALLOW is not proof the rules ran. Correlation from caller-owned `invocationState.correlationId` → `sessionId` → `requestId` — never `traceId`, never `agent.id`, never `SessionManager`, never mint. Optional peer `@strands-agents/sdk` `>=1.1.0 <2`; no unversioned alias. Fail closed (`onGuardError: "deny"`). The existing `strands-agent` example stays with Runtime. Until-published — pin `@arcjet/guard` to git SHA `f3a07ee675cbdd812a36dcb778ee4325d2f89617` ([#6251](https://github.com/arcjet/arcjet-js/pull/6251)); not in npm 1.10.0. Details in the JS Guard reference; docs https://docs.arcjet.com/guards/strands-agents/. +- **Claude Agent SDK (Python):** load [integrate-arcjet-guard-claude-agent-sdk-py](../integrate-arcjet-guard-claude-agent-sdk-py/SKILL.md). Not the JS adapter and not Claude Managed Agents. +- **OpenAI Agents (Python):** load [integrate-arcjet-guard-openai-agents-py](../integrate-arcjet-guard-openai-agents-py/SKILL.md). Not the JS adapter. +- **Strands Agents (Python):** load [integrate-arcjet-guard-strands-agents-py](../integrate-arcjet-guard-strands-agents-py/SKILL.md). Not JS `@arcjet/guard/strands-agents/v1`. +- **LangChain (Python):** load [integrate-arcjet-guard-langchain-py](../integrate-arcjet-guard-langchain-py/SKILL.md). Not JS `createAgent` and not LangGraph JS. - **Branch on which rule denied**, not just on `DENY`. Use the per-rule accessors (for example `userLimit.deniedResult(decision)` for retry-after info) or the flat reason string (`decision.reason === "PROMPT_INJECTION"` in JS, `decision.reason == "PROMPT_INJECTION"` in Python) so the error you surface to the caller tells them _why_ – "rate limited, retry in 12s" vs "input flagged as prompt injection" – instead of a generic "blocked." Note: guard's `decision.reason` is a flat string literal, unlike the request-based SDK's tagged-helper API. It is `undefined` on ALLOW – typed `Reason | undefined` in JS – so read it after checking the conclusion, or a `strict` build rejects assigning it to a `string`. - **A denial by one rule still spends the others' budget.** Rules in a single `guard()` call are all evaluated, so a request that trips @@ -169,12 +192,13 @@ If you can't run the app in the current environment, tell the user exactly what - **Wrong SDK/client**: `@arcjet/guard`, `arcjet.guard`, and Go's `NewGuardClient` are for non-HTTP code. `@arcjet/node` / `@arcjet/next` / Python `arcjet()` / Go `NewClient` are for HTTP routes. Using the wrong one is the most common mistake. - **Wrong placement**: `protect()` must not be called in Express middleware or Next.js middleware. Call it inside each route handler. -- **Wrong layer for `guard()`**: don't put `guard()` in a `handleToolCall(name, args)` dispatcher – put it inside each specific tool / task function so the `label` and metadata can be hardcoded. In Python LangChain / `create_agent`, use the helper from the Python Guard reference instead of hand-wrapping. JS `createAgent` uses `@arcjet/guard/langchain/v1`, not `@arcjet/guard/langgraph/v1`. Official CrewAI uses `register_arcjet_hooks` (crew-executed tools) or `arcjet.guard.crewai.guard_tool` (a `BaseTool` you call yourself) – not a raw `guard()` in every tool, and not `guard_crew`. -- **Python capture handlers never block**: `ArcjetCaptureHandler` only records. Policy lives in `guard_action` / `guard_tool` / `ArcjetMiddleware`. CrewAI never registers `POST_TOOL_CALL`; the gate is `PRE_TOOL_CALL` + `HookAborted(reason=..., source="arcjet")`. +- **Wrong layer for `guard()`**: don't put `guard()` in a `handleToolCall(name, args)` dispatcher – put it inside each specific tool / task function so the `label` and metadata can be hardcoded. In Python, if the project already uses LangChain, CrewAI, OpenAI Agents, Claude Agent SDK, Claude Managed Agents, or Strands Agents, load the dedicated skill from Step 3 instead of hand-wrapping. JS `createAgent` uses `@arcjet/guard/langchain/v1`, not `@arcjet/guard/langgraph/v1`. Official JS Google ADK uses `@arcjet/guard/google-adk/v2` `guardPlugin` on the Runner (no `guardTool`) – not a raw `guard()` in every tool. Official JS Strands Agents uses `@arcjet/guard/strands-agents/v1`, not a raw `guard()` in every tool. Official TanStack AI uses `@arcjet/guard/tanstack-ai/v0` `guardMiddleware` on `chat({ middleware })`, not `guardTool` and not a raw `guard()` in every `execute`. JS OpenAI Agents stays on `@arcjet/guard/openai-agents/v0`. JS Claude Managed Agents uses `guardCustomTool` / `guardEvents` — not Claude Agent SDK `guardTool` / `guardHooks` / `PreToolUse`. +- **Python adapter denials and HITL:** load the dedicated Python skill. Shared rule: capture handlers never block; HITL (`human_input`, `can_use_tool`, `needs_approval`, `event.interrupt()`, `always_ask`) is not a policy gate. - **Python helper `success` is not "the action ran"**: `guard_action` / `guard_tool` / `ArcjetMiddleware` write `metadata.outcome`. `success` means the action ran and policy judged all of it. When `on_guard_error="allow"` lets an action run without a full judgement, that event is `degraded` (`degraded` + `decision_id` = judged in part; without an id = judged not at all). `error` wins over `degraded` – do not count a throwing action in a degraded tally. Default `"deny"` still blocks those cases and records `unavailable`. Filter `degraded` and `unavailable` for post-incident review. This is not a Decision field, conclusion, or new `on_guard_error` value. Do not teach CrewAI `register_arcjet_hooks` as recording `degraded` — a proceed there is still `success`. Details in the Python Guard reference. - **Hand-edited dependency manifests**: don't append `"arcjet": "^1.0.0"` to `package.json` or `arcjet>=1.0.0` to `requirements.txt`. Run the project's package manager so the version is real and the lockfile updates. - **Double-counting**: Calling `protect()` or `guard()` multiple times for the same operation counts against rate limits multiple times. -- **JS denial envelopes:** one shared `ArcjetDenialResult` payload (`{ arcjetDenied: true, … }`, wording `"Arcjet denied this call …"`). Delivery is per-framework – AI SDK / Mastra / OpenAI Agents return the object (a throw drops the fields); Genkit returns it as completed `toolResponse.output` (a throw drops the fields; `interrupt()` is HITL, not a denial); Claude wraps it in a MCP `CallToolResult` with `isError: true` (a throw is a raw exception; omitting `isError` looks like success); LangGraph Graph API returns the object so `ToolMessage.status` is `success` (do not fabricate `status: "error"`); LangChain JS `createAgent` `guardTool` returns a plain `ArcjetDenialResult` and `guardMiddleware` `wrapToolCall` short-circuit returns a real `ToolMessage` (JSON content, default status; a throw drops the fields; `humanInTheLoopMiddleware` is HITL); Eve `guardTool` still throws `ArcjetDeniedError` (opt in to a returned payload with `onDeny: "result"`). `guardTool` and `guardAction` are different handlers – envelope vs throw. Details in the JS Guard reference. +- **Client-IP warning bypass**: never "fix" an `unverified-header` warning by reading `X-Forwarded-For` yourself and passing it as `ipSrc`, `ip_src`, or `WithIPSrc`. That relabels attacker-controlled input as manual/trusted. Configure the real ingress and trusted proxies, then verify the SDK provenance API instead. +- **JS denial envelopes:** one shared `ArcjetDenialResult` payload (`{ arcjetDenied: true, … }`, wording `"Arcjet denied this call …"`). Delivery is per-framework – AI SDK / Mastra / OpenAI Agents return the object (a throw drops the fields); Genkit returns it as completed `toolResponse.output` (a throw drops the fields; `interrupt()` is HITL, not a denial); Claude Agent SDK wraps it in a MCP `CallToolResult` with `isError: true` (a throw is a raw exception; omitting `isError` looks like success); Claude Managed Agents posts `user.custom_tool_result` with `is_error: true` (a throw leaves the hosted session idle; omitting `is_error` looks like success; this is not Claude Agent SDK `structuredContent`); LangGraph Graph API returns the object so `ToolMessage.status` is `success` (do not fabricate `status: "error"`); LangChain JS `createAgent` `guardTool` returns a plain `ArcjetDenialResult` and `guardMiddleware` `wrapToolCall` short-circuit returns a real `ToolMessage` (JSON content, default status; a throw drops the fields; `humanInTheLoopMiddleware` is HITL); Google ADK `guardPlugin` returns the object as a `beforeToolCallback` deny dict (skips `runAsync`; `undefined` executes; a throw is a raw exception; `requireConfirmation` is HITL); TanStack AI `guardMiddleware` default DENY is `onBeforeToolCall` skip with `ArcjetDenialResult` (optional `onDeny: "abort"` stops the run on real DENY only — unavailable stays skip; an `execute` throw is swallowed; there is no `guardTool`; `needsApproval` / `defineInterrupt` / `onInterruptBoundary` is HITL); Strands Agents `guardTool` returns a plain `ArcjetDenialResult` and `guardHooks` sets `BeforeToolCallEvent.cancel` to the JSON string of the payload (`event.interrupt()` is HITL, not a denial; `cancel: true` and `BeforeToolsEvent.cancel` drop the fields); Eve `guardTool` still throws `ArcjetDeniedError` (opt in to a returned payload with `onDeny: "result"`). `guardTool` and `guardAction` are different handlers – envelope vs throw. Details in the JS Guard reference. - **Never hardcode `ARCJET_KEY`** – always use environment variables. ## Choose protections @@ -186,6 +210,7 @@ When you need to pick which rules address the user's concern – bot abuse, rate For exact API signatures, parameter names, and the full set of rules and helpers, read the installed SDK's source – types and docstrings are the source of truth: - **Python SDK**: https://github.com/arcjet/arcjet-py – `arcjet` package (request protection) and `arcjet.guard` subpackage (non-HTTP guard). +- **Python Guard integration skills**: [integrate-arcjet-guard-langchain-py](../integrate-arcjet-guard-langchain-py/SKILL.md), [integrate-arcjet-guard-crewai](../integrate-arcjet-guard-crewai/SKILL.md), [integrate-arcjet-guard-openai-agents-py](../integrate-arcjet-guard-openai-agents-py/SKILL.md), [integrate-arcjet-guard-claude-agent-sdk-py](../integrate-arcjet-guard-claude-agent-sdk-py/SKILL.md), [integrate-arcjet-guard-claude-managed-agents-py](../integrate-arcjet-guard-claude-managed-agents-py/SKILL.md), [integrate-arcjet-guard-strands-agents-py](../integrate-arcjet-guard-strands-agents-py/SKILL.md). - **JavaScript / TypeScript SDK**: https://github.com/arcjet/arcjet-js – monorepo with framework-specific packages (`@arcjet/next`, `@arcjet/node`, `@arcjet/fastify`, `@arcjet/sveltekit`, `@arcjet/guard`). - **Go SDK**: https://github.com/arcjet/arcjet-go – `github.com/arcjet/arcjet-go` module with request and guard clients. The published tag is `v0.1.0`; APIs described in the Go references live on the default branch. - **Docs**: https://docs.arcjet.com – narrative guides, blueprints, and product reference. diff --git a/plugins/arcjet/skills/arcjet/references/guards_javascript.md b/plugins/arcjet/skills/arcjet/references/guards_javascript.md index 2da7c30..74c86d7 100644 --- a/plugins/arcjet/skills/arcjet/references/guards_javascript.md +++ b/plugins/arcjet/skills/arcjet/references/guards_javascript.md @@ -38,13 +38,17 @@ The correct transport is picked automatically via conditional exports (HTTP/2 on Read the installed package's types and doc comments for the full API surface. -> _Runtime support last verified against the published `@arcjet/guard` **v1.10.0** on **August 11, 2026**. `moderateContent` (graduated name), `@arcjet/guard/mastra/v1`, `@arcjet/guard/langgraph/v1`, `@arcjet/guard/claude-agent-sdk/v0`, `@arcjet/guard/openai-agents/v0`, `@arcjet/guard/genkit/v1`, and `@arcjet/guard/langchain/v1` (JS `createAgent`) are on docs/`main` or until-published; they are not in 1.10.0 – importing one from 1.10.0 fails with `ERR_PACKAGE_PATH_NOT_EXPORTED`. Shared `ArcjetDenialResult` plus per-framework envelopes are on `main` ([#6240](https://github.com/arcjet/arcjet-js/pull/6240)). Read the installed package's types before using any of them. Minimums tend to creep upward – check the [Runtime support section](https://github.com/arcjet/arcjet-js/tree/main/arcjet-guard#runtime-support) of the README._ +> _Runtime support last verified against the published `@arcjet/guard` **v1.10.0** on **August 11, 2026**. `moderateContent` (graduated name), `@arcjet/guard/mastra/v1`, `@arcjet/guard/langgraph/v1`, `@arcjet/guard/claude-agent-sdk/v0`, `@arcjet/guard/claude-managed-agents/v0`, `@arcjet/guard/openai-agents/v0`, `@arcjet/guard/genkit/v1`, `@arcjet/guard/langchain/v1` (JS `createAgent`), `@arcjet/guard/google-adk/v2`, `@arcjet/guard/tanstack-ai/v0`, and `@arcjet/guard/strands-agents/v1` are on docs/`main` or until-published; they are not in 1.10.0 – importing one from 1.10.0 fails with `ERR_PACKAGE_PATH_NOT_EXPORTED`. Shared `ArcjetDenialResult` plus per-framework envelopes are on `main` ([#6240](https://github.com/arcjet/arcjet-js/pull/6240)). Read the installed package's types before using any of them. Minimums tend to creep upward – check the [Runtime support section](https://github.com/arcjet/arcjet-js/tree/main/arcjet-guard#runtime-support) of the README._ > > Teaching pins (not in npm 1.10.0): > > - OpenAI Agents: arcjet-js merge `0099fb76e9229fa0b5922f938f4f1ce2e1033ce1` ([#6233](https://github.com/arcjet/arcjet-js/pull/6233)) > - Genkit: arcjet-js merge `4e416787b5aad709476173f5daf6c30212710c37` ([#6243](https://github.com/arcjet/arcjet-js/pull/6243)) > - LangChain JS `createAgent`: arcjet-js merge `c49abcc1f9afce7d284b6c294d0dcee5916ada86` ([#6248](https://github.com/arcjet/arcjet-js/pull/6248)) +> - Google ADK: arcjet-js `41ef36816e7174f1b0288d28217e63fa14114307` +> - TanStack AI: arcjet-js merge `d730d57a124f03843f085d41f64b0355a09d1eab` ([#6260](https://github.com/arcjet/arcjet-js/pull/6260)) +> - Strands Agents: arcjet-js merge `f3a07ee675cbdd812a36dcb778ee4325d2f89617` ([#6251](https://github.com/arcjet/arcjet-js/pull/6251)) +> - Claude Managed Agents: arcjet-js `cb35c8f92c3a2fb63fbeb9b386d79b1878c19d92` (`david/cursor/claude-managed-agents-v0-3e87`) ## Architecture: why things go where they do @@ -266,18 +270,22 @@ For tests, `registerTestClient()` from `@arcjet/guard/testing` records calls and ## Framework integrations -Import the versioned path. Unversioned aliases (`@arcjet/guard/vercel-ai`, `/vercel-eve`, `/mastra`, `/langgraph`, `/langchain`, `/claude-agent-sdk`, `/openai-agents`, `/genkit`) do not resolve. Wrappers fail closed by default (`onGuardError: "deny"`). - -| Integration | Import | Use when | -| --------------------------- | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Vercel AI SDK v7 | `@arcjet/guard/vercel-ai/v7` | Authored `tool({ execute })`. `guardTool` + `aiToolsContext(createAgentContext(), tools)`. Also exports `guardAction`, `captureAction`, `securityMetadata`. Wrapped tools cannot already declare `contextSchema`. | -| Vercel Eve v0 | `@arcjet/guard/vercel-eve/v0` | Eve agents. Optional peer `eve` `>=0.34.0 <1` (still 0.x). Node ≥ 24. `guardInbound` on channels (only place to decline a turn before it starts) – its verdict carries `outcome` (`"DENY"` \| `"UNAVAILABLE"`, denial vs outage) and the rule category on `verdict.decision?.reason`; `verdict.reason` is a deprecated alias for `outcome`, so do not return it as the category. `guardApproval` on OpenAPI/MCP connections (no local `execute`): `approval` is a function or `{ request, response }`; do not compose with Eve `always()`/`once()`/`never()`. `onAllow: "user-approval"` parks for HITL; optional `response` authorizes the responder. Request/response form is on current docs/`main`, not published 1.10.0. `arcjetHooks` is observe-only. | -| Mastra v1 | `@arcjet/guard/mastra/v1` | On current docs/`main`, not published 1.10.0. Wrapping a `createTool()` result under `exactOptionalPropertyTypes` needs `main` – earlier builds constrained `guardTool` to `ToolAction`, which a real Mastra `Tool` cannot satisfy (TS2379). `guardProcessor` for inbound/outbound text (no `guardInbound`). `guardTool` for authored tools. `guardHooks` for unwrapped MCP/workspace tools (`beforeToolCall` can deny). No `guardApproval` – Mastra `requireApproval` is human HITL. Do not also wrap with `vercel-ai/v7`. | -| Claude Agent SDK v0 | `@arcjet/guard/claude-agent-sdk/v0` | On current docs/`main`, not published 1.10.0. `guardTool` for authored `tool()` + `createSdkMcpServer()`. `guardHooks` supplies `UserPromptSubmit` (the only place a turn can be declined before the model reads it) and `PreToolUse` (the only deny for built-ins and unwrapped MCP); `PostToolUse` is capture only. No `guardInbound`. `canUseTool` is **not** a policy gate – it is skipped by `allowedTools`, allow rules and `bypassPermissions`. `claudeAgentContext` reads `session_id` / `options.sessionId`. Optional peer `@anthropic-ai/claude-agent-sdk` `>=0.1.0 <1`. Node.js 22+. | -| LangGraph v1 | `@arcjet/guard/langgraph/v1` | On current docs/`main`, not published 1.10.0. Graph API (`StateGraph` + `ToolNode` from `@langchain/langgraph/prebuilt`), not LangChain `createAgent` / `wrapToolCall` (that is `@arcjet/guard/langchain/v1`, docs `/guards/langchain-js/`). Do not build on `createReactAgent`. `guardTool` for authored `tool()` / `StructuredTool`. `guardToolNode` for MCP / unwrapped tools. `langgraphAgentContext` reads `thread_id`. No `guardInbound` / `guardApproval` / `guardInterrupt` – `interrupt()` is human HITL. Optional peers `@langchain/langgraph` and `@langchain/core` `>=1 <2`. Node.js 22+. Do not also wrap with `vercel-ai/v7`. | -| LangChain JS createAgent v1 | `@arcjet/guard/langchain/v1` | Until-published (not in npm 1.10.0; pin `@arcjet/guard` to git SHA `c49abcc1`, [#6248](https://github.com/arcjet/arcjet-js/pull/6248)). JS `createAgent` + `createMiddleware({ wrapToolCall })`. Not LangGraph `StateGraph`/`ToolNode` (`/guards/langgraph/`). Not Python LangChain (`/guards/langchain/`). `guardTool` + `guardMiddleware` + `langchainContext` only. No unversioned `@arcjet/guard/langchain` alias. Optional peers `langchain` `>=1.2.0 <2` and `@langchain/core` `>=1 <2` — no `@langchain/langgraph` peer. `guardTool` returns a plain `ArcjetDenialResult`; `guardMiddleware` `wrapToolCall` short-circuit returns a real `ToolMessage` (JSON content, default status). Policy on `wrapToolCall` only. `wrapToolCall` only sees `runtime.configurable.thread_id` as of langchain 1.2.34. `humanInTheLoopMiddleware` is HITL. Node.js 22+. Do not also wrap with `langgraph/v1` or `vercel-ai/v7`. Docs https://docs.arcjet.com/guards/langchain-js/. | -| OpenAI Agents v0 | `@arcjet/guard/openai-agents/v0` | On `main` at merge `0099fb76` ([#6233](https://github.com/arcjet/arcjet-js/pull/6233)), not published 1.10.0. Text `Agent` + `run()` / `Runner` + authored `tool()`. Not Realtime, Sandbox, hosted, MCP, `asTool`, computer/shell. `guardTool` + `openaiAgentsContext` only. No `guardInbound` / `guardApproval` / `guardToolNode` / `guardHooks`. `needsApproval` is HITL. Optional peer `@openai/agents` `>=0.17.0 <1`. Node.js 22+. Do not also wrap with `vercel-ai/v7`. | -| Genkit v1 | `@arcjet/guard/genkit/v1` | On `main` at merge `4e416787` ([#6243](https://github.com/arcjet/arcjet-js/pull/6243)), not published 1.10.0. JS `genkit()` + `ai.defineTool` + `ai.generate` – not Go / Python Genkit. `guardTool` + `guardMiddleware` + `genkitContext` only. No `guardInbound` / `guardApproval` / `guardAction` / `createAgentContext` / `aiToolsContext`. `interrupt()` / `defineInterrupt` / `toolApproval` is HITL. `guardMiddleware` needs Genkit >= 1.33 (`tool` hook). Optional peer `genkit` `>=1.0.0 <2`. Node.js 22+. Do not also wrap with `vercel-ai/v7`. | +Import the versioned path. Unversioned aliases (`@arcjet/guard/vercel-ai`, `/vercel-eve`, `/mastra`, `/langgraph`, `/langchain`, `/claude-agent-sdk`, `/claude-managed-agents`, `/openai-agents`, `/genkit`, `/google-adk`, `/tanstack-ai`, `/strands-agents`) do not resolve. Wrappers fail closed by default (`onGuardError: "deny"`). + +| Integration | Import | Use when | +| --------------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Vercel AI SDK v7 | `@arcjet/guard/vercel-ai/v7` | Authored `tool({ execute })`. `guardTool` + `aiToolsContext(createAgentContext(), tools)`. Also exports `guardAction`, `captureAction`, `securityMetadata`. Wrapped tools cannot already declare `contextSchema`. | +| Vercel Eve v0 | `@arcjet/guard/vercel-eve/v0` | Eve agents. Optional peer `eve` `>=0.34.0 <1` (still 0.x). Node ≥ 24. `guardInbound` on channels (only place to decline a turn before it starts) – its verdict carries `outcome` (`"DENY"` \| `"UNAVAILABLE"`, denial vs outage) and the rule category on `verdict.decision?.reason`; `verdict.reason` is a deprecated alias for `outcome`, so do not return it as the category. `guardApproval` on OpenAPI/MCP connections (no local `execute`): `approval` is a function or `{ request, response }`; do not compose with Eve `always()`/`once()`/`never()`. `onAllow: "user-approval"` parks for HITL; optional `response` authorizes the responder. Request/response form is on current docs/`main`, not published 1.10.0. `arcjetHooks` is observe-only. | +| Mastra v1 | `@arcjet/guard/mastra/v1` | On current docs/`main`, not published 1.10.0. Wrapping a `createTool()` result under `exactOptionalPropertyTypes` needs `main` – earlier builds constrained `guardTool` to `ToolAction`, which a real Mastra `Tool` cannot satisfy (TS2379). `guardProcessor` for inbound/outbound text (no `guardInbound`). `guardTool` for authored tools. `guardHooks` for unwrapped MCP/workspace tools (`beforeToolCall` can deny). No `guardApproval` – Mastra `requireApproval` is human HITL. Do not also wrap with `vercel-ai/v7`. | +| Claude Agent SDK v0 | `@arcjet/guard/claude-agent-sdk/v0` | On current docs/`main`, not published 1.10.0. `guardTool` for authored `tool()` + `createSdkMcpServer()`. `guardHooks` supplies `UserPromptSubmit` (the only place a turn can be declined before the model reads it) and `PreToolUse` (the only deny for built-ins and unwrapped MCP); `PostToolUse` is capture only. No `guardInbound`. `canUseTool` is **not** a policy gate – it is skipped by `allowedTools`, allow rules and `bypassPermissions`. `claudeAgentContext` reads `session_id` / `options.sessionId`. Optional peer `@anthropic-ai/claude-agent-sdk` `>=0.1.0 <1`. Node.js 22+. | +| Claude Managed Agents v0 | `@arcjet/guard/claude-managed-agents/v0` | Until-published (not in npm 1.10.0; pin `@arcjet/guard` to git SHA `cb35c8f9`). Hosted harness via `@anthropic-ai/sdk` `>=0.86.0 <1` `client.beta.sessions` — not Claude Agent SDK local `query()` / `PreToolUse` (`@arcjet/guard/claude-agent-sdk/v0`). `guardCustomTool` + `guardEvents` + `claudeManagedAgentsContext` only. No unversioned `@arcjet/guard/claude-managed-agents` alias. Optional peer `@anthropic-ai/sdk` `>=0.86.0 <1` — not `@anthropic-ai/claude-agent-sdk`. Anthropic runs built-ins; default `always_allow` means no customer pre-exec for bash/files. Real gates: inbound `user.message` and custom tools on `agent.custom_tool_use`. `always_ask` + `user.tool_confirmation` is opt-in, not HITL-as-policy. MCP Guard only on servers you host (Anthropic is the MCP client). Never mint. Node.js 22+. Do not also wrap with `claude-agent-sdk/v0` or `vercel-ai/v7`. Docs https://docs.arcjet.com/guards/claude-managed-agents/. | +| LangGraph v1 | `@arcjet/guard/langgraph/v1` | On current docs/`main`, not published 1.10.0. Graph API (`StateGraph` + `ToolNode` from `@langchain/langgraph/prebuilt`), not LangChain `createAgent` / `wrapToolCall` (that is `@arcjet/guard/langchain/v1`, docs `/guards/langchain-js/`). Do not build on `createReactAgent`. `guardTool` for authored `tool()` / `StructuredTool`. `guardToolNode` for MCP / unwrapped tools. `langgraphAgentContext` reads `thread_id`. No `guardInbound` / `guardApproval` / `guardInterrupt` – `interrupt()` is human HITL. Optional peers `@langchain/langgraph` and `@langchain/core` `>=1 <2`. Node.js 22+. Do not also wrap with `vercel-ai/v7`. | +| LangChain JS createAgent v1 | `@arcjet/guard/langchain/v1` | Until-published (not in npm 1.10.0; pin `@arcjet/guard` to git SHA `c49abcc1`, [#6248](https://github.com/arcjet/arcjet-js/pull/6248)). JS `createAgent` + `createMiddleware({ wrapToolCall })`. Not LangGraph `StateGraph`/`ToolNode` (`/guards/langgraph/`). Not Python LangChain (`/guards/langchain/`). `guardTool` + `guardMiddleware` + `langchainContext` only. No unversioned `@arcjet/guard/langchain` alias. Optional peers `langchain` `>=1.2.0 <2` and `@langchain/core` `>=1 <2` — no `@langchain/langgraph` peer. `guardTool` returns a plain `ArcjetDenialResult`; `guardMiddleware` `wrapToolCall` short-circuit returns a real `ToolMessage` (JSON content, default status). Policy on `wrapToolCall` only. `wrapToolCall` only sees `runtime.configurable.thread_id` as of langchain 1.2.34. `humanInTheLoopMiddleware` is HITL. Node.js 22+. Do not also wrap with `langgraph/v1` or `vercel-ai/v7`. Docs https://docs.arcjet.com/guards/langchain-js/. | +| OpenAI Agents v0 | `@arcjet/guard/openai-agents/v0` | On `main` at merge `0099fb76` ([#6233](https://github.com/arcjet/arcjet-js/pull/6233)), not published 1.10.0. Text `Agent` + `run()` / `Runner` + authored `tool()`. Not Realtime, Sandbox, hosted, MCP, `asTool`, computer/shell. `guardTool` + `openaiAgentsContext` only. No `guardInbound` / `guardApproval` / `guardToolNode` / `guardHooks`. `needsApproval` is HITL. Optional peer `@openai/agents` `>=0.17.0 <1`. Node.js 22+. Do not also wrap with `vercel-ai/v7`. | +| Genkit v1 | `@arcjet/guard/genkit/v1` | On `main` at merge `4e416787` ([#6243](https://github.com/arcjet/arcjet-js/pull/6243)), not published 1.10.0. JS `genkit()` + `ai.defineTool` + `ai.generate` – not Go / Python Genkit. `guardTool` + `guardMiddleware` + `genkitContext` only. No `guardInbound` / `guardApproval` / `guardAction` / `createAgentContext` / `aiToolsContext`. `interrupt()` / `defineInterrupt` / `toolApproval` is HITL. `guardMiddleware` needs Genkit >= 1.33 (`tool` hook). Optional peer `genkit` `>=1.0.0 <2`. Node.js 22+. Do not also wrap with `vercel-ai/v7`. | +| TanStack AI v0 | `@arcjet/guard/tanstack-ai/v0` | Until-published (not in npm 1.11.0; pin `@arcjet/guard` to git SHA `d730d57a`, [#6260](https://github.com/arcjet/arcjet-js/pull/6260)). Official `@tanstack/ai` `chat({ middleware })` + authored `tool({ execute })`. Not Vercel AI SDK (`/guards/vercel-ai/`). Not TanStack Start HTTP `protect()`. Not TanStack's own `contentGuardMiddleware`. `guardMiddleware` + `tanstackAiContext` only — do not use `guardTool` (TanStack swallows an `execute` throw). No unversioned `@arcjet/guard/tanstack-ai` alias. Optional peer `@tanstack/ai` `>=0.8.0 <1`. Default DENY is `onBeforeToolCall` skip with `ArcjetDenialResult`. Optional `onDeny: "abort"` (real DENY only; unavailable stays skip). Put Arcjet first (`onBeforeToolCall` is first-win). Inbound `guard()` does not skip the middleware. `needsApproval` / `defineInterrupt` / `onInterruptBoundary` is HITL. No `guardInbound`. Correlation from caller-owned context id only — never mint, never `threadId` / `requestId` / `streamId` / `traceId`. The existing `tanstack-agent` example stays with Runtime. Node.js 22+. Do not also wrap with `vercel-ai/v7`. Docs https://docs.arcjet.com/guards/tanstack-ai/. | +| Strands Agents v1 | `@arcjet/guard/strands-agents/v1` | Until-published (not in npm 1.10.0; pin `@arcjet/guard` to git SHA `f3a07ee`, [#6251](https://github.com/arcjet/arcjet-js/pull/6251)). Official `@strands-agents/sdk` `Agent` + `invoke()` / `stream()` + authored `tool({ callback })`. Not Python `strands`. `guardTool` + `guardHooks` + `strandsAgentContext` only. No unversioned `@arcjet/guard/strands-agents` alias. Optional peer `@strands-agents/sdk` `>=1.1.0 <2`. `guardTool` returns a plain `ArcjetDenialResult`. `guardHooks` is a Plugin on `Agent({ plugins })`; gate is `BeforeToolCallEvent.cancel` (string = JSON of `ArcjetDenialResult`). Do not use `BeforeToolsEvent.cancel`. `event.interrupt()` is HITL. No `guardInbound`. Correlation from caller-owned `invocationState.correlationId` → `sessionId` → `requestId` — never `traceId`, never mint. The existing `strands-agent` example stays with Runtime. Node.js 22+. Do not also wrap with `vercel-ai/v7`. Docs https://docs.arcjet.com/guards/strands-agents/. | +| Google ADK v2 | `@arcjet/guard/google-adk/v2` | Until-published (not in npm 1.10.0; pin `@arcjet/guard` to git SHA `41ef3681`, [arcjet-js `41ef36816e7174f1b0288d28217e63fa14114307`](https://github.com/arcjet/arcjet-js/commit/41ef36816e7174f1b0288d28217e63fa14114307)). JS `@google/adk` `>=2 <3` `Runner` + `LlmAgent` + `FunctionTool`. `guardPlugin` + `googleAdkContext` only — **no `guardTool`**. No unversioned `@arcjet/guard/google-adk` alias. Optional peer `@google/adk` `>=2 <3`. Gate is Runner `BasePlugin.beforeToolCallback`: a deny dict (`ArcjetDenialResult`) skips `runAsync`; `undefined` executes. Fail closed = always return a deny dict on error, never `undefined`. Arcjet plugin first. HITL (`requireConfirmation` / `requestConfirmation`) is not a policy gate. Never mint, never `traceId`, never `invocationId` / session auto-ids. Example: [`examples/google-adk-agent`](https://github.com/arcjet/examples/tree/main/examples/google-adk-agent) (pins `41ef3681`; no verify fixture). Node.js 22+. Do not also wrap with `vercel-ai/v7`. Docs https://docs.arcjet.com/guards/google-adk/. | ### Denial responses @@ -295,15 +303,19 @@ const result: ArcjetDenialResult = { AI SDK wording is `"Arcjet denied this call …"` (no longer `"tool call"`). -| Adapter | Idiomatic envelope | Why not the others | -| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| AI SDK / Mastra | Return `{ arcjetDenied: true, … }` as the tool result | A throw becomes a generic tool error and drops the fields | -| OpenAI Agents | Return `{ arcjetDenied: true, … }` from `invoke` | A throw hits `errorFunction` or `ToolCallError` and can kill the run | -| Genkit | Return `{ arcjetDenied: true, … }` as completed `toolResponse.output` | A throw drops the fields. `interrupt()` / `ToolInterruptError` is HITL (`finishReason: "interrupted"`), not a denial | -| LangGraph | Return `{ arcjetDenied: true, … }`; `ToolNode` wraps it as a `ToolMessage` with `status: "success"` | Faking a `ToolMessage` to force `status: "error"` crashes the graph reducer | -| LangChain JS createAgent | `guardTool` returns a plain `ArcjetDenialResult` (`createAgent` `baseHandler` wraps it). `guardMiddleware` `wrapToolCall` short-circuit returns a real `ToolMessage` (JSON `content`, default status) | A throw drops the fields. A bare object from `wrapToolCall` crashes the reducer. `humanInTheLoopMiddleware` is HITL, not a denial. Distinct from LangGraph Graph API (`ToolNode` wraps a plain object) | -| Claude Agent SDK | MCP `CallToolResult` with `isError: true` and the payload on `structuredContent` | A throw is a raw exception; omitting `isError` looks like success | -| Vercel Eve | Throw `ArcjetDeniedError`. Opt in to a returned payload with `onDeny: "result"` | Eve projects a throw as a failed `action.result`. A silent return can violate `outputSchema` | +| Adapter | Idiomatic envelope | Why not the others | +| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| AI SDK / Mastra | Return `{ arcjetDenied: true, … }` as the tool result | A throw becomes a generic tool error and drops the fields | +| OpenAI Agents | Return `{ arcjetDenied: true, … }` from `invoke` | A throw hits `errorFunction` or `ToolCallError` and can kill the run | +| Genkit | Return `{ arcjetDenied: true, … }` as completed `toolResponse.output` | A throw drops the fields. `interrupt()` / `ToolInterruptError` is HITL (`finishReason: "interrupted"`), not a denial | +| Google ADK | Return `{ arcjetDenied: true, … }` from Runner `BasePlugin.beforeToolCallback` | A deny dict skips `runAsync`. `undefined` executes the tool. A throw is a raw exception. `requireConfirmation` / `requestConfirmation` is HITL, not a denial. There is no `guardTool` | +| LangGraph | Return `{ arcjetDenied: true, … }`; `ToolNode` wraps it as a `ToolMessage` with `status: "success"` | Faking a `ToolMessage` to force `status: "error"` crashes the graph reducer | +| LangChain JS createAgent | `guardTool` returns a plain `ArcjetDenialResult` (`createAgent` `baseHandler` wraps it). `guardMiddleware` `wrapToolCall` short-circuit returns a real `ToolMessage` (JSON `content`, default status) | A throw drops the fields. A bare object from `wrapToolCall` crashes the reducer. `humanInTheLoopMiddleware` is HITL, not a denial. Distinct from LangGraph Graph API (`ToolNode` wraps a plain object) | +| Claude Agent SDK | MCP `CallToolResult` with `isError: true` and the payload on `structuredContent` | A throw is a raw exception; omitting `isError` looks like success | +| Claude Managed Agents | `user.custom_tool_result` with `is_error: true` and denial text | A throw leaves the hosted session idle waiting for a result. Omitting `is_error` looks like success. Built-ins never hit this path. Distinct from Claude Agent SDK MCP `CallToolResult` / `structuredContent` | +| TanStack AI | `guardMiddleware` default DENY is `onBeforeToolCall` skip with `{ arcjetDenied: true, … }`. Optional `onDeny: "abort"` returns `{ type: "abort", reason }` (real DENY only; unavailable stays skip; no payload) | An `execute` throw is swallowed and drops the fields. There is no `guardTool`. `needsApproval` / `defineInterrupt` / `onInterruptBoundary` is HITL, not a denial. Distinct from TanStack's own `contentGuardMiddleware` and from Vercel AI SDK | +| Strands Agents | `guardTool` returns a plain `ArcjetDenialResult` (`FunctionTool` wraps it in a `JsonBlock`). `guardHooks` sets `BeforeToolCallEvent.cancel` to the JSON string of `{ arcjetDenied: true, … }` | `event.interrupt()` is HITL (`InterruptError`), not a denial. `cancel: true` uses a default message and drops the fields. `BeforeToolsEvent.cancel` skips per-tool hooks. A throw is a raw exception. Do not fabricate a `ToolResultBlock` | +| Vercel Eve | Throw `ArcjetDeniedError`. Opt in to a returned payload with `onDeny: "result"` | Eve projects a throw as a failed `action.result`. A silent return can violate `outputSchema` | `guardTool` and `guardAction` remain different handlers. A model-facing `onDeny` must return an envelope the model can inspect; `guardAction` throws `ArcjetDeniedError` so application code can `catch`. Sharing one callback would either leak a throw into the tool loop or swallow a policy denial as a successful action. @@ -367,6 +379,104 @@ for await (const message of query({ } ``` +### Claude Managed Agents + +Exports: `guardCustomTool`, `guardEvents`, `claudeManagedAgentsContext`. There is no `guardTool`, `guardHooks`, `guardInbound`, or unversioned `@arcjet/guard/claude-managed-agents` alias. This is the hosted Claude Managed Agents harness (`@anthropic-ai/sdk` `client.beta.sessions`). It is **not** Claude Agent SDK local `query()` / `PreToolUse` — that stays `@arcjet/guard/claude-agent-sdk/v0` (docs https://docs.arcjet.com/guards/claude-agent-sdk/). Optional peer `@anthropic-ai/sdk` `>=0.86.0 <1` — not `@anthropic-ai/claude-agent-sdk`. Until-published: published `@arcjet/guard@1.10.0` does not export `./claude-managed-agents/v0` (`ERR_PACKAGE_PATH_NOT_EXPORTED`). Pin `@arcjet/guard` to git SHA `cb35c8f92c3a2fb63fbeb9b386d79b1878c19d92`. Worked example: [`examples/claude-managed-agents`](https://github.com/arcjet/arcjet-js/tree/main/examples/claude-managed-agents) (vendors the same `cb35c8f9`). Read the installed package's types before wiring. Python is `arcjet.guard.claude_managed_agents` (shared docs https://docs.arcjet.com/guards/claude-managed-agents/; load [integrate-arcjet-guard-claude-managed-agents-py](../../integrate-arcjet-guard-claude-managed-agents-py/SKILL.md)). There is no `/guards/claude-managed-agents-py/` page. + +This is a hosted harness. Anthropic runs the agent loop and the built-in toolset (`bash`, files, web_*). The agent toolset defaults to `always_allow`, so there is **no customer pre-exec** for bash/files — `agent.tool_use` / `agent.tool_result` fire after the built-in already ran. There is no `PreToolUse`. Do not paper over that gap with `always_ask`. + +Three gotchas first: + +1. **The real gates are inbound `user.message` and custom tools on `agent.custom_tool_use`.** `guardEvents(arcjet, { events, inbound, context }, send)` screens `user.message` **before** `sessions.events.send` — the only place a turn can be declined before the hosted harness reads the prompt, so prompt-injection rules go here. `inbound.rules` receives `{ text, events }`, not `{ prompt }`. On DENY / fail-closed unavailability it returns `{ allowed: false, outcome, message }` and does **not** call `send`. `guardCustomTool` on the hosted path is `guardCustomTool(arcjet, { event, execute, send }, policy)` when the session emits `agent.custom_tool_use`. Built-ins never enter that handler. Self-hosted `EnvironmentWorker` / `betaTool({ run })` uses the wrap form `guardCustomTool(arcjet, betaTool, policy)`. The CLI worker cannot register custom tools. +2. **`always_ask` + `user.tool_confirmation` is opt-in confirmation, not HITL-as-policy.** Permission policies apply to the agent toolset and MCP, not custom tools. Same trap as Claude Agent SDK `canUseTool`, Mastra `requireApproval`, and LangGraph `interrupt()`. Do not treat a confirmation prompt as a Guard deny. +3. **MCP Guard only on servers you host.** Anthropic is the MCP client. You cannot intercept Anthropic-side MCP execution from this adapter. If you host the MCP server, put Guard on that server (core Guard / MCP patterns) — not `guardCustomTool`, and not Claude Agent SDK `PreToolUse`. + +- **`guardCustomTool`** (hosted) runs Guard before `execute`. On `DENY` (or unevaluated Guard under the default `onGuardError: "deny"`) `execute` does not run and `send` is invoked with a real `user.custom_tool_result` (`custom_tool_use_id`, denial text on `content`, **`is_error: true`**). On ALLOW the caller posts the success `user.custom_tool_result`. A throw leaves the hosted session idle waiting for a result; omitting `is_error` looks like success. This is not Claude Agent SDK `structuredContent`. +- **`guardEvents`** is permit-then-send. `send` is `(body) => client.beta.sessions.events.send(session.id, body)`. `@anthropic-ai/sdk` `>=0.86.0` takes the session id as the first positional argument on both `send` and `stream` (`stream(session.id)`); Python is `stream(session_id=...)`. There is no wrapper that returns `{ send }`. Events that are not `user.message` pass through without an inbound screen. Inbound `"allow"` is a legitimate `onGuardError` choice because failing closed stops the agent answering. `agent.tool_use` is observe-only — the built-in already ran. +- **`claudeManagedAgentsContext`** reads a **caller-owned** `correlationId` only. It never mints. It never reads Anthropic `session.id` / `sesn_…` / `sevt_…` / `id` / `traceId`. Do not `randomUUID()` a correlation id the way Claude Agent SDK `options.sessionId` requires, and do not pass Anthropic's session id as correlation. +- Fail closed by default (`onGuardError: "deny"`). Node.js 22+. Use `guardCustomTool` / `guardEvents` / `claudeManagedAgentsContext` only — not `guardTool`, `guardHooks`, `guardInbound`, `createAgentContext`, or `aiToolsContext`. Do not also wrap with `@arcjet/guard/claude-agent-sdk/v0` or `@arcjet/guard/vercel-ai/v7`. Docs: https://docs.arcjet.com/guards/claude-managed-agents/. Worked example: [`examples/claude-managed-agents`](https://github.com/arcjet/arcjet-js/tree/main/examples/claude-managed-agents). + +```typescript +import Anthropic from "@anthropic-ai/sdk"; +import { launchArcjet, detectPromptInjection, tokenBucket } from "@arcjet/guard"; +import { + claudeManagedAgentsContext, + guardCustomTool, + guardEvents, +} from "@arcjet/guard/claude-managed-agents/v0"; + +const client = new Anthropic(); +const arcjet = launchArcjet({ key: process.env.ARCJET_KEY! }); +const lookupLimit = tokenBucket({ + bucket: "lookups", + refillRate: 10, + intervalSeconds: 60, + maxTokens: 10, +}); +const inbound = detectPromptInjection(); +// The authenticated caller, so a budget cannot be reset by varying the order id. +const userId = authenticatedUserId; +// Caller-owned Sequence id — not Anthropic session.id / sevt_... +const conversationId = authenticatedConversationId; +const ctx = claudeManagedAgentsContext({ correlationId: conversationId }); + +const session = await client.beta.sessions.create({ + agent: agentId, + environment_id: environmentId, +}); + +// Screen user.message before the hosted harness reads it. DENY does not send. +const verdict = await guardEvents( + arcjet, + { + events: [{ type: "user.message", content: [{ type: "text", text: userText }] }], + inbound: { + action: "message.received", + rules: ({ text }) => [inbound(text)], + }, + context: ctx, + }, + (body) => client.beta.sessions.events.send(session.id, body), +); +if (!verdict.allowed) { + return verdict.message; +} + +for await (const event of client.beta.sessions.events.stream(session.id)) { + if (event.type === "agent.custom_tool_use" && event.name === "lookup_order") { + const gated = await guardCustomTool( + arcjet, + { + event, + execute: (input) => lookupOrder(input), + send: (result) => + client.beta.sessions.events.send(session.id, { events: [result] }), + }, + { + action: "order.looked-up", + rules: () => [lookupLimit({ key: userId, requested: 1 })], + context: ctx, + }, + ); + if (gated.allowed) { + await client.beta.sessions.events.send(session.id, { + events: [ + { + type: "user.custom_tool_result", + custom_tool_use_id: event.id, + content: [{ type: "text", text: JSON.stringify(gated.output) }], + }, + ], + }); + } + // DENY already posted user.custom_tool_result with is_error: true. Do not throw. + } + // agent.tool_use / always_ask + user.tool_confirmation are not this policy gate +} +``` + +Key rate limits on the authenticated caller, not a model-supplied order id. + ### Vercel Eve Exports: `guardTool`, `guardApproval`, `guardInbound`, `arcjetHooks`, `eveAgentContext`. Import `@arcjet/guard/vercel-eve/v0` – there is no unversioned alias and no `/v1`. Optional peer `eve` `>=0.34.0 <1`. Eve is still 0.x. Node.js ≥ 24. The request/response form is on current docs/`main`, not published 1.10.0. @@ -724,7 +834,262 @@ await ai.generate({ }); ``` -See https://docs.arcjet.com/guards/framework-integrations/, https://docs.arcjet.com/guards/claude-agent-sdk/, https://docs.arcjet.com/guards/vercel-eve/, https://docs.arcjet.com/guards/mastra/, https://docs.arcjet.com/guards/langgraph/, https://docs.arcjet.com/guards/langchain-js/, https://docs.arcjet.com/guards/langchain/, and https://docs.arcjet.com/guards/genkit/. +### Google ADK + +Exports: `guardPlugin`, `googleAdkContext`. There is no `guardTool`. There is no unversioned `@arcjet/guard/google-adk` alias. This is JS `@google/adk` `>=2 <3` `Runner` + `LlmAgent` + `FunctionTool`. Not Python / Go / Java ADK. Until-published: published `@arcjet/guard@1.10.0` does not export `./google-adk/v2` (`ERR_PACKAGE_PATH_NOT_EXPORTED`). Pin `@arcjet/guard` to git SHA `41ef36816e7174f1b0288d28217e63fa14114307`. Optional peer `@google/adk` `>=2 <3`. Example: [`examples/google-adk-agent`](https://github.com/arcjet/examples/tree/main/examples/google-adk-agent) (pins `41ef3681`; no verify fixture). + +Three gotchas first: + +1. **Screen inbound before `runner.runAsync`.** There is no `guardInbound`. Agent / model callbacks are not this policy gate. Call `arcjet.guard()` in the application and **act on the decision**. Core `guard()` fails open: `ALLOW` is not proof the rules ran. Gate on `decision.hasFailedOpen()` if this call site must fail closed; `guardPlugin` already defaults to that. +2. **HITL is not a policy gate.** `requireConfirmation` / `requestConfirmation` / `adk_request_confirmation` / confirmation resume is human-in-the-loop. Same trap as Mastra `requireApproval`, Claude `canUseTool`, LangGraph `interrupt()`, OpenAI Agents `needsApproval`, Genkit `interrupt()`, and LangChain `humanInTheLoopMiddleware`. There is no `guardApproval`. +3. **Deny inside `guardPlugin`'s `beforeToolCallback`.** There is no `guardTool`. The Runner plugin is the only gate: a returned deny dict skips `FunctionTool.runAsync`; `undefined` executes. Fail closed = always return a deny dict on error (do not return `undefined` and do not throw). Put the Arcjet plugin **first** on `Runner({ plugins })` so a deny short-circuits before later plugins run. Do not also wrap with `@arcjet/guard/vercel-ai/v7`. + +- **`guardPlugin`** returns a `BasePlugin` for `new Runner({ plugins })`. `beforeToolCallback` evaluates Guard and, on `DENY` or unevaluated Guard under the default `onGuardError: "deny"`, returns `{ arcjetDenied: true, … }` so `runAsync` never runs. Fail closed: always return that deny dict on error — never `undefined` (that executes the tool) and never throw (PluginManager treats a throw as a plugin error, not skip). On ALLOW it returns `undefined`. Do not invent a `guardTool` wrap around `FunctionTool`. +- **`googleAdkContext`** preference: caller-owned `correlationId` → `sessionId` → `conversationId`, then envelope copies. It never mints an id. It never reads `traceId`. It never reads `invocationId` (ADK always generates it). It never reads `toolContext.sessionId` / `session.id` (session auto-ids). Do not call `createAgentContext` inside a plugin / tool callback. Put the same id on `runner.runAsync({ sessionId })` _and_ on `guardPlugin({ sessionId })`. +- Fail closed by default (`onGuardError: "deny"`). Optional peer `@google/adk` `>=2 <3`. Node.js 22+. Use `guardPlugin` / `googleAdkContext` only — not `guardTool`, `guardInbound`, `guardApproval`, `guardMiddleware`, `guardHooks`, `createAgentContext`, or `aiToolsContext`. Docs: https://docs.arcjet.com/guards/google-adk/. + +```typescript +import { launchArcjet, detectPromptInjection, tokenBucket } from "@arcjet/guard"; +import { guardPlugin, googleAdkContext } from "@arcjet/guard/google-adk/v2"; +import { FunctionTool, InMemorySessionService, LlmAgent, Runner } from "@google/adk"; + +const arcjet = launchArcjet({ key: process.env.ARCJET_KEY! }); +const lookupLimit = tokenBucket({ + bucket: "lookups", + refillRate: 10, + intervalSeconds: 60, + maxTokens: 10, +}); +// The authenticated caller, so a budget cannot be reset by varying the order id. +const userId = authenticatedUserId; + +const lookupOrder = new FunctionTool({ + name: "lookup_order", + description: "Look up an order by ID", + // requireConfirmation is HITL — not this policy gate + execute: async (input) => ({ input, status: "shipped" }), +}); + +const agent = new LlmAgent({ + name: "support_agent", + description: "Help the user.", + instruction: "Help the user.", + tools: [lookupOrder], +}); + +const sessionService = new InMemorySessionService(); +const runner = new Runner({ + appName: "support", + agent, + sessionService, + // Arcjet first: a deny dict skips runAsync before later plugins run. + plugins: [ + guardPlugin(arcjet, { + action: ({ toolName }) => `${toolName}.invoked`, + // Keyed on the authenticated caller, not the model-supplied order id. + rules: () => [lookupLimit({ key: userId, requested: 1 })], + sessionId: conversationId, + }), + ], +}); + +const appContext = { sessionId: conversationId }; +const inbound = detectPromptInjection(); +const decision = await arcjet.guard({ + label: "message.received", + rules: [inbound(userText)], + ...googleAdkContext({ context: appContext }), +}); +if (decision.conclusion === "DENY") { + throw new Error("message blocked"); +} +// `guard()` fails open, so an ALLOW is not proof the rules ran. Gate +// on `hasFailedOpen()` when this inbound site must fail closed. Omitting +// that gate is legitimate if an outage must not stop the agent. +if (decision.hasFailedOpen()) { + throw new Error("inbound guard unavailable"); +} + +for await (const event of runner.runAsync({ + userId, + sessionId: conversationId, + newMessage: { parts: [{ text: userText }] }, +})) { + void event; +} +``` + +See https://docs.arcjet.com/guards/framework-integrations/, https://docs.arcjet.com/guards/claude-agent-sdk/, https://docs.arcjet.com/guards/claude-managed-agents/, https://docs.arcjet.com/guards/vercel-eve/, https://docs.arcjet.com/guards/mastra/, https://docs.arcjet.com/guards/langgraph/, https://docs.arcjet.com/guards/langchain-js/, https://docs.arcjet.com/guards/langchain/, https://docs.arcjet.com/guards/genkit/, https://docs.arcjet.com/guards/google-adk/, https://docs.arcjet.com/guards/tanstack-ai/, and https://docs.arcjet.com/guards/strands-agents/. + +### TanStack AI + +Exports: `guardMiddleware`, `tanstackAiContext`. There is no `guardTool`. There is no unversioned `@arcjet/guard/tanstack-ai` alias. This is official `@tanstack/ai` `chat({ middleware })` + authored `tool({ execute })`. It is not Vercel AI SDK (`ai` / `@arcjet/guard/vercel-ai/v7`, docs https://docs.arcjet.com/guards/vercel-ai/). It is not TanStack Start HTTP `protect()`. It is not TanStack's own `contentGuardMiddleware` (`@tanstack/ai/middlewares` stream redaction). Until-published: published `@arcjet/guard@1.11.0` does not export `./tanstack-ai/v0` (`ERR_PACKAGE_PATH_NOT_EXPORTED`). Pin `@arcjet/guard` to git SHA `d730d57a124f03843f085d41f64b0355a09d1eab` ([#6260](https://github.com/arcjet/arcjet-js/pull/6260)). Optional peer `@tanstack/ai` `>=0.8.0 <1`. The existing `tanstack-agent` example stays with Runtime. Once it ships, the packaged skill is `node_modules/@arcjet/guard/skills/integrate-arcjet-guard-tanstack-ai`. + +Three gotchas first: + +1. **Screen inbound before `chat()`.** There is no `guardInbound`. Middleware `onConfig` / `onChunk` and TanStack's own `contentGuardMiddleware` intercept config or streamed text, not user text as a policy gate. Call `arcjet.guard()` in the application and **act on the decision**. Core `guard()` fails open: `ALLOW` is not proof the rules ran. Gate on `decision.hasFailedOpen()` if this call site must fail closed; `guardMiddleware` already defaults to that. +2. **`needsApproval` / `defineInterrupt` / `onInterruptBoundary` is not a policy gate.** `needsApproval` / `defineInterrupt` / `onInterruptBoundary` / `onInterruptResolution` is human-in-the-loop. After a human yes, Guard still runs on the tool call. Same trap as Mastra `requireApproval`, Claude `canUseTool`, LangGraph `interrupt()`, OpenAI Agents `needsApproval`, Genkit `interrupt()`, and LangChain `humanInTheLoopMiddleware`. There is no `guardApproval` / `guardInterrupt`. +3. **Deny inside `guardMiddleware`'s `onBeforeToolCall`.** Policy sits on `chat({ middleware })` only — do not wrap `tool({ execute })` with `guardTool` and do not throw from `execute` (TanStack swallows an `execute` throw into `{ error }` and drops the fields). Default DENY is `{ type: "skip", result: ArcjetDenialResult }` so the tool never runs and the model sees the payload. Optional `onDeny: "abort"` returns `{ type: "abort", reason }` and stops the chat run — the model does not get `ArcjetDenialResult`. `onDeny: "abort"` applies to real DENY only; unavailable stays skip. Put Arcjet first in the `middleware` array (`onBeforeToolCall` is first-win; if `toolCacheMiddleware` skips first, Guard never runs). Inbound `guard()` before `chat()` does not brand tools and does not skip this gate (brand-skip is only a sibling `guardTool` stamp; this namespace has no `guardTool`). Do not also wrap with `@arcjet/guard/vercel-ai/v7`. + +- **`guardMiddleware`** is `ChatMiddleware` for `chat({ middleware })`. Default DENY is an `onBeforeToolCall` skip with the shared `ArcjetDenialResult` without calling `execute`. Optional `onDeny: "abort"` stops the run on real DENY only (unavailable stays skip). Do not throw. Do not emit an interrupt to deny. +- **`tanstackAiContext`** preference: caller-owned `context.correlationId` → `sessionId` → `conversationId`, then `init.sessionId` / `init.correlationId`. It never mints an id. It never reads `threadId`, `requestId`, `streamId`, or `traceId` (TanStack mints those). It never reads `runId`. A bare object that also has string `requestId` and `streamId` looks like TanStack's middleware envelope, so top-level `sessionId` on that object is ignored — pass `{ context: appContext }`. Do not call `createAgentContext` inside a `chat()` / middleware / tool callback. Put the same caller-owned id on `chat({ context })` _and_ on `guardMiddleware({ sessionId })`. +- Fail closed by default (`onGuardError: "deny"`). Optional peer `@tanstack/ai` `>=0.8.0 <1`. Zod is theirs, not ours. Node.js 22+. Do not also wrap with `@arcjet/guard/vercel-ai/v7`. Use `guardMiddleware` / `tanstackAiContext` only — not `guardTool`, `guardInbound`, `guardApproval`, `guardToolNode`, `guardHooks`, `createAgentContext`, or `aiToolsContext`. Docs: https://docs.arcjet.com/guards/tanstack-ai/. + +```typescript +import { launchArcjet, detectPromptInjection, tokenBucket } from "@arcjet/guard"; +import { guardMiddleware, tanstackAiContext } from "@arcjet/guard/tanstack-ai/v0"; +import { chat, tool } from "@tanstack/ai"; +import { z } from "zod"; + +const arcjet = launchArcjet({ key: process.env.ARCJET_KEY! }); +const lookupLimit = tokenBucket({ + bucket: "lookups", + refillRate: 10, + intervalSeconds: 60, + maxTokens: 10, +}); +// The authenticated caller, so a budget cannot be reset by varying the order id. +const userId = authenticatedUserId; + +const lookupOrder = tool({ + name: "lookup_order", + description: "Look up an order by ID", + inputSchema: z.object({ + orderId: z.string(), + }), + execute: async ({ orderId }) => ({ orderId, status: "shipped" }), +}); + +const appContext = { sessionId: conversationId }; +const inbound = detectPromptInjection(); +const decision = await arcjet.guard({ + label: "message.received", + rules: [inbound(userText)], + ...tanstackAiContext({ context: appContext }), +}); +if (decision.conclusion === "DENY") { + throw new Error("message blocked"); +} +// `guard()` fails open, so an ALLOW is not proof the rules ran. Gate +// on `hasFailedOpen()` when this inbound site must fail closed. Omitting +// that gate is legitimate if an outage must not stop the agent. +if (decision.hasFailedOpen()) { + throw new Error("inbound guard unavailable"); +} + +const stream = chat({ + adapter, + messages: [{ role: "user", content: userText }], + tools: [lookupOrder], + context: appContext, + // Put Arcjet first: onBeforeToolCall is first-win. + // Default DENY is skip with ArcjetDenialResult. + // Optional onDeny: "abort" is real DENY only; unavailable stays skip. + // Do not wrap execute — TanStack swallows an execute throw. + middleware: [ + guardMiddleware(arcjet, { + action: ({ toolName }) => `${toolName}.invoked`, + // Keyed on the authenticated caller, not the model-supplied order id. + rules: () => [lookupLimit({ key: userId, requested: 1 })], + sessionId: conversationId, + }), + ], +}); +void stream; +``` + +See https://docs.arcjet.com/guards/framework-integrations/, https://docs.arcjet.com/guards/claude-agent-sdk/, https://docs.arcjet.com/guards/vercel-eve/, https://docs.arcjet.com/guards/mastra/, https://docs.arcjet.com/guards/langgraph/, https://docs.arcjet.com/guards/langchain-js/, https://docs.arcjet.com/guards/langchain/, https://docs.arcjet.com/guards/genkit/, and https://docs.arcjet.com/guards/tanstack-ai/. + +### Strands Agents + +Exports: `guardTool`, `guardHooks`, `strandsAgentContext`. There is no unversioned `@arcjet/guard/strands-agents` alias. This is official `@strands-agents/sdk` `Agent` + `invoke()` / `stream()` + authored `tool({ callback })` + Plugin / `addHook`. It is not Python `strands`. Until-published: published `@arcjet/guard@1.10.0` does not export `./strands-agents/v1` (`ERR_PACKAGE_PATH_NOT_EXPORTED`). Pin `@arcjet/guard` to git SHA `f3a07ee675cbdd812a36dcb778ee4325d2f89617` ([#6251](https://github.com/arcjet/arcjet-js/pull/6251)). Optional peer `@strands-agents/sdk` `>=1.1.0 <2`. The existing `strands-agent` example stays with Runtime. Once it ships, the packaged skill is `node_modules/@arcjet/guard/skills/integrate-arcjet-guard-strands-agents`. + +Three gotchas first: + +1. **Screen inbound before `invoke()` / `stream()`.** There is no `guardInbound`. Middleware / model hooks are not this policy gate. Call `arcjet.guard()` in the application and **act on the decision**. Core `guard()` fails open: `ALLOW` is not proof the rules ran. Gate on `decision.hasFailedOpen()` if this call site must fail closed; `guardTool` / `guardHooks` already default to that. +2. **`event.interrupt()` is not a policy gate.** `BeforeToolCallEvent.interrupt()` / `InterruptError` / resume is human-in-the-loop. Same trap as Mastra `requireApproval`, Claude `canUseTool`, LangGraph `interrupt()`, OpenAI Agents `needsApproval`, Genkit `interrupt()`, and LangChain `humanInTheLoopMiddleware`. There is no `guardApproval` / `guardInterrupt`. +3. **Deny inside `guardTool` and `guardHooks`' `BeforeToolCallEvent.cancel`.** Authored `tool({ callback })` is a `FunctionTool` / `ZodTool` whose runner path is `_callback`. MCP / vended / unwrapped tools skip `guardTool`. `guardHooks` is the Plugin / invoke-wide gate for those: set `event.cancel` to the JSON string of `ArcjetDenialResult` (`HookOrder.SDK_FIRST - 1`). Do not call `event.interrupt()` to deny. Do not set `cancel: true` (that drops the fields). Do not use `BeforeToolsEvent.cancel` (that skips per-tool hooks). Do not also wrap with `@arcjet/guard/vercel-ai/v7` or `@arcjet/guard/langgraph/v1`. + +- **`guardTool`** wraps an authored `tool({ callback })` so the closed-over `_callback` (and ZodTool's `_functionTool._callback`) never runs on `DENY`. Denial is a plain `ArcjetDenialResult` — this helper does not throw and does not fabricate a `ToolResultBlock`. `FunctionTool` wraps the object in a `JsonBlock`. Prefer omitting `outputSchema` on guarded tools, or verify it accepts the denial shape. Register only the value this helper returns on `Agent({ tools })` — passing the original `tool()` alongside the wrap leaves the original `stream()` path unguarded. +- **`guardHooks`** is a Plugin for `new Agent({ plugins })`. `initAgent` registers `BeforeToolCallEvent` (deny) and `AfterToolCallEvent` (capture only). A `BeforeToolCallEvent` deny sets `event.cancel` to `JSON.stringify` of `ArcjetDenialResult` so `tool.stream()` does not run; `AfterToolCallEvent` still fires. Already-branded (`guardTool`) tools skip the hook guard so Guard is not called twice. `cancel: true` uses a default message and loses the payload. +- **`strandsAgentContext`** preference: caller-owned `invocationState.correlationId` → `sessionId` → `requestId`, then envelope copies, then `init.sessionId` / `init.correlationId`. It never mints an id. It never reads `traceId`. It never reads `agent.id`. It never calls `SessionManager`. Do not call `createAgentContext` inside an invoke / hook / tool callback. Put the same id on `invoke(..., { invocationState })` _and_ on `guardHooks({ sessionId })`. +- Fail closed by default (`onGuardError: "deny"`). Optional peer `@strands-agents/sdk` `>=1.1.0 <2`. Zod is theirs, not ours. Node.js 22+. Do not also wrap with `@arcjet/guard/vercel-ai/v7`. Use `guardTool` / `guardHooks` / `strandsAgentContext` only — not `guardInbound`, `guardApproval`, `guardMiddleware`, `guardToolNode`, `createAgentContext`, or `aiToolsContext`. Docs: https://docs.arcjet.com/guards/strands-agents/. + +```typescript +import { launchArcjet, detectPromptInjection, tokenBucket } from "@arcjet/guard"; +import { guardTool, guardHooks, strandsAgentContext } from "@arcjet/guard/strands-agents/v1"; +import { Agent, tool } from "@strands-agents/sdk"; +import { z } from "zod"; + +const arcjet = launchArcjet({ key: process.env.ARCJET_KEY! }); +const lookupLimit = tokenBucket({ + bucket: "lookups", + refillRate: 10, + intervalSeconds: 60, + maxTokens: 10, +}); +const mcpLimit = tokenBucket({ + bucket: "mcp-access", + refillRate: 20, + intervalSeconds: 60, + maxTokens: 20, +}); +// The authenticated caller, so a budget cannot be reset by varying the order id. +const userId = authenticatedUserId; + +const lookupOrder = guardTool( + arcjet, + tool({ + name: "lookup_order", + description: "Look up an order by ID", + inputSchema: z.object({ + orderId: z.string(), + }), + callback: async ({ orderId }) => ({ orderId, status: "shipped" }), + }), + { + action: "order.looked-up", + // Keyed on the authenticated caller, not the model-supplied order id. + rules: () => [lookupLimit({ key: userId, requested: 1 })], + }, +); + +const invocationState = { sessionId: conversationId }; +const inbound = detectPromptInjection(); +const decision = await arcjet.guard({ + label: "message.received", + rules: [inbound(userText)], + ...strandsAgentContext({ invocationState }), +}); +if (decision.conclusion === "DENY") { + throw new Error("message blocked"); +} +// `guard()` fails open, so an ALLOW is not proof the rules ran. Gate +// on `hasFailedOpen()` when this inbound site must fail closed. Omitting +// that gate is legitimate if an outage must not stop the agent. +if (decision.hasFailedOpen()) { + throw new Error("inbound guard unavailable"); +} + +const mcpTools = []; // from an MCP client you did not wrap with guardTool +const agent = new Agent({ + tools: [lookupOrder, ...mcpTools], + // The agent-wide gate for the tools above that guardTool did not wrap. + // Already-branded tools are skipped, so Guard is not called twice. + // BeforeToolCallEvent.cancel is the JSON string of ArcjetDenialResult. + plugins: [ + guardHooks(arcjet, { + action: ({ toolName }) => `${toolName}.invoked`, + rules: ({ toolName }) => [mcpLimit({ key: toolName, requested: 1 })], + sessionId: conversationId, + }), + ], +}); + +await agent.invoke(userText, { invocationState }); +``` + +See https://docs.arcjet.com/guards/framework-integrations/, https://docs.arcjet.com/guards/claude-agent-sdk/, https://docs.arcjet.com/guards/vercel-eve/, https://docs.arcjet.com/guards/mastra/, https://docs.arcjet.com/guards/langgraph/, https://docs.arcjet.com/guards/langchain-js/, https://docs.arcjet.com/guards/langchain/, https://docs.arcjet.com/guards/genkit/, and https://docs.arcjet.com/guards/strands-agents/. ## Key patterns diff --git a/plugins/arcjet/skills/arcjet/references/guards_python.md b/plugins/arcjet/skills/arcjet/references/guards_python.md index 87506c3..9012434 100644 --- a/plugins/arcjet/skills/arcjet/references/guards_python.md +++ b/plugins/arcjet/skills/arcjet/references/guards_python.md @@ -16,13 +16,17 @@ ## What Guard is -Guard protects code paths that don't have an HTTP request – tool calls, agent loops, queue consumers, background jobs. It's part of the `arcjet` package (≥ 0.7.0) but uses a different entry point (`arcjet.guard`) from the HTTP request protection (`arcjet`). Features called out as 0.9.0 in the following sections still apply. Capture, registration, Rampart, nested metadata, and threat/billing are in **`arcjet` 0.10.0b1 / main**. `ModerateContent` (and the 2000 ms default request timeout for Guard; `protect()` matches on `main`) are on `main` only. There's no request object to inspect, so you pass explicit context (labels, keys, text to scan) at each call site. On `main`, prefer `guard_action` / `guard_tool` / `ArcjetMiddleware` when they fit – see [Framework helpers](#framework-helpers). Official CrewAI uses `arcjet.guard.crewai` (no extra; install CrewAI yourself). +Guard protects code paths that don't have an HTTP request – tool calls, agent loops, queue consumers, background jobs. It's part of the `arcjet` package (≥ 0.7.0) but uses a different entry point (`arcjet.guard`) from the HTTP request protection (`arcjet`). Features called out as 0.9.0 in the following sections still apply. Capture, registration, Rampart, nested metadata, and threat/billing are in **`arcjet` 0.10.0b1 / main**. `ModerateContent` (and the 2000 ms default request timeout for Guard; `protect()` matches on `main`) are on `main` only. There's no request object to inspect, so you pass explicit context (labels, keys, text to scan) at each call site. On `main`, prefer `guard_action` when it fits – see [Framework helpers](#framework-helpers). Official Python agent adapters (LangChain, CrewAI, OpenAI Agents, Claude Agent SDK, Claude Managed Agents, Strands Agents) live in dedicated skills so this file stays shared fundamentals. Do not copy adapter wiring from those skills back into this reference. **Version compatibility:** Python ≥ 3.10 (same as the request SDK – they're shipped together in the `arcjet` package). If the project's Python is older, warn the user and stop. Needs `libgcc` for the bundled WebAssembly runtime. Most Linux distributions include this by default, but Alpine Linux does not – run `apk add libgcc` first, otherwise `import arcjet` fails with `OSError: Error loading shared library libgcc_s.so.1`. -> _Published PyPI release last verified: `arcjet` **v0.9.0** on **June 30, 2026**. GitHub has a **v0.10.0b1** pre-release (**August 12, 2026**) that is **not on PyPI** – `pip install arcjet` still resolves 0.9.0. APIs newer than 0.9.0 live in 0.10.0b1 / main. `ModerateContent` (graduated name) and the 2000 ms default request timeout (Guard; `protect()` matches on `main`) are on `main`; 0.10.0b1 still exports `experimental_ModerateContent` (class exists but is not in `__all__`) and Guard still defaults to 1000 ms. `guard_action` / `guard_tool` / `ArcjetMiddleware` / `ArcjetCaptureHandler` are on `main` only ([arcjet-py#195](https://github.com/arcjet/arcjet-py/pull/195), [#196](https://github.com/arcjet/arcjet-py/pull/196)) – not in 0.9.0 or 0.10.0b1. `arcjet.guard.crewai` is until-published – PyPI `arcjet` 0.9.0 does not include the module, and there is no `arcjet[crewai]` extra (CrewAI pulls `chromadb`, CVE-2026-45829). Teaching is pinned to arcjet-py merge `b1253640ce676b948594beed5fe62450d0e1c77d` ([#224](https://github.com/arcjet/arcjet-py/pull/224)). Install CrewAI yourself (`pip install "crewai>=1.15.3,<2"`) and pin `arcjet` to that git SHA. Read the installed package's types before using either. Check `requires-python` in [`pyproject.toml`](https://github.com/arcjet/arcjet-py/blob/main/pyproject.toml)._ +> _Published PyPI release last verified: `arcjet` **v0.9.0** on **June 30, 2026**. GitHub has a **v0.10.0b1** pre-release (**August 12, 2026**) that is **not on PyPI** – `pip install arcjet` still resolves 0.9.0. APIs newer than 0.9.0 live in 0.10.0b1 / main. `ModerateContent` (graduated name) and the 2000 ms default request timeout (Guard; `protect()` matches on `main`) are on `main`; 0.10.0b1 still exports `experimental_ModerateContent` (class exists but is not in `__all__`) and Guard still defaults to 1000 ms. `guard_action` / `guard_tool` / `ArcjetMiddleware` / `ArcjetCaptureHandler` are on `main` only ([arcjet-py#195](https://github.com/arcjet/arcjet-py/pull/195), [#196](https://github.com/arcjet/arcjet-py/pull/196)) – not in 0.9.0 or 0.10.0b1._ +> +> Teaching pins for official Python agent adapters (not in PyPI 0.9.0) live in the dedicated skill for that adapter. See [Framework helpers](#framework-helpers). +> +> _Read the installed package's types before using any of them. Check `requires-python` in [`pyproject.toml`](https://github.com/arcjet/arcjet-py/blob/main/pyproject.toml)._ ## Installation @@ -32,7 +36,7 @@ Install with whichever package manager the project already uses (`pip install`, pip install arcjet ``` -Guard is included in the `arcjet` package – no separate install. LangChain helpers need an extra (`arcjet[langchain]` or `arcjet[langchain-agents]`). Official CrewAI uses `arcjet.guard.crewai` with no extra – install `crewai>=1.15.3,<2` yourself and pin `arcjet` to git SHA `b1253640ce676b948594beed5fe62450d0e1c77d` until that module is on PyPI. See [Framework helpers](#framework-helpers). Read the installed package's types and docstrings for the full API surface. +Guard is included in the `arcjet` package – no separate install. `guard_action` needs no extra. Official Python agent adapters install their own extras (or, for CrewAI, no extra — install CrewAI yourself). Load the dedicated skill for that adapter from [Framework helpers](#framework-helpers) instead of repeating install pins here. Read the installed package's types and docstrings for the full API surface. ## Architecture: why things go where they do @@ -214,7 +218,7 @@ On `arcjet` ≤ 0.8.0 the only signal is `decision.has_error()`, which is **depr ### Correlation IDs -Available from **`arcjet` 0.9.0**: pass `correlation_id` to `.guard()` to correlate a guard decision with a request, workflow run, or agent trace. It is a dedicated field, not metadata, and it does not affect the decision. On `main`, keep a whole run on one Sequence with `arcjet_sequence` or LangChain `config["configurable"]["arcjet_correlation_id"]` – see [Framework helpers](#framework-helpers). +Available from **`arcjet` 0.9.0**: pass `correlation_id` to `.guard()` to correlate a guard decision with a request, workflow run, or agent trace. It is a dedicated field, not metadata, and it does not affect the decision. On `main`, keep a whole run on one Sequence with `arcjet_sequence`. Framework adapters each have their own caller-owned reader — load the dedicated skill for that adapter. Never mint a new id per turn. ### Outbound HTTP proxy @@ -276,28 +280,29 @@ For tests, `from arcjet.guard.testing import register_test_client` and use `with ## Framework helpers -LangChain surfaces are on current `arcjet-py` **main** ([#195](https://github.com/arcjet/arcjet-py/pull/195), [#196](https://github.com/arcjet/arcjet-py/pull/196)). They are **not** in PyPI 0.9.0 or the 0.10.0b1 pre-release. CrewAI (`arcjet.guard.crewai`) is until-published – not in PyPI 0.9.0, and there is no `arcjet[crewai]` extra. Teaching is pinned to arcjet-py merge `b1253640` ([#224](https://github.com/arcjet/arcjet-py/pull/224)). Read the installed package before using either. +`guard_action` is core Guard — no extra. Official Python agent adapters +live in dedicated skills so this reference stays shared fundamentals. +Pick the helper that matches what you hold. Do not hand-wrap every tool +with raw `guard()`. -Pick the helper that matches what you hold. Do not hand-wrap every tool with raw `guard()`. +| You have | Use | Load | +| -------------------------------------------------------------- | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------- | +| Any Python callable (worker, MCP handler, job) | `guard_action` / `guard_action_sync` | this file | +| LangChain `BaseTool` / `create_agent` / capture | `arcjet.guard.langchain` | [integrate-arcjet-guard-langchain-py](../../integrate-arcjet-guard-langchain-py/SKILL.md) | +| Official CrewAI crew / LiteAgent / standalone `BaseTool` | `arcjet.guard.crewai` | [integrate-arcjet-guard-crewai](../../integrate-arcjet-guard-crewai/SKILL.md) | +| Official Python OpenAI Agents `FunctionTool` | `arcjet.guard.openai_agents` | [integrate-arcjet-guard-openai-agents-py](../../integrate-arcjet-guard-openai-agents-py/SKILL.md) | +| Official Python Claude Agent SDK `@tool` / unwrapped built-ins | `arcjet.guard.claude_agent_sdk` | [integrate-arcjet-guard-claude-agent-sdk-py](../../integrate-arcjet-guard-claude-agent-sdk-py/SKILL.md) | +| Claude Managed Agents custom tools / inbound events | `arcjet.guard.claude_managed_agents` | [integrate-arcjet-guard-claude-managed-agents-py](../../integrate-arcjet-guard-claude-managed-agents-py/SKILL.md) | +| Official Python Strands Agents `@tool` / Agent | `arcjet.guard.strands_agents` | [integrate-arcjet-guard-strands-agents-py](../../integrate-arcjet-guard-strands-agents-py/SKILL.md) | -| You have | Use | Extra | -| ------------------------------------------------------------ | -------------------------------------- | --------------------------------------------------------------------- | -| Any Python callable (worker, MCP handler, job) | `guard_action` / `guard_action_sync` | none (`arcjet.guard`) | -| A LangChain `BaseTool` you call yourself | `guard_tool` | `arcjet[langchain]` (`langchain-core>=1.2.5,<2`) | -| `create_agent` (the model chooses tools) | `ArcjetMiddleware` + `ToolPolicy` | `arcjet[langchain-agents]` (`langchain>=1.3,<2`, `langgraph>=1.2,<2`) | -| A chain or agent you want to observe | `ArcjetCaptureHandler` | `arcjet[langchain]` – cannot deny | -| Official CrewAI crew / LiteAgent / MCP or crew-injected tool | `register_arcjet_hooks` + `ToolPolicy` | no extra – install crewai yourself | -| A CrewAI `BaseTool` you call yourself | `guard_tool` (`arcjet.guard.crewai`) | same – only path that raises Arcjet errors | +Do not mix adapters. Importing one adapter module does not load another. +Python LangChain is not JS `createAgent` (docs https://docs.arcjet.com/guards/langchain-js/) and not LangGraph JS (docs https://docs.arcjet.com/guards/langgraph/). Python OpenAI Agents, Claude Agent SDK, Claude Managed Agents, and Strands Agents are not their JS `@arcjet/guard/...` counterparts. There is no `guard_crew`. There is no `arcjet[crewai]` extra (CrewAI pulls `chromadb`, CVE-2026-45829). Teaching pins, extras, denial envelopes, and HITL traps live in the dedicated skill — do not restate them here. -`guard_action` is core Guard – no LangChain extra. Importing `arcjet.guard.langchain` never loads LangGraph; that happens only when you reference `ArcjetMiddleware` or `ToolPolicy`. Without the agents extra those names raise, naming `arcjet[langchain-agents]`. Importing `arcjet.guard.crewai` does not load LangChain. There is no `guard_crew`. Python LangChain is not JS `createAgent` (docs https://docs.arcjet.com/guards/langchain-js/) and not LangGraph JS (docs https://docs.arcjet.com/guards/langgraph/). CrewAI docs: https://docs.arcjet.com/guards/crewai/. +### Shared helper rules -### Gotchas - -- **Fail closed.** `guard_action`, LangChain `guard_tool`, `ArcjetMiddleware`, `register_arcjet_hooks`, and CrewAI `guard_tool` default to `on_guard_error="deny"` (same fail-closed default as [#196](https://github.com/arcjet/arcjet-py/pull/196)). Only `"allow"` fails open; any other value is refused. A `DENY` always blocks. Core `guard()` still fails open (`has_failed_open()`). `guard_action`, LangChain `guard_tool`, and `ArcjetMiddleware` write `metadata.outcome`: default deny records `unavailable`; `"allow"` records `degraded` when the action ran without a full judgement. `register_arcjet_hooks` is not that path — a proceed still records `success`. See [Helper capture outcomes](#helper-capture-outcomes). -- **Configure the tool before `guard_tool()`.** Narrow `args_schema`, set `handle_tool_error` / `callbacks` / `response_format` on the tool you still hold, then wrap. Changes on the guarded handle do not reach the call. -- **One Sequence per conversation.** Use `with arcjet_sequence(correlation_id=session.id):` or `config={"configurable": {"arcjet_correlation_id": session.id}}`. Do not mint a new id per turn. LangChain's `run_id` is not used. The config key wins over an enclosing `arcjet_sequence`; `configurable` is checked before `metadata`. CrewAI correlation is the same caller-owned `correlation_id` / `arcjet_sequence` — crew, task, and agent names are metadata, never minted into an id. -- **Capture handlers never block.** LangChain ignores what a callback returns. Policy lives in `guard_action` / `guard_tool` / `ArcjetMiddleware`. CrewAI never registers `POST_TOOL_CALL`; the decision is captured in `PRE_TOOL_CALL`, which raises `HookAborted(reason=..., source="arcjet")`. -- **`human_input` is not a policy gate.** CrewAI Agent/Task `human_input` / `request_human_input` is human-in-the-loop, not Guard. Same trap as JS `humanInTheLoopMiddleware` and LangGraph `interrupt()`. +- **Fail closed.** Framework wrappers default to `on_guard_error="deny"` (same fail-closed default as [#196](https://github.com/arcjet/arcjet-py/pull/196)). Only `"allow"` fails open; any other value is refused. A `DENY` always blocks. Core `guard()` still fails open (`has_failed_open()`). `guard_action`, LangChain `guard_tool`, and `ArcjetMiddleware` write `metadata.outcome` — see [Helper capture outcomes](#helper-capture-outcomes). CrewAI `register_arcjet_hooks` is not that path — a proceed still records `success`. +- **One Sequence per conversation.** Use `with arcjet_sequence(correlation_id=session.id):` for core Guard. Framework adapters each have a caller-owned reader in their skill. Do not mint a new id per turn. +- **HITL is not a policy gate.** CrewAI `human_input`, Strands `event.interrupt()`, Claude Agent SDK `can_use_tool`, OpenAI Agents `needs_approval`, and Claude Managed Agents `always_ask` + `user.tool_confirmation` are human-in-the-loop. Same trap as JS `humanInTheLoopMiddleware` and LangGraph `interrupt()`. ### Any callable – `guard_action` @@ -324,173 +329,6 @@ result = await guard_action( `fn` takes no arguments – close over what you need. Sync code uses `guard_action_sync`. Raises `ArcjetDeniedError` on DENY, `ArcjetUnavailableError` when evaluation failed and `on_guard_error="deny"`. Guard `TokenBucket` takes `refill_rate` / `interval_seconds` / `max_tokens` (and optional `label` / `bucket`); that is not the request helper `token_bucket` (`interval` / `capacity`). -### LangChain tool you call – `guard_tool` - -```python -from arcjet.guard.langchain import guard_tool - -send_email.args_schema = PublicEmailArgs # narrow first, then wrap -guarded = guard_tool( - guard=aj, - tool=send_email, - action="email.sent", - rules=[email_limit(key=user_id, requested=1)], - on_guard_error="deny", -) -``` - -Needs `pip install "arcjet[langchain]"`. The result is still a `BaseTool`. DENY raises `ArcjetToolDeniedError` (the tool's `handle_tool_error` may convert it); unavailable raises `ArcjetToolUnavailableError`. - -### `create_agent` – `ArcjetMiddleware` - -```python -from langchain.agents import create_agent -from arcjet.guard.langchain import ArcjetMiddleware, ToolPolicy - -tools = [send_email, search_orders] -agent = create_agent( - model="openai:gpt-4o", - tools=tools, - middleware=[ - ArcjetMiddleware( - guard=aj, - policies={ - "send_email": ToolPolicy( - action="email.sent", - rules=[email_limit(key=user_id, requested=1)], - ), - }, - tools=tools, - on_guard_error="deny", - ) - ], -) - -await agent.ainvoke( - {"messages": [...]}, - config={"configurable": {"arcjet_correlation_id": session.id}}, -) -# equivalently: with arcjet_sequence(correlation_id=session.id): ... -``` - -Needs `pip install "arcjet[langchain-agents]"`. Pass `tools=` the same sequence you gave `create_agent` – a typo in a policy key is refused at construction instead of leaving that tool unguarded. Tools without a policy pass through. `guard=` is optional if you already `register_arcjet()`. - -If you can name the tool at wiring time, `guard_tool` is the smaller change. If the model picks the tool, use the middleware. They compose: a guarded tool inside a guarded agent evaluates each policy once and both land on the same Sequence. - -This is Python `create_agent` (docs https://docs.arcjet.com/guards/langchain/). It is not JS `createAgent` / `wrapToolCall` (docs https://docs.arcjet.com/guards/langchain-js/) and not LangGraph JS `StateGraph` / `ToolNode` (docs https://docs.arcjet.com/guards/langgraph/). - -### Observe a chain – `ArcjetCaptureHandler` - -```python -from arcjet.guard.langchain import ArcjetAsyncCaptureHandler, ArcjetCaptureHandler - -# invoke → ArcjetCaptureHandler; ainvoke → ArcjetAsyncCaptureHandler -chain.invoke(inputs, config={"callbacks": [ArcjetCaptureHandler(guard=aj)]}) -await chain.ainvoke( - inputs, config={"callbacks": [ArcjetAsyncCaptureHandler(guard=aj)]} -) -``` - -Same extra as `guard_tool`. Pair the handler with the call: `ArcjetCaptureHandler` with `invoke`, `ArcjetAsyncCaptureHandler` with `ainvoke`. Neither can deny a call. - -### CrewAI – `register_arcjet_hooks` - -Official `crewai` only – not community forks, not LangChain Crew wrappers. Import from `arcjet.guard.crewai`. There is **no** `arcjet[crewai]` extra: CrewAI hard-depends on `chromadb`, which carries unpatched RCE CVE-2026-45829, so an Arcjet extra must not pull it in. Install CrewAI yourself (`pip install "crewai>=1.15.3,<2"`). Until-published: PyPI `arcjet` 0.9.0 does not include this module. Pin `arcjet` to git SHA `b1253640ce676b948594beed5fe62450d0e1c77d` ([#224](https://github.com/arcjet/arcjet-py/pull/224)): - -```bash -pip install "arcjet @ git+https://github.com/arcjet/arcjet-py.git@b1253640ce676b948594beed5fe62450d0e1c77d" -pip install "crewai>=1.15.3,<2" -``` - -Exports: `register_arcjet_hooks`, `unregister_arcjet_hooks`, `ArcjetCrewAIHooks`, `guard_tool`, `ToolPolicy`, `sanitize_tool_name`, `free_text_arguments`. There is no `guard_crew`. - -Three gotchas first: - -1. **The gate is process-wide `PRE_TOOL_CALL`, once.** `register_arcjet_hooks` registers on CrewAI's dispatcher. Every tool a crew, LiteAgent, MCP adapter, or crew-injected list executes hits the hook. A `DENY` (or unevaluated Guard under the default `on_guard_error="deny"`) raises `HookAborted(reason=..., source="arcjet")` so the tool never runs. CrewAI swallows every other exception — raising `ArcjetDeniedError` / `ArcjetUnavailableError` from the hook would _run_ the tool. Same fail-closed default as [#196](https://github.com/arcjet/arcjet-py/pull/196): only `"allow"` fails open; a `DENY` always blocks. Core `guard()` still fails open (`has_failed_open()`). The hook path is **sync only** — pass `launch_arcjet_sync` / `ArcjetGuardSync`. An async client is refused at registration (`ArcjetMisconfiguration`). A second `register_arcjet_hooks` in the same process is also `ArcjetMisconfiguration` (CrewAI's registry appends and would double-evaluate); call `unregister()` on the handle first. -2. **`POST_TOOL_CALL` is never registered.** Only PRE is installed. The decision is captured in PRE. POST is not a policy surface and this module does not register it, so it cannot deny or rewrite a result. The agent always sees `Tool execution blocked by hook. Tool: {name}`. `HookAborted.reason` is telemetry only. -3. **`human_input` is not a policy gate.** Agent/Task `human_input` and `request_human_input` are human-in-the-loop. Same trap as JS `humanInTheLoopMiddleware`, LangGraph `interrupt()`, OpenAI Agents `needsApproval`, and Genkit `interrupt()`. There is no inbound helper and no approval helper. - -`ToolPolicy` is `action` + `rules`, keyed by tool name. Keys and the optional `tools=` filter go through `sanitize_tool_name` (CrewAI 1.15.3+): `Send Email` and `send_email` name the same tool. Tools without a matching policy still get `"{sanitized_tool_name}.invoked"` and the registrar-level `rules` (empty still contacts Guard) unless you pass `tools=`. `free_text_arguments` strips opaque ids (`tool_call_id`, `*_id`, …) when you want only free text for a scanning rule — the hook itself hands resolvers the tool's own argument mapping unfiltered. Screen inbound user text with core `aj.guard(...)` / `guard_sync` **before** `crew.kickoff`. Already-`guard_tool`-wrapped tools are skipped so Guard is not called twice. Tear down with `unregister_arcjet_hooks(hooks)` or `hooks.unregister()`. - -Do not hand-wrap every CrewAI tool with raw `guard()`. Use `register_arcjet_hooks` for crew-executed tools. Docs: https://docs.arcjet.com/guards/crewai/. - -```python -from crewai import Agent, Crew, Task -from arcjet.guard import DetectPromptInjection, TokenBucket, launch_arcjet_sync -from arcjet.guard.crewai import ToolPolicy, register_arcjet_hooks, unregister_arcjet_hooks - -aj = launch_arcjet_sync(key=os.environ["ARCJET_KEY"]) -lookup_limit = TokenBucket( - label="order.looked-up", - bucket="lookups", - refill_rate=10, - interval_seconds=60, - max_tokens=10, -) -inbound = DetectPromptInjection() -# The authenticated caller, so a budget cannot be reset by varying the order id. -user_id = authenticated_user_id - -hooks = register_arcjet_hooks( - guard=aj, - policies={ - "lookup_order": ToolPolicy( - action="order.looked-up", - rules=[lookup_limit(key=user_id, requested=1)], - ), - }, - tools=["lookup_order"], - on_guard_error="deny", -) - -agent = Agent( - role="Support", - goal="Look up orders", - backstory="Help the user with order status.", - # human_input=True is HITL — not this policy gate -) -task = Task( - description="Look up the user's order", - expected_output="Order status", - agent=agent, -) -crew = Crew(agents=[agent], tasks=[task]) - -decision = aj.guard( - label="message.received", - rules=[inbound(user_text)], -) -if decision.conclusion == "DENY": - raise RuntimeError("message blocked") -# `guard()` fails open, so an ALLOW is not proof the rules ran. Gate -# on `has_failed_open()` when this inbound site must fail closed. -if decision.has_failed_open(): - raise RuntimeError("inbound guard unavailable") - -crew.kickoff() -unregister_arcjet_hooks(hooks) -# equivalently: hooks.unregister() -``` - -Use `action` + `rules` only. Key rate limits on the authenticated caller, not a model-supplied order id. - -### CrewAI tool you call – `guard_tool` - -`BaseTool.run` never dispatches `PRE_TOOL_CALL`. This wrap is for a standalone CrewAI `BaseTool` you invoke yourself, and it is the **only** CrewAI path that raises `ArcjetDeniedError` / `ArcjetUnavailableError`. A sync call needs a blocking client; an async call needs an awaitable one. Hand the crew the copy this returns (it carries the brand the hook skips). The original stays unguarded on purpose — if you pass that to a crew, the hook still covers it. - -```python -from arcjet.guard.crewai import guard_tool - -guarded = guard_tool( - guard=aj, - tool=lookup_order, - action="order.looked-up", - rules=[lookup_limit(key=user_id, requested=1)], - on_guard_error="deny", -) -result = guarded.run(order_id=order_id) -``` - ## Key patterns - An empty `rules` list still calls `guard()` / the Decide API. `rules=[]` is a real decision, not a no-op skip. diff --git a/plugins/arcjet/skills/arcjet/references/requests_go.md b/plugins/arcjet/skills/arcjet/references/requests_go.md index 61b5cfb..fda04a3 100644 --- a/plugins/arcjet/skills/arcjet/references/requests_go.md +++ b/plugins/arcjet/skills/arcjet/references/requests_go.md @@ -82,7 +82,7 @@ For rule selection and rate-limiting strategy comparisons, see [Choose protectio ## Request context -Pass the real `*http.Request` and `r.Context()` so Arcjet respects cancellation and extracts IP/header metadata correctly. If the app is behind trusted reverse proxies, set `Config.Proxies` to the trusted proxy IPs/CIDRs. If the app runs on a known platform, set `Config.Platform` when appropriate. +Pass the real `*http.Request` and `r.Context()` so Arcjet respects cancellation and extracts IP/header metadata correctly. Outside a supported platform, the SDK first uses a public `RemoteAddr`. When the direct peer matches `Config.Proxies`, it walks `X-Forwarded-For` right-to-left. If `RemoteAddr` is missing or non-public, it may use a public address from a common forwarding header; that result has `Provenance == "unverified-header"`, `Verified == false`, and produces one warning for the lifetime of the SDK client. Set `Config.Proxies` to every trusted proxy IP/CIDR, make the app reachable only through them, and ensure they overwrite or safely append forwarding headers. Malformed entries are rejected; `0.0.0.0/0` and `::/0` emit a warning, while the exact addresses `0.0.0.0` and `::` do not. If the app runs on a known platform, set `Config.Platform` when appropriate. When the context has no deadline, `Protect` and `ProtectDetails` apply 2s (4s when an email rule is present). The prompt-injection 1s floor is already met. A caller-supplied deadline is never shortened. @@ -94,7 +94,14 @@ Pass `arcjet.WithCorrelationId(id)` to `Protect` to correlate this decision with ## Explicit client IP -If the application has already determined the client IP from a trusted source, pass `arcjet.WithIPSrc(ip)`. The SDK trusts the value without validating it – do not pass a client-controlled header. +If the application has already determined the client IP from a trusted source, pass `arcjet.WithIPSrc(ip)`. On current `main`, empty and malformed values are rejected. Syntax validation does not establish provenance – do not pass a client-controlled header. + +Before shipping, inspect representative staging requests with +`client.ClientIPDetails(request)`. Check `IP`, `Provenance`, `Verified`, and +`Header`; the same values are logged at debug level with +`client_ip_provenance`, `client_ip_verified`, and `client_ip_header`. The +diagnostic call does not log or consume the once-per-client warning. Never copy +`X-Forwarded-For` into `WithIPSrc` to bypass a warning. ## Metadata diff --git a/plugins/arcjet/skills/arcjet/references/requests_javascript.md b/plugins/arcjet/skills/arcjet/references/requests_javascript.md index 7ef72c4..c809fd4 100644 --- a/plugins/arcjet/skills/arcjet/references/requests_javascript.md +++ b/plugins/arcjet/skills/arcjet/references/requests_javascript.md @@ -293,7 +293,31 @@ const decision = await aj.protect(request, { }); ``` -The SDK trusts `ipSrc` without validating it. Do not pass a client-controlled header. Validate the value first. +On current `main`, non-empty malformed `ipSrc` values are rejected; an empty +`ipSrc` is treated as omitted. Syntax validation does not establish provenance: +do not pass `X-Forwarded-For` or another client-controlled header. The value +must come from an independently trusted framework or infrastructure source. + +### Client IP provenance and proxy configuration + +When a framework or platform does not expose a usable client IP, adapters may +fall back to common forwarding headers so protection can still run. A directly +connected client can spoof those headers unless trusted ingress overwrites or +safely appends them. The SDK logs the selection at debug level with +`client_ip_provenance`; an `unverified-header` source produces one warning for +the lifetime of each SDK client instance. + +Configure every trusted proxy IP/CIDR in `proxies`, or use a proxy-service +helper such as `cloudflare()`. Ensure the application is reachable only through +that ingress. Malformed entries are rejected; `0.0.0.0/0` and `::/0` warn +because they trust every peer. + +Before shipping, inspect real staging requests. `@arcjet/node` exposes +`aj.clientIpDetails(request)`. Other adapters can use `findIpDetails()` or +`resolveClientIp()` from `@arcjet/ip`. Check `ip`, `provenance`, `verified`, and +`header`. These diagnostics do not log or consume the once-per-client warning. +Never make an `unverified-header` warning disappear by copying the same header +into `ipSrc`; that relabels untrusted input as manual. ### Metadata diff --git a/plugins/arcjet/skills/arcjet/references/requests_python.md b/plugins/arcjet/skills/arcjet/references/requests_python.md index c774b69..e297460 100644 --- a/plugins/arcjet/skills/arcjet/references/requests_python.md +++ b/plugins/arcjet/skills/arcjet/references/requests_python.md @@ -188,7 +188,28 @@ aj = arcjet(key=os.environ["ARCJET_KEY"], rules=[...], disable_automatic_ip_dete decision = await aj.protect(request, ip_src=get_client_ip_from_trusted_source(request)) ``` -When automatic detection is disabled, omitting `ip_src` or passing `""` raises `ArcjetMisconfiguration`. Passing a non-empty `ip_src` while automatic detection is enabled also raises. The SDK trusts `ip_src` without validating it – do not pass a client-controlled header. This option cannot be combined with `proxies`. +When automatic detection is disabled, omitting `ip_src` or passing `""` raises `ArcjetMisconfiguration`. Passing a non-empty `ip_src` while automatic detection is enabled also raises. On current `main`, malformed IPv4/IPv6 values are rejected. Syntax validation does not establish provenance – do not pass a client-controlled header. This option cannot be combined with `proxies`. + +### Client IP provenance and proxy configuration + +When the framework does not expose a usable public client IP, automatic +detection may fall back to common forwarding headers so protection can still +run. A directly connected client can spoof those headers unless trusted ingress +overwrites or safely appends them. The SDK logs `client_ip_provenance` at debug +level and produces one warning for the lifetime of each SDK client instance +when the source is `unverified-header`. + +Configure every trusted proxy IP/CIDR in `proxies` and ensure the application is +reachable only through that ingress. Malformed entries are rejected; +`0.0.0.0/0` and `::/0` warn because they trust every peer. + +Before shipping, call `aj.client_ip_details(request)` on representative staging +requests and inspect `ip`, `provenance`, `verified`, and `header`. This method +does not log or consume the once-per-client warning. If and only if the client +was constructed with `disable_automatic_ip_detection=True`, pass the same +independently trusted `ip_src` explicitly to both `client_ip_details()` and +every `protect()` call. Never make an `unverified-header` warning disappear by +copying the same header into `ip_src`; that relabels untrusted input as manual. ### Metadata diff --git a/plugins/arcjet/skills/integrate-arcjet-guard-claude-agent-sdk-py/SKILL.md b/plugins/arcjet/skills/integrate-arcjet-guard-claude-agent-sdk-py/SKILL.md new file mode 100644 index 0000000..befe092 --- /dev/null +++ b/plugins/arcjet/skills/integrate-arcjet-guard-claude-agent-sdk-py/SKILL.md @@ -0,0 +1,228 @@ +--- +name: integrate-arcjet-guard-claude-agent-sdk-py +description: Integrate Arcjet Guard into the Python Claude Agent SDK — wrap authored @tool with guard_tool, and use guard_hooks for UserPromptSubmit inbound plus PreToolUse on unwrapped built-ins / MCP. Use when asked to add Arcjet to claude-agent-sdk, rate limit those tools, screen inbound prompts, or block prompt injection / PII. This is Python claude-agent-sdk, not the JS adapter and not Claude Managed Agents hosted sessions. +license: Apache-2.0 +compatibility: Requires Python >= 3.10 and official claude-agent-sdk>=0.2.127,<1 via arcjet[claude-agent-sdk] (safe extra, no chromadb). Until-published — pin arcjet to git SHA 9ea0b06a87bcee77b8df0664338c712c4668b87b; not in PyPI 0.9.0. +metadata: + author: arcjet + type: core + library: arcjet +--- + +# Integrate Arcjet Guard into the Python Claude Agent SDK + +`arcjet.guard.claude_agent_sdk` wraps the agent's existing Arcjet client. +It never talks to the Arcjet API itself. Shared Guard fundamentals +(client, rules, labels, decisions, capture, registration) live in +[../arcjet/references/guards_python.md](../arcjet/references/guards_python.md). +Load that reference for anything that is not Claude Agent SDK-specific. + +Official `claude-agent-sdk>=0.2.127,<1` only — not the JS +`@anthropic-ai/claude-agent-sdk` adapter +(`@arcjet/guard/claude-agent-sdk/v0`, docs +https://docs.arcjet.com/guards/claude-agent-sdk/), not Vercel AI, not +community forks, and not Claude Managed Agents hosted +`client.beta.sessions` (`arcjet.guard.claude_managed_agents`). Importing +`arcjet.guard.claude_agent_sdk` does not load LangChain, CrewAI, or the +JS adapter. The extra is safe (no chromadb). + +Exports: `guard_tool`, `guard_hooks`, `claude_agent_context`. Authored +`@tool` + `create_sdk_mcp_server` for tools you own; `guard_hooks` for +inbound `UserPromptSubmit` and unwrapped built-ins / MCP `PreToolUse`. +There is no inbound helper and no `guard_can_use_tool`. + +Three surfaces, one decision rule: + +- **An authored `@tool`** → `guard_tool`. Denial is JSON-in-content + + `is_error: True`. Python does **not** forward `structuredContent` + (that is the JS adapter). Do **not** raise. +- **Inbound text** → `guard_hooks` `UserPromptSubmit` (`decision: + "block"`). That is the only place a turn can be declined before the + model reads the prompt. +- **Built-ins / unwrapped MCP** → `guard_hooks` `PreToolUse` + (`permissionDecision: "deny"`). `PostToolUse` is capture only. +- **Correlation** → `claude_agent_context` reads a caller-owned UUID + `session_id`. It never mints. + +Docs: https://docs.arcjet.com/guards/claude-agent-sdk-py/. + +## Unwrapped tools deny on `PreToolUse` via `guard_hooks` + +Built-ins (Bash, Write, …) and MCP tools you did not pass through +`guard_tool` have no authored handler. `PreToolUse` is the only deny for +those. `PostToolUse` cannot undo a tool that already ran. List every +`guard_tool` wrapper in `exclude` or each authored tool is guarded twice +(two round trips, two quota units). Entries match the reported name: +pass `{"server": "support", "name": "lookup_order"}` for an authored MCP +tool (it resolves to `mcp__support__lookup_order`) and a bare string for +a built-in such as `"Bash"`. A bare authored name deliberately does not +match every server's tool of that name. + +## Authored `@tool` denial is JSON-in-content + `is_error: True` + +`guard_tool` wraps the `@tool` definition so the handler never runs on +`DENY` (or unevaluated Guard under the default `on_guard_error="deny"`). +The model receives the `ArcjetDenialResult` as JSON text on `content` +with `is_error: True`. Do **not** set `structuredContent` (JS only). Do +**not** raise: a throw is a raw exception; omitting `is_error` looks +like success. Same fail-closed default as +[#196](https://github.com/arcjet/arcjet-py/pull/196): only `"allow"` +fails open; a `DENY` always blocks. Core `guard()` still fails open. + +## `can_use_tool` is not a policy gate + +`can_use_tool` is human-in-the-loop. `allowed_tools`, allow rules, and +`bypassPermissions` / `acceptEdits` skip it. Same trap as CrewAI +`human_input`, JS `canUseTool`, LangGraph `interrupt()`, and OpenAI +Agents `needs_approval`. There is no inbound helper — screen prompt +injection on `guard_hooks` `UserPromptSubmit`. + +## Questions to ask the human first + +Ask only what you cannot infer from the code; suggest defaults. + +1. Which tools are **risky** (external side effects, irreversible, spends + money, sends messages)? Those get `guard_tool`. Built-ins and + unwrapped MCP get `guard_hooks` `PreToolUse`. +2. What **limits**? (e.g. "10 lookups/min per user" → `TokenBucket`.) +3. Who is the **user** for metadata — an opaque user/tenant ID (never PII)? + Default: none. Session id is the correlation id, not the user. The + Claude SDK requires `session_id` to be a UUID and allows a given id + to be created only once — later turns use `resume`. +4. Is an Arcjet outage unacceptable? Every helper defaults to + `on_guard_error="deny"`. Ask explicitly about inbound + `UserPromptSubmit`: failing closed there means the agent stops + answering, so `"allow"` is a routine and legitimate choice at that + one call site. + +## The things readers get wrong + +1. **This is not the JS adapter and not Claude Managed Agents.** Do not + import `@arcjet/guard/claude-agent-sdk/v0` or + `arcjet.guard.claude_managed_agents`. +2. **There is no inbound helper.** Screen on `UserPromptSubmit` via + `guard_hooks`. +3. **`can_use_tool` is HITL, not policy.** +4. **Python does not forward `structuredContent`.** Authored deny is + JSON-in-content + `is_error: True`. +5. **`session_id` is a UUID, and only once.** Later turns: `resume`. + `claude_agent_context` never mints. +6. **List every `guard_tool` wrapper in `exclude`.** Missing exclude + double-calls Guard. +7. **Key rate limits on the authenticated caller**, not a model-supplied + order id. Hand `query` / `create_sdk_mcp_server` the copy + `guard_tool` returns. +8. **Do not hand-wrap every tool with raw `guard()`.** + +## Step 1: Install and find the guard client + +Until-published: PyPI `arcjet` 0.9.0 does not include this module. Pin +`arcjet` to git SHA `9ea0b06a87bcee77b8df0664338c712c4668b87b`: + +```bash +pip install "arcjet[claude-agent-sdk] @ git+https://github.com/arcjet/arcjet-py.git@9ea0b06a87bcee77b8df0664338c712c4668b87b" +``` + +If the agent has no guard client yet, launch one **once at module scope**: + +```python +import os +from arcjet.guard import launch_arcjet + +aj = launch_arcjet(key=os.environ["ARCJET_KEY"]) +``` + +## Step 2: Gate authored tools — `guard_tool` + +```python +from claude_agent_sdk import ClaudeAgentOptions, create_sdk_mcp_server, query, tool +from arcjet.guard import DetectPromptInjection, TokenBucket, launch_arcjet +from arcjet.guard.claude_agent_sdk import guard_hooks, guard_tool + +aj = launch_arcjet(key=os.environ["ARCJET_KEY"]) +lookup_limit = TokenBucket( + label="order.looked-up", + bucket="lookups", + refill_rate=10, + interval_seconds=60, + max_tokens=10, +) +mcp_limit = TokenBucket( + label="mcp.invoked", + bucket="mcp-access", + refill_rate=20, + interval_seconds=60, + max_tokens=20, +) +inbound = DetectPromptInjection() +user_id = authenticated_user_id +# Caller-owned UUID for this conversation. Later turns: resume=session_id. +session_id = conversation_id + +@tool("lookup_order", "Look up an order by number", {"order_id": str}) +async def lookup_order(args: dict) -> dict: + return { + "content": [{"type": "text", "text": f"{args['order_id']}: shipped"}], + } + +lookup_order = guard_tool( + guard=aj, + tool=lookup_order, + action="order.looked-up", + rules=[lookup_limit(key=user_id, requested=1)], + on_guard_error="deny", +) +# can_use_tool=... is HITL — not this policy gate +``` + +## Step 3: Screen inbound and gate unwrapped tools — `guard_hooks` + +```python +async for message in query( + prompt=user_text, + options=ClaudeAgentOptions( + session_id=session_id, # later turns: resume=session_id instead + mcp_servers={ + "support": create_sdk_mcp_server(name="support", tools=[lookup_order]), + }, + hooks=guard_hooks( + guard=aj, + session_id=session_id, + exclude=[{"server": "support", "name": "lookup_order"}], + inbound={ + "action": "message.received", + "rules": lambda ctx: [inbound(ctx["prompt"])], + }, + action="mcp.invoked", + rules=lambda ctx: [mcp_limit(key=user_id, requested=1)], + on_guard_error="deny", + ), + ), +): + pass +``` + +## Step 4: Correlation + +`claude_agent_context(session_id=session_id)` reads that same +caller-owned UUID (hook `session_id` first, then `options.session_id`). +It never mints. Pass the id you already have into `guard_hooks` and +`ClaudeAgentOptions` — do not derive a new one per turn. The Claude SDK +also requires `session_id` to be a UUID and allows a given id to be +created only once — later turns use `resume`. + +## Verify the integration + +1. `python -m py_compile` (or the project's type-check) passes. +2. Exercise inbound PI (`UserPromptSubmit` block), an authored-tool deny + (JSON-in-content + `is_error: True`, no `structuredContent`), a + built-in / unwrapped MCP deny (`PreToolUse`), a rate limit, and + fail-closed (an unreachable guard). Confirm a wrapped tool produces + **one** guard decision per invocation — a second decision under the + `PreToolUse` action means a missing `exclude` entry. +3. Confirm in the Arcjet Console / CLI that decisions share the + caller-owned UUID `session_id`. +4. Manual E2E with a real `ARCJET_KEY` is still-to-verify until you run it. + +The example `fastapi-claude-agent-sdk-guard` stays with Runtime — do not +invent a new example name. Do not add an example in this skills repo. diff --git a/plugins/arcjet/skills/integrate-arcjet-guard-claude-managed-agents-py/SKILL.md b/plugins/arcjet/skills/integrate-arcjet-guard-claude-managed-agents-py/SKILL.md new file mode 100644 index 0000000..6ddf046 --- /dev/null +++ b/plugins/arcjet/skills/integrate-arcjet-guard-claude-managed-agents-py/SKILL.md @@ -0,0 +1,256 @@ +--- +name: integrate-arcjet-guard-claude-managed-agents-py +description: Integrate Arcjet Guard into Python Claude Managed Agents — screen user.message with guard_events before sessions.events.send, and gate custom tools on agent.custom_tool_use with guard_custom_tool. Use when asked to add Arcjet to hosted Claude Managed Agents, anthropic beta sessions, rate limit custom tools, or block prompt injection on hosted sessions. Not Claude Agent SDK local query() / PreToolUse. +license: Apache-2.0 +compatibility: Requires Python >= 3.10 and official anthropic>=0.92.0,<2 via arcjet[claude-managed-agents] (safe extra, no chromadb). Peer is anthropic, not claude-agent-sdk. Until-published — pin arcjet to git SHA 40ea4896962a90a24cdbc4cfbfc80729c096da36; not in PyPI 0.9.0. +metadata: + author: arcjet + type: core + library: arcjet +--- + +# Integrate Arcjet Guard into Python Claude Managed Agents + +`arcjet.guard.claude_managed_agents` wraps the agent's existing Arcjet +client. It never talks to the Arcjet API itself. Shared Guard +fundamentals (client, rules, labels, decisions, capture, registration) +live in +[../arcjet/references/guards_python.md](../arcjet/references/guards_python.md). +Load that reference for anything that is not Claude Managed +Agents-specific. + +Official `anthropic>=0.92.0,<2` only — not `claude-agent-sdk`, not +`arcjet.guard.claude_agent_sdk`, not the JS +`@anthropic-ai/claude-agent-sdk` adapter, and not JS Claude Managed +Agents (`@arcjet/guard/claude-managed-agents/v0`, docs +https://docs.arcjet.com/guards/claude-managed-agents/). Importing +`arcjet.guard.claude_managed_agents` does not load Claude Agent SDK, +LangChain, or CrewAI. The extra is safe (no chromadb). + +This is the hosted Claude Managed Agents harness +(`client.beta.sessions`). Anthropic runs the agent loop and the built-in +toolset (`bash`, files, web_*). The agent toolset defaults to +`always_allow`, so there is **no customer pre-exec** for bash/files — +`agent.tool_use` / `agent.tool_result` fire after the built-in already +ran. There is no `PreToolUse`. Do not paper over that gap with +`always_ask`. It is not Claude Agent SDK local `query()` / `guard_hooks`. + +Exports: `guard_custom_tool`, `guard_events`, +`claude_managed_agents_context`. There is no `guard_inbound`, no +`guard_tool`, and no `guard_tool_confirmation`. + +Three surfaces, one decision rule: + +- **Inbound text** (`user.message` / `initial_events`) → `guard_events` + wraps `sessions.events.send`. DENY raises `ArcjetDeniedError` / + `ArcjetUnavailableError` and does not call send. +- **A custom tool you execute** (`agent.custom_tool_use`) → + `guard_custom_tool(run=…)`. DENY does not run the original `run`; the + helper sends `user.custom_tool_result` with schema field `is_error`. +- **Correlation** → `claude_managed_agents_context` reads a caller-owned + `correlation_id` / `session_id`. It never mints. It never reads + Anthropic `session.id` / `sevt_…`. + +Docs: https://docs.arcjet.com/guards/claude-managed-agents/ (shared +JS+Python page). Do not use `/guards/claude-agent-sdk/` or +`/guards/claude-agent-sdk-py/`. There is no separate +`/guards/claude-managed-agents-py/` page. JS adapter pin stays +`cb35c8f92c3a2fb63fbeb9b386d79b1878c19d92`. + +## The real gates are inbound `user.message` and custom tools + +`guard_events(send=client.beta.sessions.events.send, …)` wraps the send +callable so `user.message` / `initial_events` are evaluated **before** +the original send runs — the only place a turn can be declined before +the hosted harness reads the prompt. Inbound `rules` receive +`{"prompt", "content", "type"}` from `message_arguments()` — not the JS +`{ text, events }`. `guard_custom_tool(run=…)` returns +`await handler(event, send=…, session_id=…)`. Built-ins never enter that +handler. Optional `tool=` wraps a self-hosted `@beta_tool` `run` the +same way; the CLI worker cannot register custom tools. + +## Custom-tool denial is `user.custom_tool_result` with `is_error` + +On `DENY` (or unevaluated Guard under the default +`on_guard_error="deny"`) the original `run` is not called. The helper +sends a real `user.custom_tool_result` (`custom_tool_use_id`, JSON of +`ArcjetDenialResult` on `content`, **`is_error`** — that field is on the +events schema; do not invent a second one). Do **not** raise from the +hosted handler: a throw leaves the session idle. Omitting `is_error` +looks like success. This is not Claude Agent SDK `structuredContent`. +Same fail-closed default as +[#196](https://github.com/arcjet/arcjet-py/pull/196). Core `guard()` +still fails open. + +## `always_ask` + `user.tool_confirmation` is not HITL-as-policy + +Permission policies apply to the agent toolset and MCP, not custom +tools. Same trap as CrewAI `human_input`, JS `canUseTool`, and LangGraph +`interrupt()`. MCP Guard only on servers you host — Anthropic is the +MCP client. `web_search` / `web_fetch` always run on Anthropic. + +## Questions to ask the human first + +Ask only what you cannot infer from the code; suggest defaults. + +1. Which **custom tools** does the app execute on + `agent.custom_tool_use`? Those get `guard_custom_tool`. Built-ins + under `always_allow` cannot. +2. What **limits**? (e.g. "10 lookups/min per user" → `TokenBucket`.) +3. Who is the **user** for metadata — an opaque user/tenant ID (never PII)? + Default: none. Anthropic `session.id` / `sevt_…` are not the + correlation id. +4. Is an Arcjet outage unacceptable? Every helper defaults to + `on_guard_error="deny"`. Ask explicitly about inbound `guard_events`: + failing closed there means the turn is not sent, so `"allow"` is a + routine and legitimate choice at that one call site. + +## The things readers get wrong + +1. **This is not the Claude Agent SDK.** No `guard_tool`, no + `guard_hooks`, no `PreToolUse`, no `UserPromptSubmit`. Do not also + wrap with `arcjet.guard.claude_agent_sdk` or + `@arcjet/guard/claude-agent-sdk/v0`. +2. **There is no `guard_inbound`.** The helper is `guard_events`. +3. **Inbound ctx is `{"prompt", "content", "type"}`**, not JS + `{ text, events }`. +4. **Correlation is caller-owned, never minted.** Do not pass Anthropic + `session.id` / `sevt_…` / `trace_id` as correlation. +5. **Default `always_allow` cannot be gated.** Do not claim we can block + Anthropic-cloud bash/files. +6. **On custom-tool DENY, send `user.custom_tool_result` with + `is_error`.** Do not raise. On ALLOW the caller still sends the + success result or the session idles. +7. **`always_ask` + `user.tool_confirmation` is opt-in confirmation, + not policy.** +8. **Do not hand-wrap every session event with raw `guard()`.** + +## Step 1: Install and find the guard client + +Until-published: PyPI `arcjet` 0.9.0 does not include this module. Pin +`arcjet` to git SHA `40ea4896962a90a24cdbc4cfbfc80729c096da36`: + +```bash +pip install "arcjet[claude-managed-agents] @ git+https://github.com/arcjet/arcjet-py.git@40ea4896962a90a24cdbc4cfbfc80729c096da36" +``` + +If the agent has no guard client yet, launch one **once at module scope**: + +```python +import os +from arcjet.guard import launch_arcjet + +aj = launch_arcjet(key=os.environ["ARCJET_KEY"]) +``` + +Worked example: +[`examples/fastapi-claude-managed-agents-guard`](https://github.com/arcjet/arcjet-py/tree/main/examples/fastapi-claude-managed-agents-guard) +(pins the same `40ea4896`). + +## Step 2: Screen inbound before `sessions.events.send` + +```python +from anthropic import Anthropic +from arcjet.guard import DetectPromptInjection, TokenBucket, launch_arcjet +from arcjet.guard.claude_managed_agents import ( + claude_managed_agents_context, + guard_custom_tool, + guard_events, +) + +client = Anthropic() +aj = launch_arcjet(key=os.environ["ARCJET_KEY"]) +inbound = DetectPromptInjection() +user_id = authenticated_user_id +conversation_id = authenticated_conversation_id +derived = claude_managed_agents_context(session_id=conversation_id) + +send = guard_events( + guard=aj, + send=client.beta.sessions.events.send, + action="message.received", + # Python inbound ctx is {"prompt", "content", "type"} — not JS { text, events }. + rules=lambda ctx: [inbound(ctx["prompt"])], + session_id=derived.correlation_id or conversation_id, + on_guard_error="deny", +) + +session = client.beta.sessions.create(agent=agent_id, environment_id=environment_id) +# Anthropic minted session.id — pass it to the sessions API, not as correlation. + +# Screen user.message before the hosted harness reads it. DENY raises +# ArcjetDeniedError / ArcjetUnavailableError and does not send. +await send( + session.id, + events=[{"type": "user.message", "content": [{"type": "text", "text": user_text}]}], +) +``` + +## Step 3: Gate custom tools on `agent.custom_tool_use` + +```python +lookup_limit = TokenBucket( + label="order.looked-up", + bucket="lookups", + refill_rate=10, + interval_seconds=60, + max_tokens=10, +) + +async def lookup_order(event) -> dict: + order_id = event.input["order_id"] + return {"content": [{"type": "text", "text": f"{order_id}: shipped"}]} + +handle_lookup = guard_custom_tool( + guard=aj, + action="order.looked-up", + run=lookup_order, + rules=[lookup_limit(key=user_id, requested=1)], + session_id=derived.correlation_id or conversation_id, + on_guard_error="deny", +) + +with client.beta.sessions.events.stream(session_id=session.id) as stream: + for event in stream: + if event.type == "agent.custom_tool_use" and event.name == "lookup_order": + # DENY posts user.custom_tool_result with is_error. Do not raise. + await handle_lookup( + event, + send=client.beta.sessions.events.send, + session_id=session.id, + ) + # agent.tool_use / always_ask + user.tool_confirmation are not this policy gate +``` + +Key rate limits on the authenticated caller, not a model-supplied order +id. On ALLOW the caller still sends the success `user.custom_tool_result`. + +## Step 4: Correlation + +`claude_managed_agents_context` is a reader that returns +`ClaudeManagedAgentsContext` (a dataclass) — not a context manager and +not a contextvar setter. It reads a **caller-owned** `correlation_id` / +`session_id`. It never mints. It never reads Anthropic `id` / `event_id` +/ `session.id` (`ses_…`) / `sevt_…` / `trace_id`. Passing an Anthropic +Session object is safe — those minted ids are ignored. An invalid +candidate is skipped; if nothing valid remains the call is uncorrelated +rather than joined to a generated id. Helpers also re-read +`session_id=` / `correlation_id=` internally. + +## Verify the integration + +1. `python -m py_compile` (or the project's type-check) passes. +2. Exercise inbound PI (send is not called), a custom-tool deny + (`user.custom_tool_result` with `is_error`; `run` is not called), a + rate limit, and fail-closed (an unreachable guard). Confirm + `always_ask` / `user.tool_confirmation` is never treated as the gate + and built-in `agent.tool_use` is observe-only. +3. Confirm in the Arcjet Console / CLI that decisions share the + caller-owned conversation id — not an Anthropic `session.id` / + `sevt_…`. +4. Manual E2E with a real `ARCJET_KEY` is still-to-verify until you run it. + +Worked example: +[`examples/fastapi-claude-managed-agents-guard`](https://github.com/arcjet/arcjet-py/tree/main/examples/fastapi-claude-managed-agents-guard). +Do not invent a second example name. Do not add an example in this +skills repo. diff --git a/plugins/arcjet/skills/integrate-arcjet-guard-crewai/SKILL.md b/plugins/arcjet/skills/integrate-arcjet-guard-crewai/SKILL.md new file mode 100644 index 0000000..2300975 --- /dev/null +++ b/plugins/arcjet/skills/integrate-arcjet-guard-crewai/SKILL.md @@ -0,0 +1,231 @@ +--- +name: integrate-arcjet-guard-crewai +description: Integrate Arcjet Guard into official CrewAI — register process-wide PRE_TOOL_CALL with register_arcjet_hooks, or wrap a standalone BaseTool you call yourself with guard_tool. Use when asked to add Arcjet to CrewAI, LiteAgent, crew-injected tools, rate limit those tools, screen inbound messages before kickoff, or block prompt injection / PII. Official crewai only; not an npm CrewAI port and not LangChain Crew wrappers. +license: Apache-2.0 +compatibility: Requires Python >= 3.10, official crewai>=1.15.3,<2 installed by the user, and a blocking Guard client (launch_arcjet_sync). There is no arcjet[crewai] extra. Until-published — pin arcjet to git SHA b1253640ce676b948594beed5fe62450d0e1c77d; not in PyPI 0.9.0. +metadata: + author: arcjet + type: core + library: arcjet +--- + +# Integrate Arcjet Guard into CrewAI + +`arcjet.guard.crewai` wraps the agent's existing Arcjet client. It never +talks to the Arcjet API itself. Shared Guard fundamentals (client, rules, +labels, decisions, capture, registration) live in +[../arcjet/references/guards_python.md](../arcjet/references/guards_python.md). +Load that reference for anything that is not CrewAI-specific. + +Official `crewai` only — not community forks, not LangChain Crew wrappers, +not an npm CrewAI port. There is **no** `arcjet[crewai]` extra: CrewAI +hard-depends on `chromadb`, which carries unpatched RCE CVE-2026-45829, +so an Arcjet extra must not pull it in. Install CrewAI yourself. Importing +`arcjet.guard.crewai` does not load LangChain. + +Exports: `register_arcjet_hooks`, `unregister_arcjet_hooks`, +`ArcjetCrewAIHooks`, `guard_tool`, `ToolPolicy`, `sanitize_tool_name`, +`free_text_arguments`. There is no `guard_crew`. + +Two surfaces, one decision rule: + +- **A tool a crew, LiteAgent, MCP adapter, or crew-injected list executes** + → `register_arcjet_hooks` + `ToolPolicy`. Gate is process-wide + `PRE_TOOL_CALL` only. DENY raises `HookAborted(reason=..., source="arcjet")`. +- **A CrewAI `BaseTool` you call yourself** → `guard_tool`. This is the + **only** CrewAI path that raises `ArcjetDeniedError` / + `ArcjetUnavailableError`. `BaseTool.run` never dispatches + `PRE_TOOL_CALL`. + +Docs: https://docs.arcjet.com/guards/crewai/. + +## The gate is process-wide `PRE_TOOL_CALL`, once + +`register_arcjet_hooks` registers on CrewAI's dispatcher. A `DENY` (or +unevaluated Guard under the default `on_guard_error="deny"`) raises +`HookAborted(reason=..., source="arcjet")` so the tool never runs. CrewAI +swallows every other exception — raising `ArcjetDeniedError` / +`ArcjetUnavailableError` from the hook would _run_ the tool. The hook +path is **sync only** — pass `launch_arcjet_sync` / `ArcjetGuardSync`. An +async client is refused at registration (`ArcjetMisconfiguration`). A +second `register_arcjet_hooks` in the same process is also +`ArcjetMisconfiguration` (CrewAI's registry appends and would +double-evaluate); call `unregister()` on the handle first. + +## `POST_TOOL_CALL` is never registered + +Only PRE is installed. The decision is captured in PRE. POST is not a +policy surface. The agent always sees +`Tool execution blocked by hook. Tool: {name}`. `HookAborted.reason` is +telemetry only. A proceed still records `success` — do not read CrewAI +hook capture as the five-value `metadata.outcome` table used by +`guard_action` / LangChain helpers. + +## `human_input` is not a policy gate + +Agent/Task `human_input` and `request_human_input` are human-in-the-loop. +Same trap as JS `humanInTheLoopMiddleware`, LangGraph `interrupt()`, +OpenAI Agents `needsApproval`, and Genkit `interrupt()`. There is no +inbound helper and no approval helper. + +## Questions to ask the human first + +Ask only what you cannot infer from the code; suggest defaults. + +1. Which tools are **risky** (external side effects, irreversible, spends + money, sends messages)? Crew-executed tools get + `register_arcjet_hooks`. A standalone `BaseTool` you invoke yourself + gets `guard_tool`. +2. What **limits**? (e.g. "10 lookups/min per user" → `TokenBucket`.) +3. Who is the **user** for metadata — an opaque user/tenant ID (never PII)? + Default: none. Crew, task, and agent names are metadata, never minted + into a correlation id. Use a caller-owned `correlation_id` / + `arcjet_sequence`. +4. Is an Arcjet outage unacceptable? Every helper defaults to + `on_guard_error="deny"`. Ask explicitly about inbound screening before + `crew.kickoff`: failing closed there means the crew does not run, so + `"allow"` is a routine and legitimate choice at that one call site. + +## The things readers get wrong + +1. **There is no `arcjet[crewai]` extra.** Install `crewai>=1.15.3,<2` + yourself. Until-published, pin `arcjet` to + `b1253640ce676b948594beed5fe62450d0e1c77d` + ([#224](https://github.com/arcjet/arcjet-py/pull/224)). +2. **There is no `guard_crew`.** Use `register_arcjet_hooks`. +3. **The hook path is sync only.** Pass `launch_arcjet_sync`. +4. **Raise `HookAborted` from the hook, not Arcjet errors.** CrewAI + swallows anything else and the tool runs. +5. **`human_input` is HITL, not policy.** +6. **Screen inbound with core `guard` / `guard_sync` before + `crew.kickoff`.** Core `guard()` fails open. +7. **Key rate limits on the authenticated caller**, not a model-supplied + order id. `sanitize_tool_name` matches `Send Email` and `send_email`. +8. **Do not hand-wrap every CrewAI tool with raw `guard()`.** + +## Step 1: Install and find the guard client + +```bash +pip install "arcjet @ git+https://github.com/arcjet/arcjet-py.git@b1253640ce676b948594beed5fe62450d0e1c77d" +pip install "crewai>=1.15.3,<2" +``` + +Launch a **sync** client at module scope for the hook path: + +```python +import os +from arcjet.guard import launch_arcjet_sync + +aj = launch_arcjet_sync(key=os.environ["ARCJET_KEY"]) +``` + +Use `launch_arcjet` only from async application code, or from +`guard_tool` when you call `arun()`. + +## Step 2: Gate crew-executed tools — `register_arcjet_hooks` + +`ToolPolicy` is `action` + `rules`, keyed by tool name. Keys and the +optional `tools=` filter go through `sanitize_tool_name` (CrewAI +1.15.3+). Tools without a matching policy still get +`"{sanitized_tool_name}.invoked"` and the registrar-level `rules` (empty +still contacts Guard) unless you pass `tools=`. `free_text_arguments` +strips opaque ids (`tool_call_id`, `*_id`, …) when you want only free +text for a scanning rule — the hook itself hands resolvers the tool's +own argument mapping unfiltered. Already-`guard_tool`-wrapped tools are +skipped so Guard is not called twice. Tear down with +`unregister_arcjet_hooks(hooks)` or `hooks.unregister()`. + +```python +from crewai import Agent, Crew, Task +from arcjet.guard import DetectPromptInjection, TokenBucket, launch_arcjet_sync +from arcjet.guard.crewai import ToolPolicy, register_arcjet_hooks, unregister_arcjet_hooks + +aj = launch_arcjet_sync(key=os.environ["ARCJET_KEY"]) +lookup_limit = TokenBucket( + label="order.looked-up", + bucket="lookups", + refill_rate=10, + interval_seconds=60, + max_tokens=10, +) +inbound = DetectPromptInjection() +user_id = authenticated_user_id + +hooks = register_arcjet_hooks( + guard=aj, + policies={ + "lookup_order": ToolPolicy( + action="order.looked-up", + rules=[lookup_limit(key=user_id, requested=1)], + ), + }, + tools=["lookup_order"], + on_guard_error="deny", +) + +agent = Agent( + role="Support", + goal="Look up orders", + backstory="Help the user with order status.", + # human_input=True is HITL — not this policy gate +) +task = Task( + description="Look up the user's order", + expected_output="Order status", + agent=agent, +) +crew = Crew(agents=[agent], tasks=[task]) + +decision = aj.guard( + label="message.received", + rules=[inbound(user_text)], +) +if decision.conclusion == "DENY": + raise RuntimeError("message blocked") +if decision.has_failed_open(): + raise RuntimeError("inbound guard unavailable") + +crew.kickoff() +unregister_arcjet_hooks(hooks) +``` + +## Step 3: Gate a `BaseTool` you call — `guard_tool` + +Hand the crew the copy this returns (it carries the brand the hook +skips). The original stays unguarded on purpose — if you pass that to a +crew, the hook still covers it. A sync call needs a blocking client; an +async call needs an awaitable one. + +```python +from arcjet.guard.crewai import guard_tool + +guarded = guard_tool( + guard=aj, + tool=lookup_order, + action="order.looked-up", + rules=[lookup_limit(key=user_id, requested=1)], + on_guard_error="deny", +) +result = guarded.run(order_id=order_id) +``` + +## Step 4: Correlation + +Use a caller-owned `correlation_id` / `arcjet_sequence`. Crew, task, and +agent names are metadata, never minted into an id. If nothing valid +remains, the call is uncorrelated rather than joined to a generated id. + +## Verify the integration + +1. `python -m py_compile` (or the project's type-check) passes. +2. Exercise inbound PI (before `kickoff`), a `PRE_TOOL_CALL` deny + (`HookAborted`, agent sees `Tool execution blocked by hook. Tool: + {name}`), a standalone `guard_tool` deny (`ArcjetDeniedError`), a + rate limit, and fail-closed (an unreachable guard). Confirm + `POST_TOOL_CALL` is not registered and `human_input` is never treated + as the gate. +3. Confirm in the Arcjet Console / CLI that decisions share the + caller-owned correlation id — not a crew/task/agent name. +4. Manual E2E with a real `ARCJET_KEY` is still-to-verify until you run it. + +Do not invent a CrewAI example name in this skills repo. diff --git a/plugins/arcjet/skills/integrate-arcjet-guard-langchain-py/SKILL.md b/plugins/arcjet/skills/integrate-arcjet-guard-langchain-py/SKILL.md new file mode 100644 index 0000000..58b2a3a --- /dev/null +++ b/plugins/arcjet/skills/integrate-arcjet-guard-langchain-py/SKILL.md @@ -0,0 +1,232 @@ +--- +name: integrate-arcjet-guard-langchain-py +description: Integrate Arcjet Guard into Python LangChain — wrap a BaseTool you call with guard_tool, put ArcjetMiddleware + ToolPolicy on create_agent, or observe a chain with ArcjetCaptureHandler. Use when asked to add Arcjet to LangChain, create_agent, langchain-core tools, rate limit those tools, screen inbound messages, or block prompt injection / PII. This is Python LangChain, not LangChain JS createAgent and not LangGraph JS. +license: Apache-2.0 +compatibility: Requires Python >= 3.10. A BaseTool you call needs arcjet[langchain] (langchain-core>=1.2.5,<2). create_agent needs arcjet[langchain-agents] (langchain>=1.3,<2, langgraph>=1.2,<2). Helpers track arcjet-py main (no fixed SHA) — not in PyPI 0.9.0 or 0.10.0b1. +metadata: + author: arcjet + type: core + library: arcjet +--- + +# Integrate Arcjet Guard into Python LangChain + +`arcjet.guard.langchain` wraps the agent's existing Arcjet client. It never +talks to the Arcjet API itself. Shared Guard fundamentals (client, rules, +labels, decisions, capture, registration) live in +[../arcjet/references/guards_python.md](../arcjet/references/guards_python.md). +Load that reference for anything that is not LangChain-specific. + +Four surfaces, one decision rule: + +- **Any Python callable** → `guard_action` / `guard_action_sync` in core + `arcjet.guard`. No LangChain extra. Details in the shared Python Guard + reference. +- **A LangChain `BaseTool` you call yourself** → `guard_tool`. DENY raises + `ArcjetToolDeniedError` (the tool's `handle_tool_error` may convert it). +- **`create_agent` (the model chooses tools)** → `ArcjetMiddleware` + + `ToolPolicy`. Tools without a policy pass through. +- **Observe a chain or agent** → `ArcjetCaptureHandler` / + `ArcjetAsyncCaptureHandler`. These cannot deny. + +This is Python `create_agent` (docs +https://docs.arcjet.com/guards/langchain/). It is not JS `createAgent` / +`wrapToolCall` (`@arcjet/guard/langchain/v1`, docs +https://docs.arcjet.com/guards/langchain-js/) and not LangGraph JS +`StateGraph` / `ToolNode` (docs https://docs.arcjet.com/guards/langgraph/). +Importing `arcjet.guard.langchain` does not load LangGraph. Referencing +`ArcjetMiddleware` or `ToolPolicy` without `arcjet[langchain-agents]` +raises and names that extra. + +## Screen inbound before `agent.ainvoke` — there is no inbound helper + +There is no inbound helper. Put prompt-injection (and other inbound rules) +in the application with core `aj.guard(...)` before `ainvoke` / `invoke`. +Core `guard()` fails open: `ALLOW` is not proof the rules ran. Gate on +`decision.has_failed_open()` when this call site must fail closed. +`guard_tool` / `ArcjetMiddleware` already default to that. + +## Capture handlers never block + +LangChain ignores what a callback returns. `ArcjetCaptureHandler` only +records. Policy lives in `guard_action` / `guard_tool` / `ArcjetMiddleware`. + +## Configure the tool before `guard_tool()` + +Narrow `args_schema`, set `handle_tool_error` / `callbacks` / +`response_format` on the tool you still hold, then wrap. Changes on the +guarded handle do not reach the call. + +## Questions to ask the human first + +Ask only what you cannot infer from the code; suggest defaults. + +1. Which tools are **risky** (external side effects, irreversible, spends + money, sends messages)? A tool you call yourself gets `guard_tool`. A + tool the model picks gets `ToolPolicy` on `ArcjetMiddleware`. +2. What **limits**? (e.g. "10 lookups/min per user" → `TokenBucket`.) +3. Who is the **user** for metadata — an opaque user/tenant ID (never PII)? + Default: none. The conversation / session id you already have is the + correlation id, not the user. Put it on + `config["configurable"]["arcjet_correlation_id"]` or + `with arcjet_sequence(correlation_id=...)`. +4. Is an Arcjet outage unacceptable? Every helper defaults to + `on_guard_error="deny"`. Ask explicitly about inbound screening before + `ainvoke`: failing closed there means the agent does not run for the + duration of the outage, so `"allow"` is a routine and legitimate + choice at that one call site. + +## The things readers get wrong + +1. **This is not LangChain JS and not LangGraph JS.** Do not import + `@arcjet/guard/langchain/v1` or `@arcjet/guard/langgraph/v1`. +2. **There is no inbound helper.** Screen with core `guard()` before + `ainvoke`. +3. **`ArcjetCaptureHandler` cannot deny.** Policy is `guard_tool` or + `ArcjetMiddleware`. +4. **Correlation is caller-owned, never minted.** Do not mint a new id per + turn. LangChain's `run_id` is not used. The config key wins over an + enclosing `arcjet_sequence`; `configurable` is checked before + `metadata`. +5. **Pass `tools=` the same sequence you gave `create_agent`.** A typo in + a policy key is refused at construction instead of leaving that tool + unguarded. +6. **Key rate limits on the authenticated caller**, not a model-supplied + order id. +7. **`success` on helper capture is not "the action ran".** + `guard_action` / `guard_tool` / `ArcjetMiddleware` write + `metadata.outcome`. See the shared Python Guard reference. + +## Step 1: Install and find the guard client + +Helpers track current `arcjet-py` **main** +([#195](https://github.com/arcjet/arcjet-py/pull/195), +[#196](https://github.com/arcjet/arcjet-py/pull/196)). There is no +until-published git SHA for this adapter — unlike CrewAI / OpenAI +Agents / Claude / Strands, do not pin a commit. They are **not** in +PyPI 0.9.0 or the 0.10.0b1 pre-release, so install from git `main` +with the project's package manager. Do not hand-edit +`requirements.txt` with a guessed version. + +```bash +# guard_tool + capture handlers +pip install "arcjet[langchain] @ git+https://github.com/arcjet/arcjet-py.git" +# ArcjetMiddleware + ToolPolicy +pip install "arcjet[langchain-agents] @ git+https://github.com/arcjet/arcjet-py.git" +``` + +If the agent has no guard client yet, launch one **once at module scope**. +Use `launch_arcjet` in async code and `launch_arcjet_sync` in sync code. + +```python +import os +from arcjet.guard import launch_arcjet + +aj = launch_arcjet(key=os.environ["ARCJET_KEY"]) +``` + +## Step 2: Gate a `BaseTool` you call — `guard_tool` + +```python +from arcjet.guard.langchain import guard_tool + +send_email.args_schema = PublicEmailArgs # narrow first, then wrap +guarded = guard_tool( + guard=aj, + tool=send_email, + action="email.sent", + rules=[email_limit(key=user_id, requested=1)], + on_guard_error="deny", +) +``` + +Needs `arcjet[langchain]`. The result is still a `BaseTool`. DENY raises +`ArcjetToolDeniedError`; unavailable raises `ArcjetToolUnavailableError`. +If you can name the tool at wiring time, `guard_tool` is the smaller +change. + +## Step 3: Gate `create_agent` — `ArcjetMiddleware` + +```python +from langchain.agents import create_agent +from arcjet.guard.langchain import ArcjetMiddleware, ToolPolicy + +tools = [send_email, search_orders] +agent = create_agent( + model="openai:gpt-4o", + tools=tools, + middleware=[ + ArcjetMiddleware( + guard=aj, + policies={ + "send_email": ToolPolicy( + action="email.sent", + rules=[email_limit(key=user_id, requested=1)], + ), + }, + tools=tools, + on_guard_error="deny", + ) + ], +) + +await agent.ainvoke( + {"messages": [...]}, + config={"configurable": {"arcjet_correlation_id": session.id}}, +) +``` + +Needs `arcjet[langchain-agents]`. `guard=` is optional if you already +`register_arcjet()`. They compose: a guarded tool inside a guarded agent +evaluates each policy once and both land on the same Sequence. + +## Step 4: Observe a chain — `ArcjetCaptureHandler` + +```python +from arcjet.guard.langchain import ArcjetAsyncCaptureHandler, ArcjetCaptureHandler + +# invoke → ArcjetCaptureHandler; ainvoke → ArcjetAsyncCaptureHandler +chain.invoke(inputs, config={"callbacks": [ArcjetCaptureHandler(guard=aj)]}) +await chain.ainvoke( + inputs, config={"callbacks": [ArcjetAsyncCaptureHandler(guard=aj)]} +) +``` + +Same extra as `guard_tool`. Pair the handler with the call. Neither can +deny a call. + +## Step 5: Screen inbound and correlate + +```python +from arcjet.guard import DetectPromptInjection + +inbound = DetectPromptInjection() +decision = await aj.guard( + label="message.received", + rules=[inbound(user_text)], +) +if decision.conclusion == "DENY": + raise RuntimeError("message blocked") +if decision.has_failed_open(): + raise RuntimeError("inbound guard unavailable") +``` + +Keep a whole run on one Sequence with +`with arcjet_sequence(correlation_id=session.id):` or +`config={"configurable": {"arcjet_correlation_id": session.id}}`. Do not +mint a new id per turn. + +## Verify the integration + +1. `python -m py_compile` (or the project's type-check) passes. +2. Exercise inbound PI (before `ainvoke`), a `guard_tool` deny, a + middleware deny on a named policy, a capture-only handler that does + **not** block, a rate limit, and fail-closed (an unreachable guard). +3. Confirm in the Arcjet Console / CLI (`guards list`) that decisions + share the caller-owned correlation id. +4. Manual E2E with a real `ARCJET_KEY` is still-to-verify until you run it. + +Worked example: +[`examples/fastapi-langchain-guard`](https://github.com/arcjet/arcjet-py/tree/main/examples/fastapi-langchain-guard). +Do not invent a second example name. Do not add an example in this skills +repo. diff --git a/plugins/arcjet/skills/integrate-arcjet-guard-openai-agents-py/SKILL.md b/plugins/arcjet/skills/integrate-arcjet-guard-openai-agents-py/SKILL.md new file mode 100644 index 0000000..ddbd9a6 --- /dev/null +++ b/plugins/arcjet/skills/integrate-arcjet-guard-openai-agents-py/SKILL.md @@ -0,0 +1,205 @@ +--- +name: integrate-arcjet-guard-openai-agents-py +description: Integrate Arcjet Guard into Python OpenAI Agents — wrap FunctionTool / function_tool with guard_tool and read a caller-owned session or conversation id via openai_agents_context. Use when asked to add Arcjet to openai-agents, Runner.run, rate limit those tools, screen inbound messages, or block prompt injection / PII. This is Python openai-agents, not the JS @openai/agents adapter. +license: Apache-2.0 +compatibility: Requires Python >= 3.10 and official openai-agents>=0.19.0,<1 via arcjet[openai-agents]. This is text Agent + Runner.run + authored FunctionTool — not Realtime, Sandbox, hosted, MCP, Computer / Shell / ApplyPatch, handoffs, or Agent.as_tool(). Until-published — pin arcjet to git SHA cafe6a6671ab7fdde1b78b59971c3fe8ca863a4d; not in PyPI 0.9.0. +metadata: + author: arcjet + type: core + library: arcjet +--- + +# Integrate Arcjet Guard into Python OpenAI Agents + +`arcjet.guard.openai_agents` wraps the agent's existing Arcjet client. It +never talks to the Arcjet API itself. Shared Guard fundamentals (client, +rules, labels, decisions, capture, registration) live in +[../arcjet/references/guards_python.md](../arcjet/references/guards_python.md). +Load that reference for anything that is not OpenAI Agents-specific. + +Official `openai-agents>=0.19.0,<1` only — not the JS `@openai/agents` +adapter (`@arcjet/guard/openai-agents/v0`, docs +https://docs.arcjet.com/guards/openai-agents/), not community forks. +Importing `arcjet.guard.openai_agents` does not load LangChain. + +Exports: `guard_tool`, `openai_agents_context`. Authored `FunctionTool` / +`@function_tool` only. Not hosted tools, MCP, Computer / Shell / +ApplyPatch, handoffs, or `Agent.as_tool()`. + +Two surfaces, one decision rule: + +- **An authored `FunctionTool`** → `guard_tool`. Gate is + `FunctionTool.tool_input_guardrails` + `reject_content` (JSON of + `ArcjetDenialResult`). Do **not** raise. +- **Correlation** → `openai_agents_context` reads a caller-owned session + / conversation id. It never mints. It never reads `trace_id`. + +Docs: https://docs.arcjet.com/guards/openai-agents-py/. + +## The gate is `tool_input_guardrails` + `reject_content` + +`guard_tool` returns a copy whose input guardrails start with Arcjet, so +`on_invoke_tool` never runs on `DENY` (or unevaluated Guard under the +default `on_guard_error="deny"`). Denial is +`ToolGuardrailFunctionOutput.reject_content` with JSON of +`ArcjetDenialResult` (`{ arcjetDenied: true, … }`). Do **not** raise — +`raise_exception()` is a tripwire halt, and a raise from +`on_invoke_tool` is swallowed by `default_tool_error_function`. Same +fail-closed default as [#196](https://github.com/arcjet/arcjet-py/pull/196): +only `"allow"` fails open; a `DENY` always blocks. Core `guard()` still +fails open (`has_failed_open()`). + +## `needs_approval` is not a policy gate + +`needs_approval` is human-in-the-loop (`state.approve` / `state.reject`). +Same trap as JS OpenAI Agents `needsApproval`, LangGraph `interrupt()`, +and Genkit `interrupt()`. There is no inbound helper and no approval +helper. `RunConfig.tool_execution.pre_approval_tool_input_guardrails=True` +is an application opt-in only — this helper does not set it. + +## Screen inbound before `Runner.run` + +There is no inbound helper. SDK `input_guardrails` / `output_guardrails` +/ tool output guardrails are the SDK's own tripwires, not Arcjet. Call +`aj.guard(...)` in the application and **act on the decision**. Core +`guard()` fails open: `ALLOW` is not proof the rules ran. Gate on +`decision.has_failed_open()` if this call site must fail closed; +`guard_tool` already defaults to that. + +## Questions to ask the human first + +Ask only what you cannot infer from the code; suggest defaults. + +1. Which tools are **risky** (external side effects, irreversible, spends + money, sends messages)? Those get `guard_tool`. Hosted / MCP / + handoffs / `as_tool` are out of scope. +2. What **limits**? (e.g. "10 lookups/min per user" → `TokenBucket`.) +3. Who is the **user** for metadata — an opaque user/tenant ID (never PII)? + Default: none. Put the conversation / session id you already have on + the app context you pass to `Runner.run`. That id is the correlation + id, not the user. +4. Is an Arcjet outage unacceptable? Every helper defaults to + `on_guard_error="deny"`. Ask explicitly about inbound screening before + `Runner.run`: failing closed there means the agent does not run, so + `"allow"` is a routine and legitimate choice at that one call site. + +## The things readers get wrong + +1. **This is not the JS adapter.** Do not import + `@arcjet/guard/openai-agents/v0`. +2. **There is no inbound helper.** SDK guardrails are not Arcjet. Screen + with core `guard()` before `Runner.run`. +3. **`needs_approval` is HITL, not policy.** +4. **Denial is `reject_content` only.** A raise is a tripwire halt or is + swallowed by `default_tool_error_function`. +5. **Correlation is read, never minted.** Never `trace_id`. Never + construct `OpenAIConversationsSession()`. +6. **Key rate limits on the authenticated caller**, not a model-supplied + order id. Hand the agent the copy `guard_tool` returns — the original + stays unguarded. +7. **Do not hand-wrap every tool with raw `guard()`.** + +## Step 1: Install and find the guard client + +Until-published: PyPI `arcjet` 0.9.0 does not include this module. Pin +`arcjet` to git SHA `cafe6a6671ab7fdde1b78b59971c3fe8ca863a4d` +([#226](https://github.com/arcjet/arcjet-py/pull/226)): + +```bash +pip install "arcjet[openai-agents] @ git+https://github.com/arcjet/arcjet-py.git@cafe6a6671ab7fdde1b78b59971c3fe8ca863a4d" +``` + +If the agent has no guard client yet, launch one **once at module scope**: + +```python +import os +from arcjet.guard import launch_arcjet + +aj = launch_arcjet(key=os.environ["ARCJET_KEY"]) +``` + +## Step 2: Gate authored tools — `guard_tool` + +```python +from agents import Agent, Runner, function_tool +from arcjet.guard import DetectPromptInjection, TokenBucket, launch_arcjet +from arcjet.guard.openai_agents import guard_tool, openai_agents_context + +aj = launch_arcjet(key=os.environ["ARCJET_KEY"]) +lookup_limit = TokenBucket( + label="order.looked-up", + bucket="lookups", + refill_rate=10, + interval_seconds=60, + max_tokens=10, +) +user_id = authenticated_user_id + +@function_tool # needs_approval=... is HITL — not this policy gate +def lookup_order(order_number: str) -> dict: + """Look up an order by number.""" + return {"order_number": order_number, "status": "shipped"} + +lookup_order = guard_tool( + guard=aj, + tool=lookup_order, + action="order.looked-up", + rules=[lookup_limit(key=user_id, requested=1)], + on_guard_error="deny", +) + +agent = Agent( + name="support-agent", + instructions="Help the user.", + tools=[lookup_order], +) +``` + +Use `action` + `rules` on `guard_tool`. + +## Step 3: Screen inbound before `Runner.run` + +```python +inbound = DetectPromptInjection() +app_context = {"session_id": conversation_id} +derived = openai_agents_context(app_context) +decision = await aj.guard( + label="message.received", + rules=[inbound(user_text)], + correlation_id=derived.correlation_id, +) +if decision.conclusion == "DENY": + raise Exception("message blocked") +if decision.has_failed_open(): + raise Exception("inbound guard unavailable") + +await Runner.run(agent, user_text, context=app_context) +``` + +There is no inbound helper. + +## Step 4: Correlation + +`openai_agents_context` reads a caller-owned id. Preference: fields on +`runContext.context` / a bare app object (`correlation_id`, then +`session_id`, then `conversation_id`, then `group_id`, snake or +camelCase), then the same names on the envelope, then +`correlation_id=` / `session_id=` kwargs, then an enclosing +`arcjet_sequence`. It returns `OpenAIAgentsContext` — pass +`.correlation_id` to `guard()`. It never mints an id. It never reads +`trace_id`. It never constructs `OpenAIConversationsSession()`. Do not +invent a correlation id per turn. + +## Verify the integration + +1. `python -m py_compile` (or the project's type-check) passes. +2. Exercise inbound PI (before `Runner.run`), a tool deny + (`reject_content`, not a raise), a rate limit, and fail-closed (an + unreachable guard). Confirm `needs_approval` is never treated as the + gate. +3. Confirm in the Arcjet Console / CLI that decisions share the + caller-owned session / conversation id — not a `trace_id`. +4. Manual E2E with a real `ARCJET_KEY` is still-to-verify until you run it. + +The example `fastapi-openai-agents-guard` stays with Runtime — do not +invent a new example name. Do not add an example in this skills repo. diff --git a/plugins/arcjet/skills/integrate-arcjet-guard-strands-agents-py/SKILL.md b/plugins/arcjet/skills/integrate-arcjet-guard-strands-agents-py/SKILL.md new file mode 100644 index 0000000..b0cb70b --- /dev/null +++ b/plugins/arcjet/skills/integrate-arcjet-guard-strands-agents-py/SKILL.md @@ -0,0 +1,228 @@ +--- +name: integrate-arcjet-guard-strands-agents-py +description: Integrate Arcjet Guard into Python Strands Agents — wrap authored @tool with guard_tool, and put guard_hooks on Agent(hooks=) for unwrapped / MCP tools via BeforeToolCallEvent.cancel_tool. Use when asked to add Arcjet to strands / strands-agents, rate limit those tools, screen inbound messages, or block prompt injection / PII. This is Python strands, not JS @strands-agents/sdk. +license: Apache-2.0 +compatibility: Requires Python >= 3.10 and official strands-agents>=1.11.0,<2 via arcjet[strands-agents] (safe extra, no chromadb). 1.11.0 is the first 1.x with BeforeToolCallEvent.cancel_tool. Until-published — pin arcjet to git SHA a630806169b92757192f3f5cce2e305827b26567; not in PyPI 0.9.0. +metadata: + author: arcjet + type: core + library: arcjet +--- + +# Integrate Arcjet Guard into Python Strands Agents + +`arcjet.guard.strands_agents` wraps the agent's existing Arcjet client. +It never talks to the Arcjet API itself. Shared Guard fundamentals +(client, rules, labels, decisions, capture, registration) live in +[../arcjet/references/guards_python.md](../arcjet/references/guards_python.md). +Load that reference for anything that is not Strands-specific. + +Official Python `strands` / `strands-agents>=1.11.0,<2` only — not JS +`@arcjet/guard/strands-agents/v1` (docs +https://docs.arcjet.com/guards/strands-agents/), not community forks. +Importing `arcjet.guard.strands_agents` does not load LangChain, CrewAI, +or JS `@arcjet/guard/strands-agents/v1`. Unlike CrewAI (no extra), the +`arcjet[strands-agents]` extra exists at the pin +(`strands-agents = ["strands-agents>=1.11.0,<2"]` in `pyproject.toml`). +The extra is safe (no chromadb). + +Exports: `guard_tool`, `guard_hooks`, `strands_agent_context`. Authored +`@tool` plus `Agent(hooks=)` / `add_hook`. + +Three surfaces, one decision rule: + +- **An authored `@tool`** → `guard_tool`. Hand the agent the copy this + returns — the original stays unguarded. +- **Unwrapped / MCP tools** → `guard_hooks`. Gate is per-tool + `BeforeToolCallEvent.cancel_tool` (`True` or `str`). Already-wrapped + tools are skipped so Guard is not called twice. +- **Correlation** → `strands_agent_context` reads a caller-owned id from + `invocation_state`. It never mints. It never reads `trace_id`. + +Docs: https://docs.arcjet.com/guards/strands-agents-py/. Example: +[`examples/fastapi-strands-agents-guard`](https://github.com/arcjet/arcjet-py/tree/main/examples/fastapi-strands-agents-guard) +(pins `a6308061`; no verify fixture). Do not invent a second example +name. + +## The gate is per-tool `BeforeToolCallEvent.cancel_tool` + +`guard_hooks` registers on that event so the tool never runs on `DENY` +(or unevaluated Guard under the default `on_guard_error="deny"`). A +string is the cancel message (JSON of `ArcjetDenialResult`); `True` uses +Strands' default message and drops the fields. Fail closed: always set +`cancel_tool` on error — do not leave it unset and do not raise. Same +fail-closed default as [#196](https://github.com/arcjet/arcjet-py/pull/196): +only `"allow"` fails open; a `DENY` always blocks. Core `guard()` still +fails open (`has_failed_open()`). + +## `event.interrupt()` is not a policy gate + +`BeforeToolCallEvent.interrupt()` / resume is human-in-the-loop. Same +trap as CrewAI `human_input`, JS `humanInTheLoopMiddleware`, LangGraph +`interrupt()`, OpenAI Agents `needsApproval`, and Genkit `interrupt()`. +There is no inbound helper and no approval helper. + +## Screen inbound before `Agent(...)` / `__call__` / `stream_async` + +There is no inbound helper. Call `aj.guard(...)` in the application and +**act on the decision**. Core `guard()` fails open: `ALLOW` is not proof +the rules ran. Gate on `decision.has_failed_open()` if this call site +must fail closed; `guard_tool` / `guard_hooks` already default to that. + +## Questions to ask the human first + +Ask only what you cannot infer from the code; suggest defaults. + +1. Which tools are **risky** (external side effects, irreversible, spends + money, sends messages)? Those get `guard_tool`. MCP / unwrapped tools + you did not author get `guard_hooks`. +2. What **limits**? (e.g. "10 lookups/min per user" → `TokenBucket`.) +3. Who is the **user** for metadata — an opaque user/tenant ID (never PII)? + Default: none. Put the conversation / session id you already have on + `invocation_state` _and_ on `guard_hooks(...)`. That id is the + correlation id, not the user. +4. Is an Arcjet outage unacceptable? Every helper defaults to + `on_guard_error="deny"`. Ask explicitly about inbound screening + before the agent runs: failing closed there means the agent does not + run, so `"allow"` is a routine and legitimate choice at that one + call site. + +## The things readers get wrong + +1. **This is not JS `@arcjet/guard/strands-agents/v1`.** Import + `arcjet.guard.strands_agents`. +2. **There is no inbound helper.** Screen with core `guard()` before + `Agent(...)` / `__call__` / `stream_async`. +3. **`event.interrupt()` is HITL, not policy.** Deny is + `cancel_tool` (`True` or `str`). +4. **Fail closed = always set `cancel_tool` on error.** Do not raise + and do not leave it unset. +5. **Correlation is read, never minted.** Never `trace_id`, never + `agent.id`, never SessionManager auto-ids. +6. **Already-wrapped tools are skipped** by `guard_hooks` so Guard is + not called twice. Hand the agent the copy `guard_tool` returns. +7. **Key rate limits on the authenticated caller**, not a model-supplied + order id. +8. **Do not hand-wrap every Strands tool with raw `guard()`.** + +## Step 1: Install and find the guard client + +Until-published: PyPI `arcjet` 0.9.0 does not include this module. Pin +`arcjet` to git SHA `a630806169b92757192f3f5cce2e305827b26567`: + +```bash +pip install "arcjet[strands-agents] @ git+https://github.com/arcjet/arcjet-py.git@a630806169b92757192f3f5cce2e305827b26567" +``` + +If the agent has no guard client yet, launch one **once at module scope**: + +```python +import os +from arcjet.guard import launch_arcjet + +aj = launch_arcjet(key=os.environ["ARCJET_KEY"]) +``` + +## Step 2: Gate authored tools — `guard_tool` + +```python +from strands import Agent, tool +from arcjet.guard import DetectPromptInjection, TokenBucket, launch_arcjet +from arcjet.guard.strands_agents import guard_hooks, guard_tool, strands_agent_context + +aj = launch_arcjet(key=os.environ["ARCJET_KEY"]) +lookup_limit = TokenBucket( + label="order.looked-up", + bucket="lookups", + refill_rate=10, + interval_seconds=60, + max_tokens=10, +) +mcp_limit = TokenBucket( + label="mcp.invoked", + bucket="mcp-access", + refill_rate=20, + interval_seconds=60, + max_tokens=20, +) +inbound = DetectPromptInjection() +user_id = authenticated_user_id + +@tool # event.interrupt() is HITL — not this policy gate +def lookup_order(order_id: str) -> dict: + """Look up an order by ID.""" + return {"order_id": order_id, "status": "shipped"} + +lookup_order = guard_tool( + guard=aj, + tool=lookup_order, + action="order.looked-up", + rules=[lookup_limit(key=user_id, requested=1)], + on_guard_error="deny", +) +``` + +## Step 3: Gate unwrapped / MCP tools — `guard_hooks` + +```python +mcp_tools = [] # from an MCP client you did not wrap with guard_tool +agent = Agent( + tools=[lookup_order, *mcp_tools], + # The agent-wide gate for tools guard_tool did not wrap. + # Already-wrapped tools are skipped, so Guard is not called twice. + # BeforeToolCallEvent.cancel_tool is True or a str (JSON of the payload). + hooks=[ + guard_hooks( + guard=aj, + action="mcp.invoked", + rules=[mcp_limit(key=user_id, requested=1)], + session_id=conversation_id, + on_guard_error="deny", + ), + ], +) +``` + +## Step 4: Screen inbound before the agent runs + +```python +invocation_state = {"sessionId": conversation_id} +derived = strands_agent_context(invocation_state) +decision = await aj.guard( + label="message.received", + rules=[inbound(user_text)], + correlation_id=derived.correlation_id, +) +if decision.conclusion == "DENY": + raise RuntimeError("message blocked") +if decision.has_failed_open(): + raise RuntimeError("inbound guard unavailable") + +agent(user_text, invocation_state=invocation_state) +``` + +There is no inbound helper. + +## Step 5: Correlation + +`strands_agent_context` reads a caller-owned id from +`invocation_state`: `correlationId`, then `sessionId`, then `requestId`. +It never mints an id. It never reads `trace_id`. It never reads +`agent.id` or SessionManager auto-ids. Do not invent a correlation id +per turn. Put the same id on the invocation _and_ on `guard_hooks(...)`. + +## Verify the integration + +1. `python -m py_compile` (or the project's type-check) passes. +2. Exercise inbound PI (before the agent runs), a `guard_tool` deny, a + `guard_hooks` deny on an unwrapped tool (`cancel_tool` is `True` or + a str), a rate limit, and fail-closed (an unreachable guard). Confirm + `event.interrupt()` is never called as the gate. +3. Confirm in the Arcjet Console / CLI that decisions share the + caller-owned session / request id — not a `trace_id` or `agent.id`. +4. Manual E2E with a real `ARCJET_KEY` is still-to-verify until you run it. + +Worked example: +[`examples/fastapi-strands-agents-guard`](https://github.com/arcjet/arcjet-py/tree/main/examples/fastapi-strands-agents-guard). +Do not invent a second example name. Do not add an example in this +skills repo.