Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,32 @@ this project adheres to [Semantic Versioning](https://semver.org/).

### Changed

- Synced the vendored skill tree with
Comment thread
cursor[bot] marked this conversation as resolved.
[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,
Expand Down
43 changes: 34 additions & 9 deletions plugins/arcjet/skills/arcjet/SKILL.md

Large diffs are not rendered by default.

411 changes: 388 additions & 23 deletions plugins/arcjet/skills/arcjet/references/guards_javascript.md

Large diffs are not rendered by default.

216 changes: 27 additions & 189 deletions plugins/arcjet/skills/arcjet/references/guards_python.md

Large diffs are not rendered by default.

11 changes: 9 additions & 2 deletions plugins/arcjet/skills/arcjet/references/requests_go.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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

Expand Down
26 changes: 25 additions & 1 deletion plugins/arcjet/skills/arcjet/references/requests_javascript.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
23 changes: 22 additions & 1 deletion plugins/arcjet/skills/arcjet/references/requests_python.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Loading