security

ADK ToolCallIntegrityPlugin: نتائج اختبار التلاعب

٨ أكتوبر ٢٠٢٦

ADK ToolCallIntegrityPlugin: Tamper Test Results

ملخص: أضافت مجموعة تطوير الوكلاء (ADK) من Google لـ TypeScript إضافة ToolCallIntegrityPlugin في الإصدار 2.2.1، المنشور في 6 أكتوبر 2026. تقوم هذه الإضافة بتوقيع كل استدعاء للأداة باستخدام HMAC-SHA256. في اختباري، تم تشغيل استدعاء معتمد تمت إعادة كتابته بدون الإضافة، بينما تم حظره عند تفعيلها.12

هناك إعداد واحد يعيد هذه الثغرة، وهو allowUnstampedCalls: true.3 إذا كانت أدواتك تعمل داخل عقد Workflow، فاقرأ قسم Workflow أولاً، لأن الإضافة حظرت استدعائي المشروع هناك.34 الأرقام والسكربتات الكاملة موجودة أدناه، حتى تتمكن من تشغيلها بنفسك.

إضافة ToolCallIntegrityPlugin في فقرة واحدة

تعتبر ToolCallIntegrityPlugin إضافة اختيارية لـ ADK تتحقق من أن استدعاء الأداة الموجود في الجلسة هو نفس الاستدعاء الذي ينتهي به الأمر إلى التشغيل. يذكر طلب السحب (pull request) الذي أضافها المخاطر بشكل مباشر: "يمكن لأي شخص لديه صلاحية كتابة في مخزن الجلسة إعادة كتابة استدعاء مخزن، على سبيل المثال بين قيام إنسان بالموافقة على الاستدعاء وقيام الوكيل بتشغيله".5

لسد هذه الفجوة، تقوم الإضافة بختم كل استدعاء دالة باستخدام HMAC-SHA256 على الجلسة، والاستدعاء، والفرع، والمؤلف، والاستدعاء نفسه.25 ثم ترفض تشغيل أي استدعاء لا يتم التحقق من ختمه.5

ما ستتعلمه

  • ما الذي تقوم الإضافة بتوقيعه، وأي إصدار من ADK لـ TypeScript شحنه لأول مرة
  • كيفية إعادة إنتاج موافقة تم التلاعب بها باستخدام نموذج مبرمج وبدون مفتاح API
  • التعديلات التي حظرتها الإضافة في اختباراتي، والإعداد الوحيد الذي يحولها إلى مجرد سطر في السجل (log line)
  • كيفية تعامل تدوير المفاتيح، والجلسات المنسوخة، والسجل المستعاد
  • لماذا حظرت الإضافة استدعاءً مشروعاً داخل عقدة Workflow
  • تكلفة الإضافة لكل دورة في اختبار قياسي صغير

ما تم شحنه في ADK 2.2.1

تعد ADK لـ TypeScript واحدة من إصدارات اللغات لمجموعة تطوير الوكلاء، والتي يذكر المشروع أنها متوفرة في Python و TypeScript و Go و Java و Kotlin.6 يمكنك تثبيت إصدار TypeScript باستخدام npm install @google/adk.6

وصلت الإضافة إلى الفرع الرئيسي في 2 أكتوبر 2026 (UTC) في طلب السحب #979.5 وتم نقلها (cherry-picked) إلى فرع 2.2.x في طلب السحب #998، وتم دمجها في 6 أكتوبر.2

صدر الإصدار 2.2.0 في 30 سبتمبر والإصدار 2.2.1 في 6 أكتوبر.1 قمت بفك ضغط كلا الملفين. حزمة 2.2.0 لا تحتوي على ملف إضافة للنزاهة (integrity plugin)، بينما يحتوي الإصدار 2.2.1 على ذلك.3

يذكر طلب السحب (pull request) أنه ينقل google/adk-python@b7efc4e، ويذكر التزام Python هذا أنه "يحسن وضع TOCTOU لاستدعاءات الأدوات المخزنة".57 يرمز TOCTOU إلى "وقت التحقق إلى وقت الاستخدام" (time-of-check to time-of-use)، وهي الفجوة الزمنية بين الموافقة على الاستدعاء وتشغيله. كتب المؤلف أيضاً أن التحقق عبر اللغات المختلفة لم يتم اختباره.5 وأضاف مستودع وثائق ADK صفحات مرجعية للإضافة في 7 أكتوبر.8

كيف تعمل الإضافة

تصف وثائق النوع الخاصة بالإضافة ثلاثة استدعاءات عكسية (callbacks).9

عندما يحتوي حدث ما على استدعاءات لوظائف، يقوم onEventCallback بإضافة "v1:" + HMAC-SHA256(key, payload) إلى customMetadata الخاصة بالحدث. في بداية كل تشغيل، يقوم beforeRunCallback بإعادة التحقق من كل استدعاء وظيفة يحتوي على معرف (ID) في سجل الجلسة. وقبل تنفيذ الأداة مباشرة، يسمح beforeToolCallback بتشغيلها فقط إذا تم التحقق من الاستدعاء المخزن وكان اسمه ووسائطه متطابقين مع ما سيتم تنفيذه.

يذكر طلب السحب أن الحمولة المختومة تغطي التطبيق، والمستخدم، والجلسة، والاستدعاء، والفرع، والمؤلف، واسم الاستدعاء، ومعرف الاستدعاء، والوسائط.5 يوجد الختم تحت مفتاح __adk_internal_fc_hmac، ويستخدم هذا المفتاح وعلامة "الحدث المستعاد" كلاهما بادئة البيانات الوصفية المحجوزة __adk_internal_.25

إعادة إنتاج موافقة تم التلاعب بها

السيناريو الخاص بي هو وكيل دفع مع أداة transfer_money مضبوطة على requireConfirmation: true. يطلب الوكيل الموافقة ويوافق إنسان على تحويل مبلغ 10 إلى alice.

بين الطلب والموافقة، يقوم شخص لديه صلاحية كتابة في مخزن الجلسة بتغيير المبلغ المخزن إلى 10000. أقوم بمحاكاة ذلك عن طريق تحرير الأحداث داخل InMemorySessionService، والتي تعمل كبديل لقاعدة بيانات لا تملك السيطرة الكاملة عليها.

لا تحتاج إلى مفتاح API. يستخدم السكربت ScriptedLlm يقوم بإعادة تشغيل ردين جاهزين. قمت بتثبيت الحزمة المنشورة في مجلد نظيف.

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 عن zod ^4.2.1 كاعتمادية، لذا استخدمت Zod 4. احفظ السكربت أدناه باسم 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);
}

قم بتشغيله وإخفاء أسطر سجلات ADK.

npx tsx integrity-test.ts 2>&1 | grep -v "\[ADK\]"

النتائج مع وبدون الإضافة

هذه هي المخرجات الحقيقية، بدون أسطر سجلات ADK. قمت بتشغيل السكربت ثلاث مرات وحصلت على مخرجات متطابقة تماماً في كل مرة.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

جدول يوضح ما تم تنفيذه لكل سيناريو تلاعب في ADK TypeScript 2.2.1، مع وبدون ToolCallIntegrityPlugin

بدون الإضافة (plugin)، تم تنفيذ عمليات إعادة الكتابة الثلاث بمبلغ المهاجم البالغ 10000. ومع تفعيل الإضافة، انتهت عمليات إعادة الكتابة الثلاث نفسها دون إجراء أي تحويل على الإطلاق.3

تغيير أي من نسختي الاستدعاء يكفي

يحتفظ ADK بالاستدعاء في مكانين. أحدهما هو استدعاء transfer_money نفسه، والآخر هو النسخة الموجودة داخل طلب الموافقة، والتي تسمى originalFunctionCall. قمت بإعادة كتابة المبلغ في كليهما، ثم في كل واحد منهما على حدة.

بدون إضافة، تم تنفيذ جميع المتغيرات الثلاثة بمبلغ 10000، لذا فإن تعديل نسخة واحدة ينجح.3 ومع تسجيل الإضافة، تم رفض جميع الحالات الثلاث قبل تنفيذ أي تحويل.3

يصل الفشل إلى الكود الخاص بك كـ Error عام من مدير الإضافات، ويكون ToolCallIntegrityError موجوداً في الـ cause الخاصة به. تصف وثائق الإضافة عملية التغليف هذه.9

فخ allowUnstampedCalls

يمكن للمهاجم أيضاً مجرد حذف الأختام (stamps). ترفض الإعدادات الافتراضية الاستدعاءات غير المختومة، وهذا ما أكده اختباري. انتهى حذف الأختام مع إعادة كتابة المبلغ بعدم إجراء أي تحويل.3

مع تفعيل allowUnstampedCalls: true، تم تنفيذ نفس الهجوم بمبلغ 10000.3 وهذا موثق؛ حيث يذكر التعليق الخاص بهذا الخيار أن الاستدعاءات غير المختومة يتم تنفيذها أيضاً، لذا فهو "يبلغ عن التلاعب الذي يزيل الختم ولكنه لا يمنعه".9

سأستخدم هذا العلم (flag) فقط أثناء ترحيل الجلسات المخزنة قبل وجود الإضافة، ثم أقوم بإيقافه بعد ذلك.

علامة الاستعادة المزورة لا تساعد المهاجم

يستخدم ADK علامة "استعادة" (restored) للسجل الذي تم نسخه أو إعادة تشغيله. تقول الوثائق إن أي شخص يمكنه الكتابة في المخزن يمكنه أيضاً تعيين هذه العلامة، ولكن beforeToolCallback لا يزال يرفض هذه الاستدعاءات.9

لقد جربت ذلك؛ قمت بتعيين العلامة على كل حدث مخزن وأعدت كتابة المبلغ. انتهى التشغيل دون تحويل ومع ظهور ToolCallIntegrityError.3

تدوير المفاتيح، الجلسات المنسوخة والسجل المستعاد

تغطي الصفوف الأربعة الأخيرة من المخرجات عملية نقل السجل المختوم.

تم قبول السجل المختوم بالمفتاح A بواسطة مُحقق (verifier) مهيأ بـ [B, A]، وتم تمرير مبلغ 10 المعتمد. تقول وثائق الخيار إن المفتاح الأول يختم الاستدعاءات الجديدة ويتم قبول كل مفتاح عند التحقق.9 أما المُحقق الذي يحمل المفتاح B فقط فقد رفض نفس السجل.3

فشلت عملية نسخ السجل إلى معرف جلسة (session id) مختلف حتى مع استخدام نفس المفتاح، لأن الختم يغطي الجلسة.3 السجل المنسوخ باستخدام prepareRestoredEvent يجتاز فحص السجل، لكن استدعاءاته لا تُنفذ أبداً. انتهت عملية الموافقة الخاصة بي بـ ToolCallIntegrityError.39

لذا، فإن ترحيل الجلسة الفعلي يحتاج إلى خطة. نسخ السجل من أجل السياق باستخدام prepareRestoredEvent، وإعادة تنفيذ أي موافقة كانت لا تزال معلقة، لأن موافقتي على السجل المستعاد قد فشلت.

عقد سير العمل (Workflow nodes): الإضافة منعت استدعاءً مشروعاً

يحمل طلب السحب (pull request) تحذيراً. مع تطبيقات سير العمل، تحتاج الإضافة أيضاً إلى تغيير منفصل، #978، لأن الأداة داخل عقدة سير العمل تعمل قبل أن يقوم المشغل بختم وتخزين استدعائها، لذا ترفضه البوابة.5 كان طلب السحب #978 لا يزال مفتوحاً عندما اطلعت عليه في 8 أكتوبر 2026.4

قمت بتشغيل هذه الحالة على الإصدار 2.2.1 باستخدام سكربت ثانٍ. لا يوجد به أي تلاعب على الإطلاق، مجرد استدعاء transfer_money جديد، مرة داخل عقدة Workflow ومرة من خلال AgentTool. احفظه باسم workflow-test.ts في نفس المجلد.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

اقرأ عمود executed هنا، لأن ran تعني فقط أن التشغيل لم يتسبب في خطأ. مع تسجيل الإضافة، لم يتم تنفيذ استدعاء عقدة سير العمل أبداً، وانتهى التشغيل دون حدوث خطأ.3 في تشغيل منفصل، سجل ADK أن الاستدعاء "ليس له ختم سلامة (integrity stamp)".3 أما حالة AgentTool فقد عملت بشكل طبيعي.3

يقول طلب السحب نفسه إن العميل (agent) داخل AgentTool لا يرث الإضافات في TypeScript، لذا فإن الاستدعاءات بداخله لا تُختم، بينما يظل استدعاء AgentTool الخارجي خاضعاً للبوابة.5 لم أختبر الجزء الداخلي.

إذا كان العميل الخاص بك موجوداً في Workflow، فانتظر #978 أو اختبر الإضافة في بيئة staging قبل تسجيلها.

تكلفة الإضافة لكل دورة

وفقاً لوثائقها، تقوم الإضافة بإعادة فحص السجل بالكامل في بداية كل تشغيل.9 من المفترض أن تصبح هذه العملية أبطأ مع نمو الجلسات، لذا قمت بقياسها. كل دورة هي استدعاء runAsync واحد مع استدعاء أداة واحد، بدون توقف للموافقة وباستخدام نموذج مبرمج (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)}`);
}

مخطط شريطي للوقت الإجمالي الوسيط مع وبدون ToolCallIntegrityPlugin عند 25 و 100 و 200 دورة

قمت بتشغيل الاختبار المرجعي خمس مرات منفصلة على حاوية بـ 2-vCPU، وتغيرت الأرقام قليلاً. كل تشغيل يبلغ عن وسيط لخمس محاولات. يوضح الجدول الوسيط لعمليات التشغيل الخمس، مع أدنى وأعلى قيمة بين قوسين.3

الدوراتبدون إضافة (ملي ثانية)بالإضافة (ملي ثانية)إضافي لكل دورة
2543 (36 إلى 50)100 (83 إلى 119)1.8 إلى 3.0 ملي ثانية
100495 (387 إلى 546)1100 (926 إلى 1152)5.4 إلى 6.5 ملي ثانية
2001769 (1519 إلى 1842)3910 (3607 إلى 4098)10.4 إلى 11.3 ملي ثانية

استغرقت الإضافة ما يقرب من ضعفين إلى ثلاثة أضعاف الوقت في كل تشغيل، ما بين 2.0 و 2.8 مرة. يزداد الوقت الإضافي لكل دورة مع زيادة طول السجل، وهو ما يتناسب مع عملية إعادة فحص السجل. كما يزداد الخط المرجعي أيضاً، حتى بدون الإضافة.3

اعتبر هذه النتائج كمؤشر عام وليس كمعيار دقيق لبيئة التشغيل الخاصة بك. فقد تغير خدمة الجلسات المدعومة بقاعدة بيانات الصورة الكاملة.

أين ينطبق هذا في العميل الذكي الحقيقي

تظهر المخاطر في أي مكان يتم فيه الفصل زمنياً بين موافقة البشر والإجراء المتخذ. من الأماكن التي أنصح بالنظر فيها أولاً: عمليات استرداد الأموال، والتحويلات والمدفوعات التي تنتظر موافقة المدير، وإعادة التشغيل أو تغييرات الأدوار التي تنتظر التوقيع، وتغييرات الحساب التي يقترحها وكلاء الدعم، وإجراءات الحظر أو العزل التي يقترحها وكيل الأمان.

يطرح نفس السؤال في أدوات SDK أخرى يمكنها حفظ واستئناف التشغيل. لقد غطيت ضوابط الموافقة في OpenAI Agents SDK في OpenAI Agents SDK needsApproval: Null Bug Tested، و Claude Agent SDK في Add Human-in-the-Loop Approval to Claude Agent SDK (2026).

إذا كنت تريد المزيد عن ADK نفسها، فهناك Google ADK 2.0 Workflow: Retries and Timeouts Tested (2026).

هناك مقال في مجتمع DEV يطرح الحالة العامة لربط الموافقة بالنداء المحدد باستخدام HMAC بدلاً من علامة boolean في حالة التشغيل.10 تقوم إضافة ADK بتوقيع النداء المخزن بدلاً من الموافقة نفسها، لذا فهي فكرة ذات صلة ولكن بتصميم مختلف.

قائمة التحقق قبل التفعيل

  1. قم بتوليد سر بطول حقيقي، واحتفظ به خارج مخزن الجلسات ومرره كبايتات (bytes). لقد تم رفض السلسلة النصية البسيطة في اختباري.3
  2. قم بتسجيل الإضافة أولاً في قائمة plugins. تذكر الوثائق أن الإضافات اللاحقة يجب ألا تغير نداءات الدوال أو تستبدل customMetadata.9
  3. اترك allowUnstampedCalls على قيمة false بمجرد ترحيل الجلسات القديمة.
  4. خطط لتدوير المفاتيح باستخدام قائمة مفاتيح، بحيث يكون الأحدث أولاً.
  5. اقرأ cause الخاص بالخطأ للتمييز بين إخفاقات السلامة وأخطاء الإضافات الأخرى.9
  6. تأكد من أن بيئة التشغيل لديك تدعم Web Crypto. تذكر الوثائق أنها متاحة في المتصفحات وفي Node.js 19 والإصدارات الأحدث.9

ما لم أختبره

لم أجرب نموذجاً حقيقياً، أو خدمة جلسات مدعومة بقاعدة بيانات، أو جلسات Vertex AI. كل شيء تم تشغيله مقابل InMemorySessionService، مع تعديله مباشرة.

لم أتحقق من كيفية سلوك عقد Workflow بمجرد دمج #978، ولم أختبر النداءات داخل عميل AgentTool. كما أن الفحوصات عبر اللغات مع ADK لـ Python لم تُختبر هنا، ولم يختبرها مؤلف طلب السحب (pull request) أيضاً.5

لا يمكن للإضافة المساعدة إذا كان المهاجم يمتلك المفتاح السري. كما تذكر الوثائق أن الأحداث التي لا تحتوي على استدعاءات لوظائف يتم تجاهلها، لذا فإن استدعاءات الوظائف فقط هي المحمية.9

رأيي الشخصي

في ADK لـ TypeScript 2.2.1، كان من الممكن إعادة كتابة استدعاء أداة مخزن وافق عليه إنسان وتشغيله مرة أخرى. ومع تسجيل ToolCallIntegrityPlugin، تم حظر كل محاولة إعادة كتابة جربتها، باستثناء المرة التي قمت فيها بتفعيل allowUnstampedCalls.

من السهل تجربة ذلك ومن السهل الخطأ في الإعداد في نقطة واحدة. اترك هذا العلم (flag) مغلقاً، وخطط لتدوير المفاتيح قبل أن تحتاج إليها، واختبرها في بيئة staging أولاً إذا كانت أدواتك تعمل داخل عقد Workflow.

Footnotes

  1. 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

  2. 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

  3. Author's measurements, October 8, 2026. Node 22.22.0 on Linux with 2 vCPUs, @google/adk 2.2.1, Zod 4.6.5, plus tsx and 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 with npm pack and searched them for the plugin file. The Workflow and AgentTool checks 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

  4. 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

  5. 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.x branch, which I confirmed with a shallow clone. The quoted risk statement and the __adk_internal_fc_hmac key come from the commit message. The payload fields, the cross-language note, the AgentTool note 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

  6. 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

  7. 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." ↩

  8. 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, ToolCallIntegrityError and ToolCallIntegrityPluginOptions. ↩

  9. Type documentation shipped in the @google/adk 2.2.1 package, file dist/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

  10. Whatsonyourmind, "Stop trusting the agent: bind tool-call approvals to the exact call", DEV Community. ↩

الأسئلة الشائعة

إنه إضافة اختيارية لـ ADK تقوم بختم كل استدعاء وظيفة مخزن باستخدام HMAC-SHA256 وترفض تشغيل أي استدعاء لا يتم التحقق منه.25 في ADK الخاص بـ TypeScript، تم إصداره لأول مرة في النسخة 2.2.1.13