Restrict workspace paths to configured roots

This commit is contained in:
Hamza Ayed
2026-10-03 01:02:47 +03:00
parent 5430d4ac4b
commit 1b56f70aa6
4 changed files with 49 additions and 7 deletions
+1 -1
View File
@@ -148,7 +148,7 @@ python scripts/eval_knowledge_retrieval.py --dataset evals/knowledge_retrieval_p
- لتشغيل النسخة المكتبية، شغّل `start-api.ps1` أولًا، ثم افتح `flutter_app/build/windows/x64/runner/Release/flutter_app.exe` بعد بنائه.
- يعرض شريط المحادثة قائمة النماذج المثبتة من Ollama؛ اختيار النموذج يمرر اسمه مع الطلب.
- زر المجلد يفعّل وضع الوكيل للقراءة فقط، وزر الكرة الأرضية يفعّل البحث العميق متعدد المصادر. عند تشغيل `start-api.ps1` تكون مساحة العمل الافتراضية مجلد المشروع `SovereignAI-Starter`؛ لتغييرها عرّف `SOVEREIGNAI_WORKSPACE` قبل تشغيل الخدمة.
- زر المجلد يفعّل وضع الوكيل للقراءة فقط، وزر الكرة الأرضية يفعّل البحث العميق متعدد المصادر. عند تشغيل `start-api.ps1` تكون مساحة العمل الافتراضية مجلد المشروع `SovereignAI-Starter`؛ لتغييرها عرّف `SOVEREIGNAI_WORKSPACE` قبل تشغيل الخدمة. يمكن لمسؤول الخدمة التصريح بعدة جذور عبر `SOVEREIGNAI_ALLOWED_WORKSPACES` مفصولة بفاصل المسارات الخاص بالنظام؛ أي مجلد يختاره العميل يجب أن يقع داخل أحدها. هذه قائمة سماح على مستوى الخادم وليست بعد تعيينًا خاصًا بكل حساب.
- الوكيل يطابق السؤال مع ملفات النص/الكود المدعومة، ويمكنه قراءة PDF رقمي يحدده المستخدم؛ يمرر مقتطفات محدودة للنموذج، وعند ذكر مسار نسبي مثل `app/main.py` يزيد المقتطف إلى 12,000 حرف كحد أقصى. يعيد أسماء الملفات التي قرأها. الملفات المخفية، مجلدات `.git` وبيئات البناء، والملفات الكبيرة أو خارج جذر مساحة العمل مستثناة.
- في وضع الوكيل اختر المهارة من أيقونة الدماغ بجانب زر مساحة العمل: **شرح الكود** أو **مراجعة الكود** أو **خطة اختبارات**. يستعرض `GET /v1/agent/skills` المهارات وأدوات كل منها؛ يتحقق الخادم من صلاحيات الأداة ولا يعتمد على طلب النموذج وحده. مراجعة الكود تستطيع طلب معاينة diff فقط، ولا تطبق تغييرًا.
- فهرس معرفة محلي: في وضع مساحة العمل حدد ملفات UTF-8 أو PDF رقميًا/ممسوحًا ثم اضغط زر الفهرسة بجانب أيقونة المجلد؛ زر الإزالة يحذف الملفات المحددة من الفهرس. يدعم `POST /v1/agent/knowledge/index` و`DELETE /v1/agent/knowledge/index` و`POST /v1/agent/knowledge/search`. يقتصر الطلب على 20 ملفًا و2 ميغابايت إجمالًا، و256 كيلوبايت للملف النصي، وPDF حتى 30 صفحة و24 ألف محرف مستخرج. يجمع PDF المختلط نص الصفحات الرقمية وOCR لأول 3 صفحات ممسوحة، ويضيف تضمينات Granite المحلية الاختيارية إلى FTS5 عند توفر النموذج؛ لا يرفع المحتوى إلى خدمة خارجية. تعذر التضمين يبقي البحث النصي فعالًا. إعادة الفهرسة تستبدل النسخة السابقة، والبحث يحذف تلقائيًا أي مستند تغيّر أو لم يعد ضمن المساحة.
+3 -1
View File
@@ -27,6 +27,7 @@
- 2026-10-03: اكتمل فرض Bearer على كل عمليات `/v1` الخاصة (عدا الصحة وقائمة النماذج ومسارات بدء المصادقة العامة)، وربط Flutter بالجلسة لكل طلب محادثة/وكيل/ملف/معرفة/ويب/صوت. أصبحت ملكية فهرس المعرفة وسجل التدقيق حسب الحساب، ومعاينة تعديل الملف لا تُطبق إلا بجلسة صاحبها. تحقق OpenAPI وواجهات رفض الرمز وعزل حسابين وترحيل SQLite: 61 اختبار Python ناجح، و11 اختبار Flutter وتحليل Flutter بلا ملاحظات. يبقى قصر الوصول إلى مسارات نظام الملفات لكل مستخدم، وواجهة الدخول وتخزين الرمز الآمن وحدود محاولات الدخول؛ لذلك يظل التشغيل loopback فقط.
- 2026-10-03: أضيفت شاشة Flutter للحساب (إنشاء/دخول/خروج)، وعند تبديل الهوية تمسح المحادثات المحملة ثم تعيد قراءتها تحت الجلسة الجديدة. رمز الحساب يمر عبر `flutter_secure_storage` ويُستعاد بالتحقق من `/v1/auth/me`؛ اختبار Flutter 11/11 و`flutter analyze` بلا ملاحظات. Android مضبوط على API 23. **لم يكتمل بناء Windows:** Visual Studio Build Tools يفتقد ATL (`atlstr.h`) المطلوب من الإضافة، ومحاولة إضافة المكوّن لم تُثبته؛ يجب إصلاح مكوّن البناء ثم إعادة `flutter build windows --debug` قبل اعتبار خطوة Windows مجربة. بناء الويب بدأ لكن لم يُحسم بعد.
- 2026-10-03: أضيف تحديد دائم لمحاولات الدخول عبر SQLite: خمس كلمات مرور خاطئة لكل حساب أو عنوان اتصال خلال 15 دقيقة، وحدّ إنشاء 30 حسابًا لكل اتصال في الساعة. تُخزن بصمات SHA-256 للمفاتيح بدل البريد أو IP الخام، ويرجع الخادم 429 و`Retry-After`. تحقق اختبار انقضاء النافذة ومجموعة Python الكاملة: 62 اختبارًا ناجحًا، و`compileall` و`git diff --check` ناجحان. لا يزال هذا حدًا أوليًا خلف خدمة loopback، وليس سياسة كافية وحده لخدمة عامة.
- 2026-10-03: أضيفت قائمة سماح جذور لمساحات العمل على مستوى الخدمة؛ اختيار المجلد يتطلب أن يقع داخل جذر يحدده المسؤول في `SOVEREIGNAI_ALLOWED_WORKSPACES`. اختُبر رفض المسار الخارجي وقبول المسار الداخلي، ونجحت مجموعة Python الآن بـ63 اختبارًا مع `compileall` و`git diff --check`. ما زال ربط الجذر بالحساب مستقلًا مطلوبًا.
## المرحلة 1 — تجربة المحادثة
@@ -65,7 +66,8 @@
- [x] ربط CRUD المحادثات والتقييم بهوية الجلسة، والتحقق من أن حسابًا ثانيًا لا يقرأ محادثة الحساب الأول.
- [x] فرض Bearer على جميع عمليات `/v1` الخاصة وإرسال الجلسة من Flutter للمحادثة/الوكيل/الملفات/المعرفة/البحث والصوت؛ فحص OpenAPI يضمن ألا توجد عملية خاصة بلا HTTP Bearer.
- [x] عزل فهرس المعرفة وسجل التدقيق بمعرّف الحساب، وربط رمز معاينة تعديل الملفات بصاحبها؛ اختبار API أثبت عدم استرجاع حساب لمحتوى فهرسه حساب آخر.
- [ ] قصر مسارات مساحة العمل على مجلدات مصرح بها لكل مستخدم، وإزالة صلاحيات FastAPI العامة عن ملفات المضيف قبل السماح بعميل شبكي أو خدمة مستضافة.
- [ ] ربط جذور مساحة العمل بكل حساب على حدة، وإزالة صلاحيات FastAPI العامة عن ملفات المضيف قبل السماح بعميل شبكي أو خدمة مستضافة.
- [x] إضافة قائمة سماح على مستوى الخادم عبر `SOVEREIGNAI_ALLOWED_WORKSPACES`؛ يرفض API الآن أي مجلد خارج الجذور المعتمدة عند ضبطها. هذا يقلل الوصول إلى المضيف لكنه لا يعزل حسابين داخل الجذر نفسه، ولا يغني عن إعدادها في كل خدمة تشغيل.
- [x] بناء واجهة إنشاء الحساب/الدخول والخروج وربطها بجلسات API؛ عند تبديل الحساب تمسح الواجهة الحالة المحلية ثم تعيد تحميل المحادثات والمهارات تحت الهوية الجديدة.
- [x] حفظ رمز الحساب عبر `flutter_secure_storage` في مخزن النظام على المنصات المدعومة، واستعادة `/v1/auth/me` عند بدء التطبيق؛ Android مضبوط على API 23 كحد أدنى. تخزين الويب تجريبي ويعتمد على المتصفح، لذلك لا يُعامل كحماية نظام تشغيل.
- [ ] إضافة حدود لمحاولات الدخول/التسجيل وتدفق استعادة كلمة المرور واختبار نشر محلي عبر TLS قبل الوصول الشبكي.
+28 -5
View File
@@ -29,12 +29,30 @@ MAX_PENDING_PROPOSALS = 32
_pending_changes: dict[str, dict[str, object]] = {}
def configured_roots() -> tuple[Path, ...]:
"""Return the server administrator's allow-listed workspace roots."""
configured = os.getenv("SOVEREIGNAI_ALLOWED_WORKSPACES")
if configured:
values = configured.split(os.pathsep)
else:
primary = os.getenv("SOVEREIGNAI_WORKSPACE")
values = [primary] if primary else []
roots: list[Path] = []
for value in values:
if not value or not value.strip():
continue
try:
root = Path(value.strip()).expanduser().resolve(strict=True)
except (OSError, RuntimeError):
continue
if root.is_dir() and root not in roots:
roots.append(root)
return tuple(roots)
def configured_root() -> Path | None:
value = os.getenv("SOVEREIGNAI_WORKSPACE")
if not value:
return None
root = Path(value).expanduser().resolve()
return root if root.is_dir() else None
roots = configured_roots()
return roots[0] if roots else None
def selected_root(value: str | None) -> Path | None:
@@ -51,6 +69,11 @@ def selected_root(value: str | None) -> Path | None:
raise ValueError("اختر مجلد مشروع محددًا، وليس جذر القرص.")
if root.name.startswith(".") or root.name in IGNORED_PARTS:
raise ValueError("لا يمكن استخدام مجلد مخفي أو مستثنى كمساحة عمل.")
allowed_roots = configured_roots()
if allowed_roots and not any(
root == allowed or allowed in root.parents for allowed in allowed_roots
):
raise ValueError("المساحة المحددة خارج مجلدات المشاريع المصرح بها على الخادم.")
return root
@@ -4,7 +4,9 @@ from __future__ import annotations
import tempfile
import unittest
import os
from pathlib import Path
from unittest.mock import patch
from app import workspace
@@ -86,6 +88,21 @@ class WorkspaceChangeTests(unittest.TestCase):
self.root, relative_path, operation, "content\n"
)
def test_selected_workspace_must_be_under_server_allowlisted_roots(self) -> None:
allowed = self.root / "allowed"
nested = allowed / "project"
nested.mkdir(parents=True)
outside = self.root / "outside"
outside.mkdir()
with patch.dict(
os.environ,
{"SOVEREIGNAI_ALLOWED_WORKSPACES": str(allowed)},
clear=False,
):
with self.assertRaisesRegex(ValueError, "خارج مجلدات المشاريع"):
workspace.selected_root(str(outside))
self.assertEqual(workspace.selected_root(str(nested)), nested.resolve())
def test_rejects_create_overwrite_and_update_of_missing_file(self) -> None:
target = self.root / "src" / "existing.py"
target.write_text("value = 1\n", encoding="utf-8")