AI agent security

Google ADK security guide

Use this eight-item guide on @google/adk 2.x TypeScript. Pin ADK and import the versioned adapter path, keep keys out of session state, screen text before runner.runAsync, don't treat requestConfirmation or SecurityPlugin as a gate, put guardPlugin first in the Runner plugins list, deny by returning the deny dict rather than throwing, correlate on an ID you own, and run the same scanners in CI that you run in the editor.

12 min read
In short: Use this eight-item guide on @google/adk 2.x TypeScript. Pin ADK and import the versioned adapter path, keep keys out of session state, screen text before runner.runAsync, don't treat requestConfirmation or SecurityPlugin as a gate, put guardPlugin first in the Runner plugins list, deny by returning the deny dict rather than throwing, correlate on an ID you own, and run the same scanners in CI that you run in the editor.

What is the Google ADK security guide?

Use this guide on Google Agent Development Kit (ADK) TypeScript, @google/adk 2.x.

ADK builds a LlmAgent from a model, an instruction, and a list of tools. Runner.runAsync drives the loop: the model reads user text, decides to call a FunctionTool, and ADK executes that tool's execute handler. Prompt injection is when untrusted text – a pasted ticket, a retrieved document, a tool result from an earlier turn – steers that decision toward a side effect you never intended.

You need two separate controls. Screen inbound text before runner.runAsync, then gate every tool call inside the run. ADK gives you exactly one place for the second control: a BasePlugin.beforeToolCallback on the Runner. Everything else in ADK either observes or asks a human.

Arcjet Guard is a runtime policy layer for AI agents. You define rules – prompt-injection detectors, rate limits, PII checks, allowlists – and Guard allows or denies inbound text and tool calls before side effects run. The examples below use Guard for both controls through @arcjet/guard/google-adk/v2.

This adapter is ADK JavaScript only. It isn't Google GenAI (@google/genai), and it isn't Python ADK. HTTP request protection on a route never sees runAsync, so a WAF in front of the app doesn't cover any of this.

Work through these eight topics in order. Each one is a control you can verify, not a slogan.

  • Patch and lock dependencies. Stay on @google/adk 2.x and import the versioned adapter path.
  • Keep model keys off the client and out of session state.
  • Screen user text with a direct guard() call before runner.runAsync.
  • Don't treat requestConfirmation or ADK SecurityPlugin as a policy gate.
  • Gate every tool call with guardPlugin, first in the Runner plugins list.
  • Deny by returning the deny dict. Never throw, and never return undefined on error.
  • Correlate on an ID you own. Never on an ADK-generated one.
  • Catch issues in the editor and in CI before they ship.

For the Guard helpers used in the examples, see the Google ADK agent guard.

How do you keep Google ADK dependencies safe?

Apply patches on a schedule you actually keep, and out of band for a critical advisory. Commit a lockfile so every environment installs the same graph, and turn on Dependabot, Renovate, or an equivalent bot for npm. Run npm audit in CI and fail on known high-severity issues you haven't waived.

@google/adk is an optional peer of @arcjet/guard (>=2 <3), not a dependency of it. If ADK is already in your project at that range, install @arcjet/guard on its own so your existing pins don't move. The integration requires Node.js 22 or later.

Terminal window
npm install @arcjet/guard @google/adk

Import from the versioned path. The version segment is the ADK major, and there is no unversioned alias – @arcjet/guard/google-adk does not resolve.

import { guardPlugin, googleAdkContext } from "@arcjet/guard/google-adk/v2";

Pin @google/adk so a 3.x major can't land under that import. An ADK major is the thing most likely to move the tool-call callback contract, and the adapter path is versioned precisely so the mismatch is a resolution error rather than a silently skipped gate. See trivial packages and dependency confusion.

How should Google ADK secrets be handled?

Model credentials – GOOGLE_API_KEY, a Vertex AI service account, ARCJET_KEY – belong on the server, loaded at process start. Launch one Arcjet client at module scope and reuse it, rather than constructing one per request.

import { launchArcjet } from "@arcjet/guard";
export const arcjet = launchArcjet({ key: process.env.ARCJET_KEY! });

Don't put credentials in ADK session state. Session state is read back into model context on later turns, which makes it reachable by anything that can influence the transcript. It is for caller-owned identifiers – a conversation ID, a tenant ID – not secrets.

Don't log raw tool arguments or the raw prompt. Tool arguments are model-generated from user text, so they carry whatever the user put in. See storing secrets in environment variables and redacting sensitive data from logs.

How do you screen inbound Google ADK text?

ADK has no inbound hook, so there is no guardInbound on this adapter. Screen in application code before runner.runAsync, while the user's text is still a string you control and not yet part of the agent's turn history.

Direct guard() fails open: if Guard can't be evaluated, the call returns ALLOW. That means an ALLOW on its own is not proof the rules ran. Gate on decision.hasFailedOpen() when this call site must fail closed. On a deny, don't call runAsync at all.

import { detectPromptInjection } from "@arcjet/guard";
import { googleAdkContext } from "@arcjet/guard/google-adk/v2";
import { arcjet } from "./arcjet.js";
const inbound = detectPromptInjection();
const appContext = { sessionId: conversationId };
const decision = await arcjet.guard({
label: "message.received",
rules: [inbound(userText)],
...googleAdkContext(appContext),
});
if (decision.conclusion === "DENY" || decision.hasFailedOpen()) {
throw new Error("message blocked");
}

Note the field name. A direct guard() call takes label. A framework wrapper such as guardPlugin takes action for the same slug. Passing label to a wrapper is a type error, and passing action to guard() won't name the event.

A clean inbound score does not authorize a tool call. Inbound screening decides whether the model sees the prompt; it says nothing about whether the refund tool may run. That is the second control, and it is separate. See prompt injection detection.

Why isn't requestConfirmation a Google ADK security policy?

ADK can pause a run and wait for a person. requireConfirmation on a tool and requestConfirmation in a handler are human-in-the-loop confirmation. They're useful when someone must review context, and they are not a policy gate: they ask, they don't decide, and nothing evaluates a rule.

This is the same trap under different names across frameworks – LangChain humanInTheLoopMiddleware, Strands event.interrupt(), Genkit interrupt(), OpenAI needsApproval, Mastra requireApproval, TanStack needsApproval, Claude canUseTool. There is no guardApproval on the ADK adapter.

ADK's own SecurityPlugin is likewise not the Arcjet policy gate. Don't wire Arcjet into it, and don't express a denial by pausing for a human. Policy sits on beforeToolCallback only. See human approval is not a security policy and needsApproval and interrupt() are not a policy.

How do you gate Google ADK tool calls?

There is no guardTool on this adapter. Wrapping FunctionTool.execute is the wrong gate, because ADK's skip point is the Runner callback, not the handler. The gate is guardPlugin, a BasePlugin you pass on new Runner({ plugins }) or new InMemoryRunner({ plugins }).

Its beforeToolCallback returns one of two things. A deny dict – an ArcjetDenialResult – skips the tool, and execute never runs. undefined executes the tool. It never throws.

Put it first in the plugin list. beforeToolCallback uses the first dictionary any plugin returns. If another plugin returns a dict ahead of Arcjet, Guard never runs and the gate is silently gone.

import { localDetectSensitiveInfo, tokenBucket } from "@arcjet/guard";
import { guardPlugin } from "@arcjet/guard/google-adk/v2";
import { FunctionTool, InMemoryRunner, LlmAgent } from "@google/adk";
import { z } from "zod";
import { arcjet } from "./arcjet.js";
const lookupLimit = tokenBucket({
bucket: "lookups",
refillRate: 10,
intervalSeconds: 60,
maxTokens: 10,
});
const detectPii = localDetectSensitiveInfo({
deny: ["EMAIL", "PHONE_NUMBER", "IP_ADDRESS", "CREDIT_CARD_NUMBER"],
});
const lookupOrderInput = z.object({
orderNumber: z.string(),
note: z.string(),
});
const lookupOrder = new FunctionTool({
name: "lookup_order",
description: "Look up an order by number",
parameters: lookupOrderInput,
execute: ({ orderNumber, note }) => ({
orderNumber,
note,
status: "shipped",
}),
});
const agent = new LlmAgent({
name: "order_agent",
model: "gemini-flash-latest",
instruction: "Look up orders with lookup_order.",
tools: [lookupOrder],
});
const runner = new InMemoryRunner({
agent,
appName: "orders",
plugins: [
guardPlugin(arcjet, {
action: "order.looked-up",
sessionId: conversationId,
onGuardError: "deny",
rules: ({ toolName, input }) => {
if (toolName !== "lookup_order") {
return [];
}
const { orderNumber, note } = lookupOrderInput.parse(input);
return [
lookupLimit({ key: orderNumber, requested: 1 }),
detectPii(note),
];
},
}),
],
});

guardPlugin is Runner-wide, so rules receives { toolName, input } for every tool the agent calls. Branch on toolName and return [] for the tools you aren't scoring. The guard call still happens, which is what puts the decision in the Console; returning [] only means you submitted no SDK rules for it.

Re-parse input with the tool's own schema rather than trusting its shape. The model produced those arguments, so they are untrusted input in the same sense the prompt was.

Key the rate limit on a trusted identifier such as orderNumber. Don't key a bucket on free-text the user wrote, or they choose their own bucket and the limit does nothing.

Scan the free-text fields – a note, a reason, a body. An opaque orderNumber or tool-call ID won't trip email, phone, card, or IP detection, so passing it to localDetectSensitiveInfo adds cost and no coverage. That helper runs on a local model backend, so the text never leaves your process. See detecting and redacting PII in LLM inputs and outputs.

What does a Google ADK denial look like?

On DENY the original tool never runs and the model receives an ArcjetDenialResult. This is a returned value, not a thrown error – a throw is the wrong envelope, and ADK will surface it as a crash instead of a result the model can read.

import type { ArcjetDenialResult } from "@arcjet/guard/google-adk/v2";
const denial: ArcjetDenialResult = {
arcjetDenied: true,
reason: "RATE_LIMIT", // or PROMPT_INJECTION, SENSITIVE_INFO, ERROR
message:
"Arcjet denied this call (RATE_LIMIT). It may be retried after 30 seconds.",
retryable: true,
retryAfterSeconds: 30,
};

Only rate-limit denials set retryable: true and carry retryAfterSeconds. Every other reason tells the model not to retry, which matters: a model that retries a SENSITIVE_INFO denial in a loop burns tokens against a decision that will never change.

guardPlugin defaults to onGuardError: "deny". When Guard can't be evaluated, the callback returns the deny dict with reason: "ERROR", retryable: true, and retryAfterSeconds: 5. It never throws on that path, and it never returns undefined – returning undefined on an error would execute the tool without a decision, which is the failure mode the default exists to prevent.

Set onGuardError: "allow" only where executing without a complete security decision is genuinely acceptable, such as a read-only lookup with no side effect. Remember that direct guard() still fails open regardless, which is why the inbound example gates on hasFailedOpen().

How do you correlate a Google ADK run?

googleAdkContext reads a caller-owned ID from the object you pass: correlationId first, then sessionId, then conversationId. It never mints an ID.

It deliberately ignores every ADK-generated value – traceId, an ADK-generated invocationId, toolContext.sessionId, and session.id. ADK will generate a session or invocation ID when you omit one, and joining decisions on an auto-generated value produces a Sequence that looks correlated but groups unrelated runs. If you didn't pass an ID, the call is uncorrelated, which is the honest outcome. Don't derive one from a generated value to fill the gap.

Put the same ID on runAsync and on guardPlugin, so the inbound decision and every tool decision land on one Sequence.

const appContext = { sessionId: conversationId };
await arcjet.guard({
label: "message.received",
...googleAdkContext(appContext),
});
await runner.runAsync({
userId: conversationId,
sessionId: conversationId,
newMessage: { parts: [{ text: userText }] },
});

A run that pauses on requestConfirmation resumes through a later runAsync. Pass the same caller-owned ID on that resume call, or the second half of the conversation detaches from the first. The confirmation payload itself is not a correlation source.

Don't call createAgentContext inside an ADK callback.

How does the editor and CI catch Google ADK security bugs?

Turn on TypeScript strict, ESLint, and secret scanning in the editor. Trunk, Semgrep, TruffleHog, and Gitleaks all have editor plugins and CI jobs. Run the same scanners in CI that you run locally – an editor warning that isn't a CI failure gets ignored.

Fail the build on secrets in the diff and on known vulnerable dependencies. TypeScript catches the adapter mistakes that matter most here: label passed to guardPlugin, an unversioned @arcjet/guard/google-adk import, or a rules callback that destructures input without parsing it.

What automation won't catch is plugin ordering. Nothing type-checks that guardPlugin sits first in plugins, and a Guard that never runs looks exactly like a Guard that always allows. Make that a review item, and verify it against a real denial rather than inferring it from the absence of a side effect.

How do you verify the gate actually fires?

A missing decision is not a denial. If the model asks a clarifying question instead of calling the guarded tool, nothing is sent, no guard call happens, and no decision is returned. From the outside that is indistinguishable from a working gate, because in both cases the side effect didn't happen.

Read the decision in the Arcjet Console or your logs rather than concluding from the absence of a side effect. Two things make a test agent cooperative: a system prompt telling it to complete the request without follow-up questions, and one telling it to quote retrieved values verbatim. A model that helpfully masks a card number itself leaves the rule nothing to detect, and Guard then correctly allows – which reads as a broken rule when it isn't.

Check the three failure modes explicitly. Confirm a denial when a rate limit is exhausted, confirm a denial when a free-text argument carries PII, and confirm the tool doesn't run when Guard is unreachable and onGuardError is "deny". See functional testing of security rules.

What do you do after this guide?

Confirm each item against one sensitive run: a prompt that asks for a refund, an authored lookup that includes a free-text note, and a second plugin in the list to prove ordering behaves as you expect.

Then apply AI agent runtime security for the sequence, budget, and identity controls that aren't ADK-specific, and enforce token spend budgets for the cost side of an agent loop that can call tools repeatedly.

Frequently asked questions

What is the Google ADK security guide?

Use this eight-item guide on @google/adk 2.x TypeScript. Pin ADK and import the versioned adapter path, keep keys out of session state, screen text before runner.runAsync, don't treat requestConfirmation or SecurityPlugin as a gate, put guardPlugin first in the Runner plugins list, deny by returning the deny dict rather than throwing, correlate on an ID you own, and run the same scanners in CI that you run in the editor.

Is there a guardTool for Google ADK?

No. Wrapping FunctionTool.execute is the wrong gate. Policy sits on beforeToolCallback through guardPlugin, which skips the tool by returning a deny dict.

Why must guardPlugin be first in the plugins list?

beforeToolCallback uses the first dictionary any plugin returns. If another plugin returns one ahead of Arcjet, Guard never runs and the gate is silently gone.

Does this cover Python ADK or Google GenAI?

No. This adapter is ADK JavaScript (@google/adk 2.x). It isn't Python ADK and it isn't Google GenAI (@google/genai). Don't wrap these tools with the Vercel AI SDK or Genkit adapters.

Can I correlate on the ADK invocation id?

No. googleAdkContext reads correlationId, then sessionId, then conversationId from the object you pass. It ignores traceId, an ADK-generated invocationId, toolContext.sessionId, and session.id. Without a caller-owned ID the call is uncorrelated.

AI runtime security in your code

Protect your AI agent workflows with Arcjet

Arcjet guards run inside the tool, so the allow or deny arrives before the side effect rather than after it.