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.
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 toLlmAgent(before_tool_callback=...). This is not a wrap aroundFunctionTool. - Runner-wide / tools you did not attach a callback to →
guard_plugin. A RunnerBasePluginwhosebefore_tool_callbackis the run-wide gate. Put it first. - Correlation →
google_adk_contextreads a caller-owned id. It never mints. It never readsinvocation_id. It never readstrace_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
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: 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/inputsand 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. actoris the authenticated caller, never a model-produced tool argument. An order id or emailto=the model chose is not who is acting. Takeactorfrom 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.
- Which tools are risky (external side effects, irreversible,
spends money, sends messages)? An agent you authored gets
guard_tool. Runner-wide / unwrapped tools getguard_plugin. - What limits? (e.g. "10 lookups/min per user" →
TokenBucket.) - 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 onguard_tool/guard_plugin(session_id=...) and onrunner.run_async(..., session_id=...). That id is the correlation id, not the user. Do not useinvocation_idor a session-service auto-id. - Is an Arcjet outage unacceptable? Every helper defaults to
on_guard_error="deny". Ask explicitly about inbound screening beforerunner.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
- This is not JS
@arcjet/guard/google-adk/v2. Importarcjet.guard.google_adk. The JS adapter is plugin-only (noguardTool). - There is no inbound helper. Screen with core
guard()beforerunner.run_async. require_confirmation/request_confirmationis HITL, not policy. Do not useSecurityPluginas the Arcjet gate.- Fail closed = always return a deny dict with
arcjetDeniedon error. Do not raise, do not returnNone, and never return{}. guard_toolis a callback, not a FunctionTool wrap. Assign it toLlmAgent(before_tool_callback=...).- Do not stack
guard_pluginandguard_toolon the same tools. Plugin ALLOW still reaches the agent callback. - Put Arcjet first on
Runner(..., plugins=). - Correlation is read, never minted. Never
invocation_id, nevertrace_id, never session auto-ids. - Key rate limits on the authenticated caller, not a
model-supplied order id. Same for
actor. - Omit
actor/inputsand remote rules never fire. A resolver throw fail-closes. Useserver_input/local_input, not JSpolicyInput. - 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:
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:
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.
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.
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.
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
python -m py_compile(or the project's type-check) passes.- Exercise inbound PI (before
run_async, includinghas_failed_open()), aguard_tooldeny-dict skip (arcjetDenied: true), aguard_plugindeny-dict skip,Noneexecute, first-plugin short-circuit (Arcjet first), no-raise, never-{}, never-mint, omittedactor/inputs(remote rules silent), a resolver throw (fail-closed deny dict), and fail-closed (an unreachable guard → deny dict, neverNone). Confirmrequire_confirmationis never treated as the gate and that stacking both helpers does not double-call Guard on ALLOW. - Confirm in the Arcjet Console / CLI that decisions share the
caller-owned session / conversation id — not an
invocation_idor session auto-id — and thatactoris the authenticated caller. - Manual E2E with a real
ARCJET_KEYis still-to-verify until you run it.
Worked example:
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.