Skip to content
Open
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
27 changes: 27 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -205,3 +205,30 @@ jobs:
run: |
echo "::error::macOS test matrix result: ${{ needs.test-macos.result }}"
exit 1

# The Claude Agent SDK integration tests need the optional
# `claude-agent-sdk` extra, which the default install leaves out, so the
# jobs above skip them. This job installs it from the lockfile. The CLI
# tests drive the CLI bundled with that SDK against a scripted, local
# Messages API, with no network access and no key. They are what catches a
# CLI release that changes a built-in tool's output shape: the CLI sends
# the original tool result to the model, without saying so, when a
# replacement's shape does not match.
test-claude-agent-sdk:
name: Run tests (claude-agent-sdk)
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
- name: Check the extra imports
# The tests skip when the extra is missing, so an install that left it
# out would otherwise pass with nothing run.
run: uv run --extra claude-agent-sdk python -c "import claude_agent_sdk"
- name: Tests
run: >-
uv run --extra claude-agent-sdk pytest --no-cov
tests/integration/guard/test_claude_agent_sdk.py
tests/integration/guard/test_claude_agent_sdk_cli.py
24 changes: 14 additions & 10 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,20 +114,24 @@ integration files, which is what CI does.
### Claude Agent SDK tests

`arcjet[claude-agent-sdk]` is a real extra (`claude-agent-sdk>=0.2.127,<1`) but
is in no dependency group, so the lockfile does not pull its transitive tree
into every `uv sync`. Install it alongside the locked environment when you want
integration coverage:
is in no dependency group, so a plain `uv sync` leaves it out. The lockfile
pins it, so run the integration files with the extra from the lock:

```sh
uv sync
uv pip install "claude-agent-sdk>=0.2.127,<1"
# `--no-sync`, or uv restores the lock and removes claude-agent-sdk again
uv run --no-sync pytest tests/integration/guard/test_claude_agent_sdk.py --no-cov
uv run --extra claude-agent-sdk pytest --no-cov \
tests/integration/guard/test_claude_agent_sdk.py \
tests/integration/guard/test_claude_agent_sdk_cli.py
```

`tests/unit/guard/test_claude_agent_sdk.py` imports no `claude_agent_sdk` and
always runs, including when the extra is absent. `just test` on a plain
`uv sync` skips the integration file, which is what CI does.
`test_claude_agent_sdk_cli.py` drives the CLI bundled with the SDK against a
scripted, local Messages API, with no network access and no key, and checks
what the model would receive. A CLI release that changes a built-in tool's
output shape fails it.

`tests/unit/guard/test_claude_agent_sdk.py` and
`test_claude_agent_sdk_tool_results.py` import no `claude_agent_sdk` and always
run, including when the extra is absent. `just test` on a plain `uv sync` skips
the integration files; CI runs them in the `Run tests (claude-agent-sdk)` job.

### Claude Managed Agents tests

Expand Down
80 changes: 77 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1006,7 +1006,7 @@ effect happens.
| A CrewAI `BaseTool` you call yourself | `guard_tool` (`arcjet.guard.crewai`) | your own `crewai` | Yes |
| An OpenAI Agents authored `FunctionTool` | `guard_tool` (`arcjet.guard.openai_agents`) | `arcjet[openai-agents]` | Yes — via `reject_content` |
| A Claude Agent SDK authored `@tool` / `SdkMcpTool` | `guard_tool` (`arcjet.guard.claude_agent_sdk`) | `arcjet[claude-agent-sdk]` | Yes — via `is_error` tool result |
| Unwrapped Claude built-ins / MCP, or inbound user text | `guard_hooks` (`arcjet.guard.claude_agent_sdk`) | `arcjet[claude-agent-sdk]` | Yes — `PreToolUse` deny / `UserPromptSubmit` block |
| Unwrapped Claude built-ins / MCP, or inbound user text | `guard_hooks` (`arcjet.guard.claude_agent_sdk`) | `arcjet[claude-agent-sdk]` | Yes — `PreToolUse` deny / `UserPromptSubmit` block / `PostToolUse` result replaced or run stopped |
| A Claude Managed Agents custom tool (`agent.custom_tool_use`) | `guard_custom_tool` (`arcjet.guard.claude_managed_agents`) | `arcjet[claude-managed-agents]` | Yes — `user.custom_tool_result` (does not run the tool) |
| Claude Managed Agents inbound `user.message` / `initial_events` | `guard_events` (`arcjet.guard.claude_managed_agents`) | `arcjet[claude-managed-agents]` | Yes — does not call `sessions.events.send` |
| A Strands Agents authored `@tool` | `guard_tool` (`arcjet.guard.strands_agents`) | `arcjet[strands-agents]` | Yes — error-status JSON `ArcjetDenialResult` |
Expand Down Expand Up @@ -1694,6 +1694,79 @@ rules on `guard_hooks(..., inbound=...)`. A deny is `{"decision": "block"}`.
`protect()` and the request path are fail-open — the caller must check
`has_failed_open()`. These helpers default to `on_guard_error="deny"`.

#### Screen an authored tool's result

`guard_tool(..., result_rules=...)` makes a second Guard call after the
handler returns and before the model reads its result. The callable receives
`{"arguments": ..., "result": ..., "text": ...}`: `result` is the handler's
return value unchanged and `text` is every text block in it, joined in order
and never shortened. A DENY, or an unevaluated policy under the default
`on_guard_error="deny"`, returns the same `is_error` envelope in place of the
result. The call is labelled `result_action`, which defaults to the tool's
`action` followed by `.result`, so remote policy and rate limits on `action`
see each call once.

```py
guarded_fetch = guard_tool(
guard=aj,
tool=fetch_page,
action="page.fetched",
session_id=session_id,
result_rules=lambda r: [DetectPromptInjection()(r["text"])],
)
```

A detector that cannot return a verdict, for example because a long result
takes longer to screen than the service allows, leaves the policy unevaluated.
Under the default `on_guard_error="deny"` that replaces the result with the
denial envelope (`"reason": "ERROR"`, `"retryable": true`), so a tool that
returns long text can be refused more often than its content warrants. Choose
`on_guard_error` for the tool deliberately rather than relying on the default.

This covers tools you write. To screen the results of built-ins and other
MCP servers, use `guard_hooks(..., tool_results=...)` below.

#### Screen every tool's result on `PostToolUse`

`guard_hooks(..., tool_results=...)` screens each tool's result before the
model reads it. `rules` is required: a callable of
`{"tool_name": ..., "tool_input": ..., "tool_response": ..., "text": ...}`,
where `text` is the result's text, never shortened. `action` defaults to
`"{tool_name}.result"`; `actor`, `metadata`, `on_guard_error`, `session_id`
and `guard` work as they do for `inbound`. No policy inputs are sent.

```py
hooks = guard_hooks(
guard=aj,
session_id=session_id,
tool_results={
"rules": lambda r: [DetectPromptInjection()(r["text"])],
},
)
```

On DENY, or an unevaluated policy under the default `on_guard_error="deny"`,
what happens depends on the tool:

| Tool | On DENY |
| --- | --- |
| Any MCP tool (`mcp__…`) | The result is replaced with the denial envelope. |
| `Bash`, `Read` (`REPLACEABLE_BUILTIN_TOOLS`) | The result is replaced with the denial envelope, in that tool's output shape. |
| Any other tool | The run stops (`continue: false`) before the model's next turn. |

Claude Code validates a replacement for a built-in tool against that tool's
output schema, which is not published, and sends the original result when it
does not match. A tool is listed only once a test drives its denial through
the bundled CLI, and every other tool stops the run instead. A stopped session
still holds the original result, so do not resume it; its capture records
`claude.denial: run_stopped`. Where stopping is not acceptable, keep the tools
`can_replace_result()` rejects out of `allowed_tools`. Subagent (`Agent`)
output arrives after `PostToolUse` and is not screened.

`PostToolUse` hooks run in parallel on the original result and the last
rewrite wins, so another hook that rewrites a result can replace the denial.
The timeout caveat above applies here too.

#### `can_use_tool` is not a policy gate

`can_use_tool` is ask-only HITL. `allowed_tools`, allow rules, and
Expand All @@ -1709,8 +1782,9 @@ are gated here. `PreToolUse` returns `permissionDecision: "deny"`.
plus `tool_name` so a local rate limit can key on the name. List every
`guard_tool` wrapper in `exclude` as `{"server": ..., "name": ...}` so the
reported `mcp__{server}__{name}` name is not double-gated. A bare authored
name does not match every server's tool of that name. `PostToolUse` is
capture-only (`claude.phase: after`) and is not skipped by `exclude`.
name does not match every server's tool of that name. `PostToolUse`
captures each call (`claude.phase: after`) and is not skipped by `exclude`;
it blocks nothing unless `tool_results=` is set.
`actor=` / `inputs=` next to `inbound=` do not install a tool gate; pass
`tools=True` for the default `{tool_name}.invoked` gate with empty rules.

Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ exclude = [
"tests/integration/guard/test_openai_agents_runner.py",
# Optional extra; claude-agent-sdk is not in the default install.
"tests/integration/guard/test_claude_agent_sdk.py",
"tests/integration/guard/test_claude_agent_sdk_cli.py",
# Optional extra; strands-agents is not in the default install.
"tests/integration/guard/test_strands_agents.py",
# Optional extra; google-adk is not in the default install.
Expand Down Expand Up @@ -261,6 +262,7 @@ exclude = [
"tests/integration/guard/test_openai_agents_runner.py",
# Optional extra; claude-agent-sdk is not in the default install.
"tests/integration/guard/test_claude_agent_sdk.py",
"tests/integration/guard/test_claude_agent_sdk_cli.py",
# Optional extra; strands-agents is not in the default install.
"tests/integration/guard/test_strands_agents.py",
# Optional extra; google-adk is not in the default install.
Expand Down
28 changes: 18 additions & 10 deletions src/arcjet/guard/claude_agent_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,27 @@
:mod:`arcjet.guard.langchain`, :mod:`arcjet.guard.crewai`, or
:mod:`arcjet.guard.openai_agents`.

Three names:
The names:

* :func:`guard_tool` — wrap an authored ``@tool`` / ``SdkMcpTool`` so the
handler never runs on DENY. The deny is an MCP tool result
handler never runs on DENY, and with ``result_rules=`` screen what it
returns. The deny is an MCP tool result
``{"content": [{"type": "text", "text": <json>}], "is_error": True}``.
Do not throw (the SDK swallows into ``str(e)``). Do not set
``structuredContent`` (``create_sdk_mcp_server`` drops it).
* :func:`guard_hooks` — ``PreToolUse`` ``permissionDecision: "deny"`` for
unwrapped built-ins / MCP, ``PostToolUse`` capture-only
(``claude.phase: after``), and ``UserPromptSubmit`` ``{"decision":
"block"}`` when ``inbound=`` is set. List ``guard_tool`` wrappers in
``exclude`` as ``{"server": ..., "name": ...}`` so they are not
double-gated under the ``mcp__{server}__{name}`` name. ``exclude``
does not skip PostToolUse. ``actor=`` / ``inputs=`` do not register
a tool hook by themselves; pass ``tools=True`` for the default
``"{tool_name}.invoked"`` gate.
unwrapped built-ins / MCP, ``PostToolUse`` capture
(``claude.phase: after``) and, with ``tool_results=``, a screen of every
tool's result, and ``UserPromptSubmit`` ``{"decision": "block"}`` when
``inbound=`` is set. List ``guard_tool`` wrappers in ``exclude`` as
``{"server": ..., "name": ...}`` so they are not double-gated under the
``mcp__{server}__{name}`` name. ``exclude`` does not skip PostToolUse.
``actor=`` / ``inputs=`` do not register a tool hook by themselves; pass
``tools=True`` for the default ``"{tool_name}.invoked"`` gate.
* :data:`REPLACEABLE_BUILTIN_TOOLS` and :func:`can_replace_result` — the
tools whose denied result ``tool_results=`` replaces. A denied result
from any other tool stops the run, so keep those tools out of
``allowed_tools`` when that matters.
* :func:`claude_agent_context` — read a caller-owned ``session_id`` from
hook input or a policy fallback. Never mints. Never reads ``trace_id``.
An authored handler has no ``extra.session_id`` — use
Expand All @@ -42,9 +47,12 @@
from ._context import claude_agent_context
from ._hooks import guard_hooks
from ._tool import guard_tool
from ._tool_results import REPLACEABLE_BUILTIN_TOOLS, can_replace_result

__all__ = [
"guard_tool",
"guard_hooks",
"claude_agent_context",
"REPLACEABLE_BUILTIN_TOOLS",
"can_replace_result",
]
Loading
Loading