fix: isolate registered project folders by tenant

This commit is contained in:
Hamza Ayed
2026-10-04 02:04:14 +03:00
parent 1feeb372a4
commit 5ab7ec2713
4 changed files with 58 additions and 3 deletions
+2 -2
View File
@@ -4,9 +4,9 @@
## الحالة الحالية — 2026-10-04 ## الحالة الحالية — 2026-10-04
- 2026-10-04 — تسجيل المشاريع في الواجهة والـAPI: عند اختيار مجلد من Flutter يُسجّل API المحلي المسار في SQLite تحت هوية الحساب (`user_workspace_roots`) ثم يسرد الملفات المدعومة؛ عند إزالة المشروع يُلغى التسجيل ولا تُحذف الملفات. أضيف POST/DELETE لـ`/v1/agent/projects` ويُسمح بهما عبر loopback فقط. اختبارات API تثبت التسجيل والقراءة لصاحب المشروع، رفض الحساب الآخر، منع تسجيل مجلد متداخل بين حسابين (409)، وإلغاء الوصول بعد الحذف أو بعد حذف المجلد الأصلي. اختبار حي على مجلد خارج جذر المنتج قرأ `README.md`، وسأل Gemma عبر الوكيل فأجاب من المحتوى باسم `Cedar`، ثم تحقق رفض الوصول بعد الإلغاء. اختبارات Python ذات الصلة 9/9 وFlutter 17/17، و`flutter analyze` بلا ملاحظات و`compileall` ناجح. أُعيد تشغيل API وفحص `/health` (`ok`, Gemma 4)، وتأكد وجود مساري POST/DELETE في OpenAPI، وبُني وشُغّل Windows Debug الحالي (PID 1328). تشغيل أوامر البناء/الاختبار من الوكيل لم يُدمج بعد؛ عزل نظام التشغيل لهذا التشغيل ما زال مفتوحًا. - 2026-10-04 — تسجيل المشاريع في الواجهة والـAPI: عند اختيار مجلد من Flutter يُسجّل API المحلي المسار في SQLite تحت هوية الحساب (`user_workspace_roots`) ثم يسرد الملفات المدعومة؛ عند إزالة المشروع يُلغى التسجيل ولا تُحذف الملفات. أضيف POST/DELETE لـ`/v1/agent/projects` ويُسمح بهما عبر loopback فقط. اختبارات API تثبت التسجيل والقراءة لصاحب المشروع، رفض الحساب الآخر، منع تسجيل مجلد متداخل بين حسابين أو مع جذر خصصه مسؤول لحساب آخر (409)، وإلغاء الوصول بعد الحذف أو بعد حذف المجلد الأصلي. اختبار حي على مجلد خارج جذر المنتج قرأ `README.md`، وسأل Gemma عبر الوكيل فأجاب من المحتوى باسم `Cedar`، ثم تحقق رفض الوصول بعد الإلغاء. اختبارات Python ذات الصلة 10/10 وFlutter 17/17، و`flutter analyze` بلا ملاحظات و`compileall` ناجح. أُعيد تشغيل API وفحص `/health` (`ok`, Gemma 4)، وتأكد وجود مساري POST/DELETE في OpenAPI، وبُني وشُغّل Windows Debug الحالي (PID 1328). تشغيل أوامر البناء/الاختبار من الوكيل لم يُدمج بعد؛ عزل نظام التشغيل لهذا التشغيل ما زال مفتوحًا.
- اختبار سقف التخزين: نجح `scripts/verify_vhd_disk_quota.ps1` بقرص VHDX مؤقت 64MiB؛ كتب 53,477,376 بايت حتى `ERROR_DISK_FULL` (112)، بقي 151,552 بايت حرًا، وحُذف قرص الاختبار بعد التحقق. سكربت القرص الدائم 1GiB اجتاز تحليل PowerShell بعد إصلاحه، لكن `Status` أكد عدم وجود القرص؛ محاولة الإعداد عبر Windows PowerShell وPowerShell 7 انتهت بخطأ بدء `0xc0000142` قبل أي تعديل. لذلك لا يوجد حتى الآن VHDX دائم، ولا يُسمح للوكيل بتنفيذ أوامر. - اختبار سقف التخزين: نجح `scripts/verify_vhd_disk_quota.ps1` بقرص VHDX مؤقت 64MiB؛ كتب 53,477,376 بايت حتى `ERROR_DISK_FULL` (112)، بقي 151,552 بايت حرًا، وحُذف قرص الاختبار بعد التحقق. سكربت القرص الدائم 1GiB اجتاز تحليل PowerShell بعد إصلاحه، لكن `Status` أكد عدم وجود القرص؛ محاولات UAC عبر Windows PowerShell وPowerShell 7 و`cmd.exe` انتهت بخطأ بدء `0xc0000142` قبل أي تعديل. لذلك لا يوجد حتى الآن VHDX دائم، ولا يُسمح للوكيل بتنفيذ أوامر.
- متابعة التقييم على ملفات المشروع الفعلية: أضيفت مرادفات `permission/permissions/صلاحية/صلاحيات` لرموز صلاحيات الأدوات، فأصبح المقطع العميق `tool_name not in selected_skill.allowed_tools` يظهر ضمن نتائج السؤال الذي كان يفشل سابقًا؛ تقييم الاسترجاع 9/9 أدلة (`evals/results/retrieval_project_permission_synonym_2026-10-03.json`). هذا يقيس العثور على المصدر لا جودة صياغة Gemma. أضيف حدّ سياق لأربع مقاطع وإزالة التكرار وتوزيع المقاطع على ملفات مختلفة. في 2026-10-03 أُعيد تشغيل FastAPI من الشفرة الحالية بعد اكتشاف أن الخدمة الحية كانت أقدم من تعديل السياق. على الخدمة الجديدة، أعاد التقييم الكامل 11/11 ملفًا مستهدفًا في المرتبة الأولى ووجد الدليل 11/11 (`evals/results/retrieval_project_agent_fresh_api_2026-10-03.json`). مراجعة يدوية أولية لإجابات الجولة وجدت 9/11 إجابات مباشرة؛ سؤال ترحيل الإجابات وسؤال توجيه الصور امتنعا رغم وجود الدليل، لكن إعادة كل حالة منفردة أجابت عنها مع اقتباس المصدر (`evals/results/retrieval_project_agent_migration_retry_2026-10-03.json` و`evals/results/retrieval_project_agent_routing_retry_2026-10-03.json`). هذا يكشف تذبذب جودة التوليد، ولا يعادل درجات مستقلة؛ مراجعة بشرية أوسع ما زالت مفتوحة. اختبارات Flutter الكاملة الآن 16/16، واختبارات Python الكاملة عبر `unittest` الآن 89/89 (2026-10-03). فحص `compileall` و`git diff --check` ناجحان. - متابعة التقييم على ملفات المشروع الفعلية: أضيفت مرادفات `permission/permissions/صلاحية/صلاحيات` لرموز صلاحيات الأدوات، فأصبح المقطع العميق `tool_name not in selected_skill.allowed_tools` يظهر ضمن نتائج السؤال الذي كان يفشل سابقًا؛ تقييم الاسترجاع 9/9 أدلة (`evals/results/retrieval_project_permission_synonym_2026-10-03.json`). هذا يقيس العثور على المصدر لا جودة صياغة Gemma. أضيف حدّ سياق لأربع مقاطع وإزالة التكرار وتوزيع المقاطع على ملفات مختلفة. في 2026-10-03 أُعيد تشغيل FastAPI من الشفرة الحالية بعد اكتشاف أن الخدمة الحية كانت أقدم من تعديل السياق. على الخدمة الجديدة، أعاد التقييم الكامل 11/11 ملفًا مستهدفًا في المرتبة الأولى ووجد الدليل 11/11 (`evals/results/retrieval_project_agent_fresh_api_2026-10-03.json`). مراجعة يدوية أولية لإجابات الجولة وجدت 9/11 إجابات مباشرة؛ سؤال ترحيل الإجابات وسؤال توجيه الصور امتنعا رغم وجود الدليل، لكن إعادة كل حالة منفردة أجابت عنها مع اقتباس المصدر (`evals/results/retrieval_project_agent_migration_retry_2026-10-03.json` و`evals/results/retrieval_project_agent_routing_retry_2026-10-03.json`). هذا يكشف تذبذب جودة التوليد، ولا يعادل درجات مستقلة؛ مراجعة بشرية أوسع ما زالت مفتوحة. اختبارات Flutter الكاملة الآن 16/16، واختبارات Python الكاملة عبر `unittest` الآن 89/89 (2026-10-03). فحص `compileall` و`git diff --check` ناجحان.
+3 -1
View File
@@ -263,8 +263,10 @@ async def register_agent_project(
raise HTTPException(status_code=403, detail="تسجيل مجلدات المشاريع متاح من هذا الجهاز فقط.") raise HTTPException(status_code=403, detail="تسجيل مجلدات المشاريع متاح من هذا الجهاز فقط.")
try: try:
root = workspace.validate_workspace_registration(request.workspace_path) root = workspace.validate_workspace_registration(request.workspace_path)
workspace.validate_workspace_registration_isolation(root, user_id)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc status_code = 409 if "يتداخل" in str(exc) else 422
raise HTTPException(status_code=status_code, detail=str(exc)) from exc
try: try:
database.register_workspace_root(user_id, str(root)) database.register_workspace_root(user_id, str(root))
except ValueError as exc: except ValueError as exc:
+43
View File
@@ -153,6 +153,49 @@ def validate_workspace_registration(value: str, *, must_exist: bool = True) -> P
return root return root
def validate_workspace_registration_isolation(root: Path, user_id: str) -> None:
"""Prevent locally registered roots from overlapping another tenant's admin grant."""
from app import auth, database
raw_mapping = os.getenv("SOVEREIGNAI_USER_WORKSPACES", "")
try:
mapping = json.loads(raw_mapping) if raw_mapping else {}
except json.JSONDecodeError as exc:
raise ValueError("إعداد مساحات العمل حسب الحساب غير صالح على الخادم.") from exc
if not isinstance(mapping, dict):
raise ValueError("إعداد مساحات العمل حسب الحساب يجب أن يكون كائن JSON.")
current_email = (
auth.account_email(user_id)
if user_id != database.LOCAL_USER_ID
else None
)
candidate = os.path.normcase(os.path.abspath(str(root)))
for assigned_email, assigned_paths in mapping.items():
if not isinstance(assigned_email, str) or not isinstance(assigned_paths, list):
raise ValueError("كل مستخدم في إعداد مساحات العمل يجب أن يقابله مصفوفة مسارات.")
if current_email and assigned_email.strip().casefold() == current_email.casefold():
continue
for value in assigned_paths:
if not isinstance(value, str) or not value.strip():
raise ValueError("مسار مساحة العمل المخصصة في الخادم غير صالح.")
try:
assigned = Path(value).expanduser().resolve(strict=True)
except (OSError, RuntimeError) as exc:
raise ValueError("أحد مسارات مساحة العمل المخصصة غير موجود أو غير متاح.") from exc
if not assigned.is_dir():
raise ValueError("جذر مساحة العمل المخصصة ليس مجلدًا صالحًا.")
normalized = os.path.normcase(os.path.abspath(str(assigned)))
try:
common = os.path.commonpath((candidate, normalized))
except ValueError:
continue
if common in (candidate, normalized):
raise ValueError(
"مجلد المشروع يتداخل مع مساحة عمل مخصصة لحساب آخر؛ اختر مجلدًا منفصلًا."
)
def selected_root(value: str | None, *, user_id: str | None = None) -> Path | None: def selected_root(value: str | None, *, user_id: str | None = None) -> Path | None:
"""Resolve a selected directory only within this user's server-assigned roots.""" """Resolve a selected directory only within this user's server-assigned roots."""
allowed_roots = _roots_for_user(user_id) allowed_roots = _roots_for_user(user_id)
+10
View File
@@ -309,6 +309,11 @@ class AuthenticationTests(unittest.TestCase):
headers={"Authorization": f"Bearer {first_token}"}, headers={"Authorization": f"Bearer {first_token}"},
json={"workspace_path": str(second_root)}, json={"workspace_path": str(second_root)},
) )
first_overlapping_registration = self.client.post(
"/v1/agent/projects",
headers={"Authorization": f"Bearer {first_token}"},
json={"workspace_path": str(parent)},
)
second_own = self.client.post( second_own = self.client.post(
"/v1/agent/workspace/files", "/v1/agent/workspace/files",
headers={"Authorization": f"Bearer {second_token}"}, headers={"Authorization": f"Bearer {second_token}"},
@@ -317,6 +322,11 @@ class AuthenticationTests(unittest.TestCase):
self.assertEqual(first_own.status_code, 200, first_own.text) self.assertEqual(first_own.status_code, 200, first_own.text)
self.assertEqual(first_own.json()["files"], ["one.md"]) self.assertEqual(first_own.json()["files"], ["one.md"])
self.assertEqual(first_other.status_code, 403, first_other.text) self.assertEqual(first_other.status_code, 403, first_other.text)
self.assertEqual(
first_overlapping_registration.status_code,
409,
first_overlapping_registration.text,
)
self.assertEqual(second_own.status_code, 200, second_own.text) self.assertEqual(second_own.status_code, 200, second_own.text)
self.assertEqual(second_own.json()["files"], ["two.md"]) self.assertEqual(second_own.json()["files"], ["two.md"])