integrate-arcjet-guard-google-adk-py · git:20260922.4ae5d84 · 2026-09-22 · sha256 b153ede38e5dbdc6

integrate-arcjet-guard-google-adk-py git:20260922.4ae5d84A

Immutable. This exact content is served forever at /api/v1/blob/b153ede38e5dbdc6.

---
name: integrate-arcjet-guard-google-adk-py
description: Integrate Arcjet Guard into Python Google ADK — assign guard_tool to LlmAgent(before_tool_callback=), put guard_plugin first on Runner(plugins=) so a deny dict with arcjetDenied skips the tool, and read a caller-owned id via google_adk_context. Use when asked to add Arcjet to google-adk, Google ADK Python, rate limit those tools, screen inbound messages, or block prompt injection / PII. This is Python google-adk 2.x, not JS @google/adk and not @google/genai.
license: Apache-2.0
compatibility: Requires Python >= 3.10 and official google-adk>=2.0.0,<3 via the published arcjet[google-adk] extra (safe extra, no chromadb). Install with pip install or uv add. This is LlmAgent.before_tool_callback plus Runner BasePlugin. DENY is a skip dict with arcjetDenied.
metadata:
  author: arcjet
  type: core
  library: arcjet
---

# Integrate Arcjet Guard into Python Google ADK

`arcjet.guard.google_adk` 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 Google ADK-specific.

Official Python `google-adk>=2.0.0,<3` only — not JS `@google/adk`
(`@arcjet/guard/google-adk/v2`, docs
https://docs.arcjet.com/guards/google-adk/), not `@google/genai`,
not Go / Java ADK. Importing `arcjet.guard.google_adk` does not
load LangChain, CrewAI, or the JS adapter. The extra is safe (no
chromadb).

Exports: `guard_tool`, `guard_plugin`, `google_adk_context`. There
is no inbound helper and no approval helper.

Three surfaces, one decision rule:

- **An authored `LlmAgent`** → `guard_tool`. Assign the returned
  callback to `LlmAgent(before_tool_callback=...)`. This is not a
  wrap around `FunctionTool`.
- **Runner-wide / tools you did not attach a callback to** →
  `guard_plugin`. A Runner `BasePlugin` whose
  `before_tool_callback` is the run-wide gate. Put it **first**.
- **Correlation** → `google_adk_context` reads a caller-owned id.
  It never mints. It never reads `invocation_id`. It never reads
  `trace_id`. It never reads session-service auto-ids.

DENY is a skip dict with `arcjetDenied: true` (`ArcjetDenialResult`,
camelCase keys). ADK treats any mapping — including `{}` — as skip
and uses the dict as the tool result, except `{}` is falsy in the
callback chain and the tool would run. `None` is the only allow.
Never return `{}` to allow. Do not raise — a throw is a plugin /
callback error, not skip.

There is no `/guards/google-adk-py/` docs page. Do not invent one.
The JS adapter page is https://docs.arcjet.com/guards/google-adk/
— this skill is the Python teaching. Example:
[`examples/fastapi-google-adk-guard`](https://github.com/arcjet/arcjet-py/tree/main/examples/fastapi-google-adk-guard)
on arcjet-py `main`. Do not invent a second example name.

## The gate is `before_tool_callback` skip dict

Both helpers evaluate Guard and, on `DENY` or unevaluated Guard
under the default `on_guard_error="deny"`, return a deny dict so
the original tool function never runs. Fail closed: always return
that deny dict on error — do not return `None` (that executes the
tool) and do not raise. On ALLOW they return `None`. 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()`).

`guard_tool` is the positional callback
`(tool, args, tool_context)`. `guard_plugin` is the keyword-only
plugin callback `(*, tool, tool_args, tool_context)`. Same skip
dict. `guard_tool` requires `action`. `guard_plugin` defaults
action to `"{tool_name}.invoked"`.

PluginManager is first-win on a returned dict. If another plugin
returns a dict first, Guard never runs. On ALLOW the plugin
returns `None` and a later agent `before_tool_callback` still
runs — do not stack `guard_plugin` and `guard_tool` on the same
tools or Guard is called twice.

## `require_confirmation` is not a policy gate

`require_confirmation` / `request_confirmation` /
`adk_request_confirmation` / ADK `SecurityPlugin` / confirmation
resume is human-in-the-loop. After a human yes, Guard still runs
on the tool call. Same trap as CrewAI `human_input`, JS
`requireConfirmation`, LangGraph `interrupt()`, OpenAI Agents
`needs_approval`, and Genkit `interrupt()`. There is no inbound
helper and no approval helper.

## Screen inbound before `runner.run_async`

There is no inbound helper. Agent / model callbacks
(`before_model_callback`, `before_run_callback`,
`on_user_message_callback`) are not this policy gate. 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_plugin` already default to that.

## `actor` / `inputs` — omit and remote rules never fire

A remote policy that declares `actor` or typed `inputs` only
evaluates those values when this call sends them. Pass `actor=`
and `inputs=` on `guard_tool` / `guard_plugin` (and on inbound
`aj.guard(...)`). Build each input with `server_input` /
`local_input` from `arcjet.guard` — that is the Python
`policyInput` equivalent. Do not import JS `policyInput` and do
not invent a second helper.

- **Omit `actor` / `inputs`** and a remote policy that requires
  them never fires. The call still contacts Guard; the remote
  rules just have nothing to read.
- **A resolver that throws is degraded and fail-closes** under the
  default `on_guard_error="deny"`. The tool does not run.
- **`actor` is the authenticated caller**, never a model-produced
  tool argument. An order id or email `to=` the model chose is
  not who is acting. Take `actor` from a signed session, verified
  token, or other server-side identity.

Resolvers see the tool-call envelope: model args plus
`tool_name` (applied last so a tool argument of that name cannot
hide the callback's name).

## 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)? An agent you authored gets
   `guard_tool`. Runner-wide / unwrapped tools get `guard_plugin`.
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. That same authenticated id is
   `actor`. Put the conversation / session id you already have on
   `guard_tool` / `guard_plugin` (`session_id=...`) and on
   `runner.run_async(..., session_id=...)`. That id is the
   correlation id, not the user. Do not use `invocation_id` or a
   session-service auto-id.
4. Is an Arcjet outage unacceptable? Every helper defaults to
   `on_guard_error="deny"`. Ask explicitly about inbound screening
   before `runner.run_async`: failing closed there means the run
   does not start, 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/google-adk/v2`.** Import
   `arcjet.guard.google_adk`. The JS adapter is plugin-only (no
   `guardTool`).
2. **There is no inbound helper.** Screen with core `guard()`
   before `runner.run_async`.
3. **`require_confirmation` / `request_confirmation` is HITL, not
   policy.** Do not use `SecurityPlugin` as the Arcjet gate.
4. **Fail closed = always return a deny dict with `arcjetDenied`
   on error.** Do not raise, do not return `None`, and never
   return `{}`.
5. **`guard_tool` is a callback, not a FunctionTool wrap.** Assign
   it to `LlmAgent(before_tool_callback=...)`.
6. **Do not stack `guard_plugin` and `guard_tool` on the same
   tools.** Plugin ALLOW still reaches the agent callback.
7. **Put Arcjet first** on `Runner(..., plugins=)`.
8. **Correlation is read, never minted.** Never `invocation_id`,
   never `trace_id`, never session auto-ids.
9. **Key rate limits on the authenticated caller**, not a
   model-supplied order id. Same for `actor`.
10. **Omit `actor` / `inputs` and remote rules never fire.** A
    resolver throw fail-closes. Use `server_input` / `local_input`,
    not JS `policyInput`.
11. **Do not hand-wrap every ADK tool with raw `guard()`.**

## Step 1: Install and find the guard client

Install the published extra. Use the package manager the project
already uses — do not pin a git SHA:

```bash
pip install "arcjet[google-adk]"
uv add "arcjet[google-adk]"
```

The extra pulls `google-adk>=2.0.0,<3`. 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 an authored agent — `guard_tool`

Build the callback (and the agent) from the **current request's**
authenticated caller. Do not freeze `user_id` / `conversation_id`
at import time.

```python
from google.adk.agents import LlmAgent
from google.adk.tools import FunctionTool
from arcjet.guard import TokenBucket, launch_arcjet, server_input
from arcjet.guard.google_adk import google_adk_context, guard_plugin, 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,
)
# Per request: authenticated caller + caller-owned conversation id.
user_id = authenticated_user_id
conversation_id = authenticated_conversation_id

def lookup_order(order_id: str) -> dict:
    """Look up an order by ID."""
    return {"order_id": order_id, "status": "shipped"}

# require_confirmation=True is HITL — not this policy gate
lookup = FunctionTool(func=lookup_order)

agent = LlmAgent(
    name="support_agent",
    description="Help the user.",
    instruction="Help the user.",
    tools=[lookup],
    before_tool_callback=guard_tool(
        guard=aj,
        action="order.looked-up",
        # Authenticated caller — never a model-produced order id.
        actor=user_id,
        inputs=lambda call: {
            "order_id": server_input.string(str(call.get("order_id", ""))),
        },
        rules=[lookup_limit(key=user_id, requested=1)],
        session_id=conversation_id,
        on_guard_error="deny",
    ),
)
```

Use `action` + `rules` on `guard_tool`. `action` may be a function
of the tool-call envelope (`tool_name` plus `input`). Empty `rules`
still contacts Guard. Key rate limits on the authenticated caller,
not a model-supplied order id.

## Step 3: Gate the Runner — `guard_plugin`

Use this for tools you did not attach `guard_tool` to. Put Arcjet
**first**. Do not also assign `guard_tool` on those same agents.

```python
from google.adk.runners import Runner
from google.adk.sessions import InMemorySessionService

session_service = InMemorySessionService()
runner = Runner(
    app_name="support",
    agent=agent,
    session_service=session_service,
    # Arcjet first: a deny dict skips the tool before later plugins run.
    plugins=[
        guard_plugin(
            guard=aj,
            action=lambda call: f"{call['tool_name']}.invoked",
            actor=user_id,
            inputs=lambda call: {
                "tool": server_input.string(str(call.get("tool_name", ""))),
            },
            rules=[lookup_limit(key=user_id, requested=1)],
            session_id=conversation_id,
            on_guard_error="deny",
        ),
    ],
)
```

## Step 4: Screen inbound before `runner.run_async`

`google_adk_context` accepts `sessionId` or `session_id` (same for
`correlationId` / `conversationId`). CamelCase is not required.

```python
from google.genai import types
from arcjet.guard import DetectPromptInjection

inbound = DetectPromptInjection()
app_context = {"sessionId": conversation_id}
derived = google_adk_context(app_context)
decision = await aj.guard(
    label="message.received",
    actor=user_id,
    inputs={"content": server_input.string(user_text)},
    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")

async for event in runner.run_async(
    user_id=user_id,
    session_id=conversation_id,
    new_message=types.Content(
        role="user",
        parts=[types.Part(text=user_text)],
    ),
):
    _ = event
```

There is no inbound helper.

## Step 5: Correlation

`google_adk_context` reads a caller-owned id. Preference:
`correlationId` / `correlation_id`, then `sessionId` /
`session_id`, then `conversationId` / `conversation_id` on the
object (or a bare mapping), then the same names on application-owned
`state`, then `correlation_id=` / `session_id=` /
`conversation_id=` kwargs, then an enclosing `arcjet_sequence`.
It never mints an id. It never reads `invocation_id` (ADK always
generates it). It never reads `trace_id`. It never reads
`toolContext.sessionId` / `session.id` (session auto-ids) and
never walks into `.session`. Do not invent a correlation id per
turn. Put the same id on the helper *and* on
`runner.run_async(...)`. 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 `run_async`, including
   `has_failed_open()`), a `guard_tool` deny-dict skip
   (`arcjetDenied: true`), a `guard_plugin` deny-dict skip, `None`
   execute, first-plugin short-circuit (Arcjet first), no-raise,
   never-`{}`, never-mint, omitted `actor` / `inputs` (remote
   rules silent), a resolver throw (fail-closed deny dict), and
   fail-closed (an unreachable guard → deny dict, never `None`).
   Confirm `require_confirmation` is never treated as the gate
   and that stacking both helpers does not double-call Guard on
   ALLOW.
3. Confirm in the Arcjet Console / CLI that decisions share the
   caller-owned session / conversation id — not an `invocation_id`
   or session auto-id — and that `actor` is the authenticated
   caller.
4. Manual E2E with a real `ARCJET_KEY` is still-to-verify until you
   run it.

Worked example:
[`examples/fastapi-google-adk-guard`](https://github.com/arcjet/arcjet-py/tree/main/examples/fastapi-google-adk-guard)
on arcjet-py `main`. Do not invent a second example name. Do not add
an example in this skills repo.