---
title: 'ADK ToolCallIntegrityPlugin: نتائج اختبار التلاعب'
description: لقد أعدت كتابة استدعاء أداة معتمد في Google ADK TypeScript 2.2.1. وقد تم تشغيله بدون أي plugin وتم حظره بواسطة ToolCallIntegrityPlugin، باستثناء إعداد واحد.
url: https://nerdleveltech.com/ar/google-adk-toolcallintegrityplugin-tamper-tested
language: ar
date: '2026-10-08'
category: security
tags:
  - google adk
  - tool call integrity
  - ai agent security
  - human in the loop
  - typescript
  - ai agents
markdown_url: https://nerdleveltech.com/ar/google-adk-toolcallintegrityplugin-tamper-tested.md
attribution: 'Source: NerdLevelTech — https://nerdleveltech.com/ar/google-adk-toolcallintegrityplugin-tamper-tested'
terms: https://nerdleveltech.com/llms.txt
---

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

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

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

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

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

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

## ما ستتعلمه

- ما الذي تقوم الإضافة بتوقيعه، وأي إصدار من 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.[^3] وتم نقلها (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 على ذلك.[^8]

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

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

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

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

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

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

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

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

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

```bash
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`.[^8]

```typescript
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.

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

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

هذه هي المخرجات الحقيقية، بدون أسطر سجلات ADK. قمت بتشغيل السكربت ثلاث مرات وحصلت على مخرجات متطابقة تماماً في كل مرة.[^8]

```text
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](https://nerdleveltech.com/images/adk-tool-call-integrity-tamper-matrix.png)

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

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

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

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

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

## فخ allowUnstampedCalls

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

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

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

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

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

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

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

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

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

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

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

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

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

قمت بتشغيل هذه الحالة على الإصدار 2.2.1 باستخدام سكربت ثانٍ. لا يوجد به أي تلاعب على الإطلاق، مجرد استدعاء `transfer_money` جديد، مرة داخل عقدة Workflow ومرة من خلال `AgentTool`. احفظه باسم `workflow-test.ts` في نفس المجلد.[^8]

```typescript
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');
```

```bash
npx tsx workflow-test.ts 2>&1 | grep -v -E "\[ADK\]|INFO|WARN|ERROR"
```

```text
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` تعني فقط أن التشغيل لم يتسبب في خطأ. مع تسجيل الإضافة، لم يتم تنفيذ استدعاء عقدة سير العمل أبداً، وانتهى التشغيل دون حدوث خطأ.[^8] في تشغيل منفصل، سجل ADK أن الاستدعاء "ليس له ختم سلامة (integrity stamp)".[^8] أما حالة `AgentTool` فقد عملت بشكل طبيعي.[^8]

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

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

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

وفقاً لوثائقها، تقوم الإضافة بإعادة فحص السجل بالكامل في بداية كل تشغيل.[^7] من المفترض أن تصبح هذه العملية أبطأ مع نمو الجلسات، لذا قمت بقياسها. كل دورة هي استدعاء `runAsync` واحد مع استدعاء أداة واحد، بدون توقف للموافقة وباستخدام نموذج مبرمج (scripted model).

```typescript
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 دورة](https://nerdleveltech.com/images/adk-tool-call-integrity-overhead.png)

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

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

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

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

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

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

يطرح نفس السؤال في أدوات SDK أخرى يمكنها حفظ واستئناف التشغيل. لقد غطيت ضوابط الموافقة في OpenAI Agents SDK في [OpenAI Agents SDK needsApproval: Null Bug Tested](https://nerdleveltech.com/openai-agents-js-needsapproval-null-optional-tested)، و Claude Agent SDK في [Add Human-in-the-Loop Approval to Claude Agent SDK (2026)](https://nerdleveltech.com/human-in-the-loop-claude-agent-sdk-typescript-tutorial).

إذا كنت تريد المزيد عن ADK نفسها، فهناك [Google ADK 2.0 Workflow: Retries and Timeouts Tested (2026)](https://nerdleveltech.com/google-adk-workflow-retry-timeout-routing-tested).

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

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

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

## ما لم أختبره

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

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

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

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

### ما هو ToolCallIntegrityPlugin في Google ADK؟

إنه إضافة اختيارية لـ ADK تقوم بختم كل استدعاء وظيفة مخزن باستخدام HMAC-SHA256 وترفض تشغيل أي استدعاء لا يتم التحقق منه.[^2][^3] في ADK الخاص بـ TypeScript، تم إصداره لأول مرة في النسخة 2.2.1.[^1][^8]

### أي نسخة من ADK لـ TypeScript أضافت ToolCallIntegrityPlugin؟

النسخة 2.2.1، التي نُشرت في 6 أكتوبر 2026. النسخة 2.2.0، التي نُشرت في 30 سبتمبر، لا تحتوي على ملف الإضافة.[^1][^8]

### هل يمنع الشخص الذي يقوم بتعديل مخزن الجلسة (session store)؟

في اختباري، قام بحظر كل عملية إعادة كتابة حاولت القيام بها، بما في ذلك التعديلات على نسخة واحدة فقط من الاستدعاء وعلامة استعادة مزورة.[^8] لكنه لن يساعد ضد شخص يمتلك المفتاح السري.[^7]

### هل يكتشف ADK الموافقة المعاد كتابتها بدون الإضافة؟

ليس في تدفق التأكيد هذا في النسخة 2.2.1. إعادة كتابة المبلغ في أي من النسخ المخزنة أدت إلى تنفيذ قيمة المهاجم، كما يظهر في المخرجات أعلاه.[^8]

### ماذا تفعل خاصية allowUnstampedCalls؟

تقوم بتسجيل تحذير بدلاً من إلقاء خطأ للاستدعاءات التي لا تحتوي على ختم، وتظل هذه الاستدعاءات تعمل. تذكر الوثائق أنها تبلغ عن التلاعب الذي يزيل الختم ولكنها لا تمنعه.[^7]

### هل يمكنني تدوير مفتاح HMAC؟

نعم. قم بتمرير قائمة من المفاتيح. المفتاح الأول يقوم بختم الاستدعاءات الجديدة، ويتم قبول جميع المفاتيح عند التحقق.[^7]

### كيف أميز بين فشل السلامة (integrity failure) وأي خطأ آخر في الإضافة؟

يقوم مدير الإضافات بإعادة إلقاء `Error` عام، ويكون `ToolCallIntegrityError` هو الـ `cause` الخاص به.[^7]

## رأيي الشخصي

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

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

[^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](https://www.npmjs.com/package/@google/adk).

[^2]: google/adk-js, [pull request #998, "chore: cherry-pick #977, #979 for 2.2.1"](https://github.com/google/adk-js/pull/998), 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."

[^8]: 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.

[^10]: google/adk-js, [pull request #978, "fix(workflow): wait for the runner to store node events (adk-python parity)"](https://github.com/google/adk-js/pull/978), 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."

[^3]: google/adk-js, [pull request #979, "feat(plugins): add ToolCallIntegrityPlugin (adk-python parity)"](https://github.com/google/adk-js/pull/979), 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.

[^6]: [Agent Development Kit (ADK) documentation](https://adk.dev/), 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](https://github.com/google/adk-js).

[^4]: google/adk-python, [commit b7efc4e, "feat(plugins): add optional ToolCallIntegrityPlugin"](https://github.com/google/adk-python/commit/b7efc4e). The commit message reads "Adds an optional plugin that improves the TOCTOU posture of stored tool calls."

[^5]: google/adk-docs, [pull request #2343, "Update API reference docs for ADK TypeScript 2.2.1"](https://github.com/google/adk-docs/pull/2343), merged 2026-10-07. It adds reference pages for `ToolCallIntegrityPlugin`, `ToolCallIntegrityError` and `ToolCallIntegrityPluginOptions`.

[^7]: 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.

[^9]: Whatsonyourmind, ["Stop trusting the agent: bind tool-call approvals to the exact call"](https://dev.to/whatsonyourmind/stop-trusting-the-agent-bind-tool-call-approvals-to-the-exact-call-5080), DEV Community.
