ADK ToolCallIntegrityPlugin: Tamper Test Results
October 8, 2026

TL;DR: Google's Agent Development Kit (ADK) for TypeScript added ToolCallIntegrityPlugin in version 2.2.1, published October 6, 2026. It signs each tool call with HMAC-SHA256. In my test, a rewritten approved call ran without the plugin and was blocked with it.12
One setting brings the hole back, allowUnstampedCalls: true.3 If your tools run inside Workflow nodes, read the Workflow section first, because the plugin blocked my legitimate call there.34 The numbers and the full scripts are below, so you can run them yourself.
ToolCallIntegrityPlugin in one paragraph
ToolCallIntegrityPlugin is an optional ADK plugin that checks that a tool call sitting in the session is the same call that ends up running. The pull request that added it names the risk directly: a stored call "can be rewritten by anyone with write access to the session store, for example between a human approving a call and the agent running it."5
To close that gap, the plugin stamps every function call with an HMAC-SHA256 over the session, invocation, branch, author and call.25 Then it refuses to run any call whose stamp doesn't verify.5
What you'll learn
- What the plugin signs, and which ADK for TypeScript version first shipped it
- How to reproduce a tampered approval with a scripted model and no API key
- Which edits the plugin blocked in my tests, and the one setting that turns it into a log line
- How key rotation, copied sessions and restored history behave
- Why the plugin blocked a legitimate call inside a Workflow node
- What the plugin cost per turn in a small benchmark
What shipped in ADK 2.2.1
ADK for TypeScript is one of the language versions of the Agent Development Kit, which the project says is available in Python, TypeScript, Go, Java and Kotlin.6 You install the TypeScript one with npm install @google/adk.6
The plugin landed on the main branch on October 2, 2026 (UTC) in pull request #979.5 It was cherry-picked into the 2.2.x branch in pull request #998, merged on October 6.2
Version 2.2.0 came out on September 30 and 2.2.1 on October 6.1 I unpacked both tarballs. The 2.2.0 package has no integrity plugin file and 2.2.1 does.3
The pull request says it ports google/adk-python@b7efc4e, and that Python commit says it "improves the TOCTOU posture of stored tool calls."57 TOCTOU stands for time-of-check to time-of-use, which is the gap between approving a call and running it. The author also wrote that cross-language verification hadn't been tested.5 The ADK docs repository added reference pages for the plugin on October 7.8
How the plugin works
The plugin's own type documentation describes three callbacks.9
When an event contains function calls, onEventCallback adds "v1:" + HMAC-SHA256(key, payload) to the event's customMetadata. At the start of every run, beforeRunCallback re-checks each function call with an ID in the session history. Right before a tool executes, beforeToolCallback lets it run only if the stored call verifies and its name and arguments match what is about to execute.
The pull request says the stamped payload covers app, user, session, invocation, branch, author, call name, call ID and args.5 The stamp lives under the __adk_internal_fc_hmac key, and that key and the "restored event" marker both use the reserved __adk_internal_ metadata prefix.25
Reproduce a tampered approval
My scenario is a payment agent with a transfer_money tool set to requireConfirmation: true. The agent asks for approval and a human approves a transfer of 10 to alice.
Between the request and the approval, someone with write access to the session store changes the stored amount to 10000. I simulate that by editing events inside InMemorySessionService, which stands in for a database you don't fully control.
You don't need an API key. The script uses a ScriptedLlm that replays two canned replies. I installed the published package in a clean folder.
mkdir adk-integrity && cd adk-integrity
npm init -y && npm pkg set type=module
npm i @google/adk@2.2.1 zod@4.6.5 tsx typescript @types/node
ADK 2.2.1 declares zod ^4.2.1 as a dependency, so I used Zod 4. Save the script below as integrity-test.ts.3
import {
App, BaseLlm, BaseLlmConnection, Event, FunctionTool, getFunctionCalls,
InMemorySessionService, LlmAgent, LlmRequest, LlmResponse, Runner, Session,
REQUEST_CONFIRMATION_FUNCTION_CALL_NAME as CONFIRM, RESTORED_EVENT_KEY, ToolCallIntegrityPlugin,
} from '@google/adk';
import type {Content, FunctionCall} from '@google/genai';
import {z} from 'zod';
const APP = 'bank_app', USER = 'u1';
const enc = (s: string) => new TextEncoder().encode(s);
const KEY_A = enc('key-A-demo-secret'), KEY_B = enc('key-B-demo-secret');
const STAMP = '__adk_internal_fc_hmac';
// A model that replays canned responses: no API key needed.
class ScriptedLlm extends BaseLlm {
private i = 0;
constructor(private replies: LlmResponse[]) { super({model: 'scripted'}); }
async *generateContentAsync(_q: LlmRequest) {
yield this.replies[this.i++] ?? {content: {role: 'model', parts: [{text: 'Done.'}]}};
}
async connect(_q: LlmRequest): Promise<BaseLlmConnection> { throw new Error('n/a'); }
}
function build(plugin?: ToolCallIntegrityPlugin) {
const transfers: Array<{amount: number; recipient: string}> = [];
const tool = new FunctionTool({
name: 'transfer_money', description: 'Transfers money.',
parameters: z.object({amount: z.number(), recipient: z.string()}),
requireConfirmation: true, // pauses the run until a human approves
execute: (input) => { transfers.push(input); return {status: 'done'}; },
});
const model = new ScriptedLlm([
{content: {role: 'model', parts: [{functionCall: {name: 'transfer_money', args: {amount: 10, recipient: 'alice'}}}]}},
{content: {role: 'model', parts: [{text: 'Sent.'}]}},
]);
const sessions = new InMemorySessionService();
const runner = new Runner({
app: new App({name: APP, rootAgent: new LlmAgent({name: 'bank_agent', model, tools: [tool]}), plugins: plugin ? [plugin] : []}),
sessionService: sessions,
});
return {transfers, sessions, runner};
}
type Rig = ReturnType<typeof build>;
async function run(r: Runner, sessionId: string, msg: Content): Promise<Event[]> {
const out: Event[] = [];
for await (const e of r.runAsync({userId: USER, sessionId, newMessage: msg})) out.push(e);
return out;
}
// Reach into the in-memory store the way an attacker with write access would.
const events = (svc: InMemorySessionService, id: string): Event[] =>
(svc as unknown as {sessions: Record<string, Record<string, Record<string, Session>>>}).sessions[APP][USER][id].events;
async function requestApproval(rig: Rig, sessionId = 'sess-1') {
await rig.sessions.createSession({appName: APP, userId: USER, sessionId});
const evs = await run(rig.runner, sessionId, {role: 'user', parts: [{text: 'Pay alice 10'}]});
const ask = evs.flatMap((e) => getFunctionCalls(e)).find((fc) => fc.name === CONFIRM)!;
return {sessionId, callId: ask.id!};
}
const approve = (id: string): Content =>
({role: 'user', parts: [{functionResponse: {id, name: CONFIRM, response: {confirmed: true}}}]});
type Target = 'both' | 'transferCall' | 'approvalCopy';
function rewriteAmount(evs: Event[], target: Target, amount = 10000) {
for (const ev of evs) for (const fc of getFunctionCalls(ev)) {
if (fc.name === 'transfer_money' && target !== 'approvalCopy') fc.args!['amount'] = amount;
if (fc.name === CONFIRM && target !== 'transferCall')
(fc.args!['originalFunctionCall'] as FunctionCall).args!['amount'] = amount;
}
}
const stripStamps = (evs: Event[]) => evs.forEach((e) => { if (e.customMetadata) delete e.customMetadata[STAMP]; });
async function approveAndReport(label: string, rig: Rig, sessionId: string, callId: string) {
let result = 'ran';
try { await run(rig.runner, sessionId, approve(callId)); }
catch (e) { result = `blocked by ${((e as Error).cause as Error | undefined)?.name ?? (e as Error).name}`; }
console.log(`${label.padEnd(58)} | executed: ${JSON.stringify(rig.transfers).padEnd(38)} | ${result}`);
}
const plugin = (o: {secretKey: Uint8Array | Uint8Array[]; allowUnstampedCalls?: boolean}) => new ToolCallIntegrityPlugin(o);
// 1. No plugin
{ const r = build(); const a = await requestApproval(r); await approveAndReport('no plugin, untouched', r, a.sessionId, a.callId); }
for (const t of ['both', 'transferCall', 'approvalCopy'] as Target[]) {
const r = build(); const a = await requestApproval(r);
rewriteAmount(events(r.sessions, a.sessionId), t);
await approveAndReport(`no plugin, amount rewritten (${t})`, r, a.sessionId, a.callId);
}
// 2. With the plugin
{ const r = build(plugin({secretKey: KEY_A})); const a = await requestApproval(r); await approveAndReport('plugin, untouched', r, a.sessionId, a.callId); }
for (const t of ['both', 'transferCall', 'approvalCopy'] as Target[]) {
const r = build(plugin({secretKey: KEY_A})); const a = await requestApproval(r);
rewriteAmount(events(r.sessions, a.sessionId), t);
await approveAndReport(`plugin, amount rewritten (${t})`, r, a.sessionId, a.callId);
}
// 3. Attacker also deletes the stamps
for (const allow of [false, true]) {
const r = build(plugin({secretKey: KEY_A, allowUnstampedCalls: allow})); const a = await requestApproval(r);
stripStamps(events(r.sessions, a.sessionId)); rewriteAmount(events(r.sessions, a.sessionId), 'both');
await approveAndReport(`plugin, stamps stripped + rewritten, allowUnstamped=${allow}`, r, a.sessionId, a.callId);
}
// 3b. Attacker rewrites the amount AND sets the restored-event marker so verification is skipped
{ const r = build(plugin({secretKey: KEY_A})); const a = await requestApproval(r);
for (const e of events(r.sessions, a.sessionId)) e.customMetadata = {...(e.customMetadata ?? {}), [RESTORED_EVENT_KEY]: true};
rewriteAmount(events(r.sessions, a.sessionId), 'both');
await approveAndReport('plugin, rewritten + restored marker set by attacker', r, a.sessionId, a.callId); }
// 4. Moving history between stores / keys
async function moveHistory(src: Rig, srcId: string, dst: Rig, dstId: string, copy: (e: Event) => Event) {
await dst.sessions.createSession({appName: APP, userId: USER, sessionId: dstId});
for (const e of events(src.sessions, srcId)) events(dst.sessions, dstId).push(copy(e));
}
for (const [label, dstKeys, dstId, copy] of [
['stamped with A, verifier keys [B, A] (rotation)', [KEY_B, KEY_A], 'sess-1', structuredClone],
['stamped with A, verifier only has B', [KEY_B], 'sess-1', structuredClone],
['same key, history copied into another session id', [KEY_A], 'sess-2', structuredClone],
['same key, copied with prepareRestoredEvent', [KEY_A], 'sess-2', ToolCallIntegrityPlugin.prepareRestoredEvent],
] as const) {
const src = build(plugin({secretKey: KEY_A})); const a = await requestApproval(src);
const dst = build(plugin({secretKey: [...dstKeys]}));
await moveHistory(src, a.sessionId, dst, dstId, copy as (e: Event) => Event);
await approveAndReport(label, dst, dstId, a.callId);
}
Run it and hide ADK's own log lines.
npx tsx integrity-test.ts 2>&1 | grep -v "\[ADK\]"
Results with and without the plugin
This is the real output, minus ADK's log lines. I ran the script three times and got byte-identical output every time.3
no plugin, untouched | executed: [{"amount":10,"recipient":"alice"}] | ran
no plugin, amount rewritten (both) | executed: [{"amount":10000,"recipient":"alice"}] | ran
no plugin, amount rewritten (transferCall) | executed: [{"amount":10000,"recipient":"alice"}] | ran
no plugin, amount rewritten (approvalCopy) | executed: [{"amount":10000,"recipient":"alice"}] | ran
plugin, untouched | executed: [{"amount":10,"recipient":"alice"}] | ran
plugin, amount rewritten (both) | executed: [] | blocked by ToolCallIntegrityError
plugin, amount rewritten (transferCall) | executed: [] | blocked by ToolCallIntegrityError
plugin, amount rewritten (approvalCopy) | executed: [] | blocked by ToolCallIntegrityError
plugin, stamps stripped + rewritten, allowUnstamped=false | executed: [] | blocked by ToolCallIntegrityError
plugin, stamps stripped + rewritten, allowUnstamped=true | executed: [{"amount":10000,"recipient":"alice"}] | ran
plugin, rewritten + restored marker set by attacker | executed: [] | blocked by ToolCallIntegrityError
stamped with A, verifier keys [B, A] (rotation) | executed: [{"amount":10,"recipient":"alice"}] | ran
stamped with A, verifier only has B | executed: [] | blocked by ToolCallIntegrityError
same key, history copied into another session id | executed: [] | blocked by ToolCallIntegrityError
same key, copied with prepareRestoredEvent | executed: [] | blocked by ToolCallIntegrityError

Without the plugin, all three rewrites ran with the attacker's amount of 10000. With the plugin on, the same three rewrites ended with no transfer at all.3
Changing either copy of the call is enough
ADK keeps the call in two places. One is the transfer_money call itself, and the other is the copy inside the approval request, called originalFunctionCall. I rewrote the amount in both, then in each one alone.
With no plugin, all three variants executed 10000, so editing a single copy works.3 With the plugin registered, all three were rejected before any transfer ran.3
The failure reaches your code as a generic Error from the plugin manager, and the ToolCallIntegrityError sits in its cause. The plugin docs describe this wrapping.9
The allowUnstampedCalls trap
An attacker can also just delete the stamps. The default settings reject unstamped calls, and my test agreed. Stamps stripped plus a rewritten amount ended with no transfer.3
With allowUnstampedCalls: true, the same attack executed 10000.3 That's documented. The option's comment says unstamped calls also run, so it "reports tampering that removes a stamp but does not prevent it."9
I'd use that flag only while migrating sessions stored before the plugin existed, and switch it off afterward.
A forged restored marker doesn't help the attacker
ADK uses a "restored" marker for history that was copied or replayed. The docs say anyone who can write to the store can also set that marker, but beforeToolCallback still refuses those calls.9
I tried it. I set the marker on every stored event and rewrote the amount. The run ended with no transfer and a ToolCallIntegrityError.3
Key rotation, copied sessions and restored history
The last four rows of the output cover moving stamped history around.
History stamped with key A was accepted by a verifier configured with [B, A], and the approved 10 went through. The option docs say the first key stamps new calls and every key is accepted when verifying.9 A verifier holding only key B rejected the same history.3
Copying the history into a different session id failed even with the same key, because the stamp covers the session.3 History copied with prepareRestoredEvent passes the history check, but its calls never execute. My approval ended with a ToolCallIntegrityError.39
So a real session migration needs a plan. Copy history over for context with prepareRestoredEvent, and redo any approval that was still pending, because my approval on restored history failed.
Workflow nodes: the plugin blocked a legitimate call
The pull request carries a warning. With workflow apps the plugin also needs a separate change, #978, because a tool inside a workflow node runs before the runner has stamped and stored its call, so the gate rejects it.5 Pull request #978 was still open when I looked on October 8, 2026.4
I ran that case on 2.2.1 with a second script. It has no tampering at all, just a fresh transfer_money call, once inside a Workflow node and once through an AgentTool. Save it as workflow-test.ts in the same folder.3
import {
AgentTool, App, BaseLlm, BaseLlmConnection, FunctionTool, InMemorySessionService,
LlmAgent, LlmRequest, LlmResponse, Runner, START, ToolCallIntegrityPlugin, Workflow,
} from '@google/adk';
import {z} from 'zod';
class ScriptedLlm extends BaseLlm {
private i = 0;
constructor(private replies: LlmResponse[]) { super({model: 'scripted'}); }
async *generateContentAsync(_q: LlmRequest) {
yield this.replies[this.i++] ?? {content: {role: 'model', parts: [{text: 'Done.'}]}};
}
async connect(_q: LlmRequest): Promise<BaseLlmConnection> { throw new Error('n/a'); }
}
const call = (name: string, args: Record<string, unknown>): LlmResponse =>
({content: {role: 'model', parts: [{functionCall: {name, args}}]}});
const say = (t: string): LlmResponse => ({content: {role: 'model', parts: [{text: t}]}});
async function scenario(label: string, withPlugin: boolean, shape: 'workflow' | 'agentTool') {
const transfers: unknown[] = [];
const transfer = new FunctionTool({
name: 'transfer_money', description: 'Transfers money.',
parameters: z.object({amount: z.number(), recipient: z.string()}),
execute: (input) => { transfers.push(input); return {status: 'done'}; },
});
const worker = new LlmAgent({
name: 'bank_agent', description: 'Moves money.',
model: new ScriptedLlm([call('transfer_money', {amount: 2, recipient: 'bob'}), say('moved')]),
tools: [transfer],
});
const root = shape === 'workflow'
? new Workflow({name: 'wf', edges: [[START, worker]]})
: new LlmAgent({
name: 'outer_agent',
model: new ScriptedLlm([call('bank_agent', {request: 'pay bob'}), say('done')]),
tools: [new AgentTool({agent: worker})],
});
const sessions = new InMemorySessionService();
const runner = new Runner({
app: new App({name: 'app', rootAgent: root, plugins: withPlugin ? [new ToolCallIntegrityPlugin({secretKey: new TextEncoder().encode('demo-key')})] : []}),
sessionService: sessions,
});
const s = await sessions.createSession({appName: 'app', userId: 'u'});
let result = 'ran';
try {
for await (const _ of runner.runAsync({userId: 'u', sessionId: s.id, newMessage: {role: 'user', parts: [{text: 'pay bob 2'}]}})) { /* drain */ }
} catch (e) {
const err = e as Error;
result = `error: ${(err.cause as Error | undefined)?.message ?? err.message}`.slice(0, 150);
}
console.log(`${label.padEnd(34)} | executed: ${JSON.stringify(transfers).padEnd(34)} | ${result}`);
}
await scenario('workflow node, no plugin', false, 'workflow');
await scenario('workflow node, plugin', true, 'workflow');
await scenario('AgentTool, no plugin', false, 'agentTool');
await scenario('AgentTool, plugin', true, 'agentTool');
npx tsx workflow-test.ts 2>&1 | grep -v -E "\[ADK\]|INFO|WARN|ERROR"
workflow node, no plugin | executed: [{"amount":2,"recipient":"bob"}] | ran
workflow node, plugin | executed: [] | ran
AgentTool, no plugin | executed: [{"amount":2,"recipient":"bob"}] | ran
AgentTool, plugin | executed: [{"amount":2,"recipient":"bob"}] | ran
Read the executed column here, because ran only means the run didn't throw. With the plugin registered, the workflow node's call never executed, and the run finished without throwing.3 In a separate run, ADK logged that the call "has no integrity stamp."3 The AgentTool case ran normally.3
The same pull request says an agent inside an AgentTool doesn't inherit plugins in TypeScript, so calls inside it aren't stamped, while the outer AgentTool call is still gated.5 I didn't test the inner part.
If your agent lives in a Workflow, wait for #978 or test the plugin in staging before you register it.
What the plugin cost per turn
According to its docs, the plugin re-checks the whole history at the start of every run.9 That should get slower as sessions grow, so I measured it. Each turn is one runAsync call with one tool call, no approval pause and a scripted model.
import {App, BaseLlm, BaseLlmConnection, FunctionTool, InMemorySessionService, LlmAgent, LlmRequest, LlmResponse, Runner, ToolCallIntegrityPlugin} from '@google/adk';
import {z} from 'zod';
class S extends BaseLlm {
private i = 0;
constructor(private n: number) { super({model: 'scripted'}); }
async *generateContentAsync(_q: LlmRequest): AsyncGenerator<LlmResponse> {
const k = this.i++;
yield k % 2 === 0
? {content: {role: 'model', parts: [{functionCall: {name: 'ping', args: {n: k}}}]}}
: {content: {role: 'model', parts: [{text: 'ok'}]}};
}
async connect(_q: LlmRequest): Promise<BaseLlmConnection> { throw new Error('n/a'); }
}
async function turns(n: number, withPlugin: boolean) {
let hits = 0;
const tool = new FunctionTool({name: 'ping', description: 'p', parameters: z.object({n: z.number()}), execute: () => { hits++; return {ok: true}; }});
const svc = new InMemorySessionService();
const runner = new Runner({app: new App({name: 'b', rootAgent: new LlmAgent({name: 'a', model: new S(n), tools: [tool]}), plugins: withPlugin ? [new ToolCallIntegrityPlugin({secretKey: new TextEncoder().encode('k')})] : []}), sessionService: svc});
const s = await svc.createSession({appName: 'b', userId: 'u'});
const t0 = performance.now();
for (let i = 0; i < n; i++) for await (const _ of runner.runAsync({userId: 'u', sessionId: s.id, newMessage: {role: 'user', parts: [{text: 'go'}]}})) {}
return {ms: performance.now() - t0, hits};
}
const med = (a: number[]) => a.sort((x, y) => x - y)[Math.floor(a.length / 2)];
for (const n of [25, 100, 200]) {
const off: number[] = [], on: number[] = []; let h = 0;
for (let r = 0; r < 5; r++) { off.push((await turns(n, false)).ms); const o = await turns(n, true); on.push(o.ms); h = o.hits; }
console.log(`turns=${n} (tool calls executed with plugin: ${h}) median total ms: no plugin ${med(off).toFixed(0)}, plugin ${med(on).toFixed(0)}`);
}

I ran the benchmark five separate times on a 2-vCPU container, and the numbers moved around. Each run reports a median of five passes. The table shows the median of the five runs, with the lowest and highest in brackets.3
| Turns | No plugin (ms) | Plugin (ms) | Extra per turn |
|---|---|---|---|
| 25 | 43 (36 to 50) | 100 (83 to 119) | 1.8 to 3.0 ms |
| 100 | 495 (387 to 546) | 1100 (926 to 1152) | 5.4 to 6.5 ms |
| 200 | 1769 (1519 to 1842) | 3910 (3607 to 4098) | 10.4 to 11.3 ms |
The plugin took roughly two to three times as long in every run, between 2.0 and 2.8 times. The extra time per turn grows with history length, which fits the history re-check. The baseline grows too, even without the plugin.3
Take this as a shape and not a benchmark of your deployment. A database-backed session service may change the picture.
Where this fits in a real agent
The risk shows up wherever a human approval and the action are separated in time. A few places I'd look first are refunds, transfers and payouts waiting on a manager, restarts or role changes waiting on sign-off, account changes that support agents propose, and block or isolation actions that a security agent suggests.
The same question comes up in other SDKs that can save and resume a run. I covered the approval controls in the OpenAI Agents SDK in OpenAI Agents SDK needsApproval: Null Bug Tested, and the Claude Agent SDK in Add Human-in-the-Loop Approval to Claude Agent SDK (2026).
If you want more on ADK itself, there's Google ADK 2.0 Workflow: Retries and Timeouts Tested (2026).
A DEV Community article makes the general case for tying an approval to the exact call with an HMAC instead of a boolean flag in run state.10 ADK's plugin signs the stored call rather than the approval itself, so it's a related idea and a different design.
Checklist before you turn it on
- Generate a secret of real length, keep it out of the session store and pass it as bytes. A plain string was rejected in my test.3
- Register the plugin first in the
pluginslist. The docs say a later plugin must not change function calls or replacecustomMetadata.9 - Leave
allowUnstampedCallsatfalseonce old sessions are migrated. - Plan rotation with a key list, newest first.
- Read the
causeof the error to tell integrity failures apart from other plugin errors.9 - Make sure your runtime has Web Crypto. The docs say it's available in browsers and in Node.js 19 and later.9
What I didn't test
I didn't try a real model, a database-backed session service or Vertex AI sessions. Everything ran against InMemorySessionService, edited directly.
I didn't check how Workflow nodes behave once #978 is merged, and I didn't test the calls inside an AgentTool agent. Cross-language checks with ADK for Python are untested here, and the pull request author hadn't tested them either.5
The plugin can't help if an attacker holds the secret key. The docs also say events without function calls are ignored, so only function calls are protected.9
My take
On ADK for TypeScript 2.2.1, a stored tool call that a human had approved could be rewritten and still run. With ToolCallIntegrityPlugin registered, every rewrite I tried was blocked, apart from the run where I turned on allowUnstampedCalls.
It's quick to try and easy to misconfigure in one spot. Leave that flag off, plan your key rotation before you need it, and test it in staging first if your tools run inside Workflow nodes.
Footnotes
-
npm registry metadata for
@google/adk(npm view @google/adk time), read October 8, 2026. Version 2.2.0 was published 2026-09-30 and 2.2.1 on 2026-10-06. Package: @google/adk on npm. ↩ ↩2 ↩3 ↩4 -
google/adk-js, pull request #998, "chore: cherry-pick #977, #979 for 2.2.1", merged 2026-10-06 into
release/2.2.x. The description says it cherry-picks #977, which adds reserved__adk_internal_metadata keys, and #979. The cherry-picked commit message says the plugin "stamps every function call with an HMAC-SHA256 over the session, invocation, branch, author and call." ↩ ↩2 ↩3 ↩4 ↩5 -
Author's measurements, October 8, 2026. Node 22.22.0 on Linux with 2 vCPUs,
@google/adk2.2.1, Zod 4.6.5, plustsxand TypeScript for a strict type check. I extracted the published script from this post and ran it unchanged in a fresh folder, three times with identical output. I unpacked the 2.2.0 and 2.2.1 tarballs withnpm packand searched them for the plugin file. The Workflow andAgentToolchecks come from the second script, and the string-key check from a one-line constructor test. The benchmark was run five separate times and the table shows medians of those runs. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11 ↩12 ↩13 ↩14 ↩15 ↩16 ↩17 ↩18 ↩19 ↩20 ↩21 ↩22 ↩23 ↩24 ↩25 -
google/adk-js, pull request #978, "fix(workflow): wait for the runner to store node events (adk-python parity)", opened 2026-10-01 and still open when I read it on 2026-10-08. The description says #979 "skips its workflow-node test until this lands." ↩ ↩2
-
google/adk-js, pull request #979, "feat(plugins): add ToolCallIntegrityPlugin (adk-python parity)", merged into main on 2026-10-02 UTC as commit d7b6f04, authored 2026-10-01 at 17:26 Pacific time. The same change is commit a099fd5 on the
release/2.2.xbranch, which I confirmed with a shallow clone. The quoted risk statement and the__adk_internal_fc_hmackey come from the commit message. The payload fields, the cross-language note, theAgentToolnote and the #978 workflow warning come from the PR description, which I read on GitHub on October 8, 2026. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11 ↩12 -
Agent Development Kit (ADK) documentation, which says ADK is "Available in Python, TypeScript, Go, Java, and Kotlin" and gives the TypeScript install command
npm install @google/adk. Read October 8, 2026. Source repository: google/adk-js. ↩ ↩2 -
google/adk-python, commit b7efc4e, "feat(plugins): add optional ToolCallIntegrityPlugin". The commit message reads "Adds an optional plugin that improves the TOCTOU posture of stored tool calls." ↩
-
google/adk-docs, pull request #2343, "Update API reference docs for ADK TypeScript 2.2.1", merged 2026-10-07. It adds reference pages for
ToolCallIntegrityPlugin,ToolCallIntegrityErrorandToolCallIntegrityPluginOptions. ↩ -
Type documentation shipped in the
@google/adk2.2.1 package, filedist/types/plugins/tool_call_integrity_plugin.d.ts, read from the unpacked tarball on October 8, 2026. The quotes and the statements about callbacks,allowUnstampedCalls, key rotation, the restored marker, plugin ordering, error wrapping and Web Crypto all come from that file. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11 ↩12 ↩13 ↩14 ↩15 -
Whatsonyourmind, "Stop trusting the agent: bind tool-call approvals to the exact call", DEV Community. ↩



