Add bounded workspace execution snapshots
This commit is contained in:
@@ -98,6 +98,7 @@
|
||||
3. [x] اختيار مساحة العمل/الملف من نافذة التطبيق واستعراض قائمة الملفات قبل سؤال الوكيل. تعرض الواجهة الملفات النصية المدعومة وتسمح بتحديد 3 كحد أقصى؛ يرسل التطبيق المسارات النسبية، ويتحقق الخادم منها داخل المجلد المحدد. فُحص endpoint حيًا واستُبعد `.env`؛ ولا يرسل قائمة الملفات أو محتواها للتخزين.
|
||||
4. [x] كتابة مضبوطة: إنشاء وتعديل ملفات داخل مساحة العمل فقط، مع معاينة diff وتأكيد المستخدم قبل التطبيق. (2026-10-02: لا كتابة عند المعاينة؛ الرمز مؤقت ولمرة واحدة، وفحص المسار والبصمة يعاد قبل التطبيق؛ اجتازت اختبارات Python واختبارات واجهة Flutter، وأُعيد تشغيل Windows Debug وFastAPI بالتغييرات.)
|
||||
5. أوامر تطوير: تشغيل أوامر محددة في بيئة معزولة وبمهلة وحدود موارد، ومع موافقة لكل أمر في البداية. (2026-10-03: تحقق Windows 10 Pro 19045، Intel i7-6600U مع virtualization firmware مفعّل، RAM 15.9GB والمتاح وقت القياس 5.2GB، و40.8GB مساحة فارغة على C:. لا يوجد `WindowsSandbox.exe` أو Docker. استعلاما WSL أعادا شاشة المساعدة فلم يثبتا توفر توزيعة. فحص Windows Sandbox يحتاج مسؤولًا؛ محاولة DISM مرتفعة الصلاحية انتهت بخطأ `0xc0000142` ولم تغيّر إعدادًا. أُعدّ prototype محلي بـAppContainer وJob Object (`scripts/appcontainer_probe.cpp`): 512 MiB، حد 8 عمليات، مهلة 30 ثانية، وإنهاء شجرة العمليات عند إغلاق الـJob. في تشغيل Windows بأذونات مناسبة نجح smoke test: `cmd.exe` عمل داخل الحاوية؛ مُنع من قراءة ملف Temp للمضيف ومن إنشاء ملف خارجه، بينما نجح في الكتابة والقراءة من مجلد العمل المعزول. نُسخ `README.md` من المشروع إلى الحاوية ثم استخدم `curl.exe file://` لنسخه منها؛ النسخة تطابقت بايتًا ببايت. اختُبر `curl.exe` داخل الحاوية (`--version` exit 0)، وفشل الوصول إلى `/health` على `127.0.0.1:8100` بمهلة curl 28 رغم نجاح endpoint من المضيف؛ هذا فحص اتصال محلي فقط، وليس اختبارًا للإنترنت العام. إعداد AppContainer بلا قدرات شبكية. الجلسة المقيدة لدى Codex فشلت في إنشاء الملف الشخصي بـ`0x80070005`، بينما نجح الفاحص عبر جلسة التنفيذ المسموحة؛ يحتوي `scripts/run_appcontainer_probe.ps1` على build وتشغيل وتنظيف مؤقت قابل للتكرار. ما زال هذا prototype غير مدمج في الوكيل ولا توجد أوامر عامة قابلة للتنفيذ. التالي: تجربة نسخ ملفات محددة وآمنة من مساحة يختارها المستخدم مع حدود حجم واستثناء الأسرار والروابط الرمزية، ثم إرجاع المخرجات/diff والتحقق من الموارد والمهلة، وبعدها دمج قائمة أوامر مسموحة وموافقة صريحة في API والواجهة. [AppContainer isolation](https://learn.microsoft.com/en-us/windows/win32/secauthz/appcontainer-isolation)، [تنفيذ AppContainer](https://learn.microsoft.com/en-us/windows/win32/secauthz/implementing-an-appcontainer)، [Job Objects](https://learn.microsoft.com/en-us/windows/win32/procthread/job-objects).)
|
||||
- [x] تجهيز Snapshot محدود لملفات يختارها المستخدم (`app/execution_snapshot.py`): يفرض جذر workspace المعتمد للحساب، حتى 50 ملفًا، 512KB لكل ملف و10MB إجماليًا، يقبل الامتدادات المدعومة فقط، ويرفض المسارات المخفية/المستثناة/الخارجة وأسماء الملفات ذات مؤشرات الأسرار والروابط الرمزية وأسماء أجهزة Windows المحجوزة؛ ينسخ إلى مجلد مؤقت مع SHA-256 لكل ملف. اختبارات الحدود والمسارات والروابط: 5 ناجحة. اختبارات تغيير Workspace المرتبطة: 7 ناجحة (2026-10-03). هذا staging host-side مثبت وحده ولم يُوصل بعد إلى AppContainer أو API؛ حدّ اكتماله هو تجهيز النسخة فقط.
|
||||
6. لا وصول عام إلى القرص، ولا أوامر مدمرة أو نشر خارجي دون موافقة صريحة. كل أداة لها مخطط مدخلات ومخرجات واختبارات وسجل تدقيق.
|
||||
|
||||
### كودكس للبرمجة
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
"""Build a bounded, secret-aware project snapshot for isolated command runs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from pathlib import PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
from app import workspace
|
||||
|
||||
MAX_SNAPSHOT_FILES = 50
|
||||
MAX_SNAPSHOT_FILE_BYTES = 512 * 1024
|
||||
MAX_SNAPSHOT_TOTAL_BYTES = 10 * 1024 * 1024
|
||||
_SENSITIVE_NAME = re.compile(
|
||||
r"(^|[._-])(secrets?|credentials?|passwords?|tokens?|api[_-]?keys?)([._-]|$)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_WINDOWS_RESERVED_NAMES = {"CON", "PRN", "AUX", "NUL"} | {
|
||||
f"{prefix}{number}"
|
||||
for prefix in ("COM", "LPT")
|
||||
for number in range(1, 10)
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class StagedWorkspace:
|
||||
"""Temporary copy of explicit, bounded workspace files; call close() when done."""
|
||||
|
||||
root: Path
|
||||
files: list[dict[str, Any]]
|
||||
total_bytes: int
|
||||
_temporary: tempfile.TemporaryDirectory
|
||||
|
||||
def close(self) -> None:
|
||||
self._temporary.cleanup()
|
||||
|
||||
def __enter__(self) -> "StagedWorkspace":
|
||||
return self
|
||||
|
||||
def __exit__(self, *_exc: object) -> None:
|
||||
self.close()
|
||||
|
||||
|
||||
def _is_reparse_point(path: Path) -> bool:
|
||||
if path.is_symlink():
|
||||
return True
|
||||
is_junction = getattr(path, "is_junction", None)
|
||||
return bool(is_junction()) if is_junction is not None else False
|
||||
|
||||
|
||||
def _safe_relative_path(root: Path, value: str) -> tuple[Path, str]:
|
||||
if (
|
||||
not isinstance(value, str)
|
||||
or not value
|
||||
or len(value) > 240
|
||||
or "\x00" in value
|
||||
or any(ord(char) < 32 for char in value)
|
||||
):
|
||||
raise ValueError("مسار الملف المحدد غير صالح.")
|
||||
normalized = value.replace("\\", "/")
|
||||
parts = normalized.split("/")
|
||||
relative = PurePosixPath(normalized)
|
||||
if (
|
||||
relative.is_absolute()
|
||||
or not parts
|
||||
or any(part in {"", ".", ".."} for part in parts)
|
||||
or any(":" in part or any(char in part for char in '<>|?*"') for part in parts)
|
||||
or any(
|
||||
len(part) > 255
|
||||
or part.endswith((".", " "))
|
||||
or part.split(".", 1)[0].upper() in _WINDOWS_RESERVED_NAMES
|
||||
for part in parts
|
||||
)
|
||||
or any(part.startswith(".") or part in workspace.IGNORED_PARTS for part in parts)
|
||||
):
|
||||
raise ValueError("لا يسمح بنسخ مسارات مطلقة أو مخفية أو مستثناة خارج الملف المحدد.")
|
||||
if not relative.parts or relative.suffix.lower() not in workspace.ALLOWED_SUFFIXES:
|
||||
raise ValueError("امتداد الملف غير مسموح لنسخة التنفيذ.")
|
||||
if any(_SENSITIVE_NAME.search(part) for part in parts):
|
||||
raise ValueError("اسم الملف يوحي باحتوائه على بيانات سرية؛ لم تتم إضافته إلى نسخة التنفيذ.")
|
||||
|
||||
candidate = root.joinpath(*relative.parts)
|
||||
current = root
|
||||
try:
|
||||
for part in relative.parts:
|
||||
current = current / part
|
||||
if _is_reparse_point(current):
|
||||
raise ValueError("نسخ الملفات عبر الروابط الرمزية أو junctions غير مسموح.")
|
||||
resolved = candidate.resolve(strict=True)
|
||||
resolved.relative_to(root)
|
||||
metadata = os.stat(resolved, follow_symlinks=False)
|
||||
except ValueError:
|
||||
raise
|
||||
except (OSError, RuntimeError) as exc:
|
||||
raise ValueError("الملف المحدد غير موجود أو غير متاح داخل مساحة العمل.") from exc
|
||||
if not stat.S_ISREG(metadata.st_mode):
|
||||
raise ValueError("يمكن نسخ الملفات العادية فقط.")
|
||||
if metadata.st_size > MAX_SNAPSHOT_FILE_BYTES:
|
||||
raise ValueError("الملف يتجاوز حد 512 كيلوبايت لنسخة التنفيذ.")
|
||||
return resolved, relative.as_posix()
|
||||
|
||||
|
||||
def stage_selected_files(
|
||||
root: Path, relative_paths: list[str], *, user_id: str | None = None
|
||||
) -> StagedWorkspace:
|
||||
"""Copy only selected safe files into a fresh temporary staging directory."""
|
||||
resolved_root = workspace.selected_root(str(root), user_id=user_id)
|
||||
if resolved_root is None:
|
||||
raise ValueError("لم يتم اختيار مساحة عمل مصرح بها.")
|
||||
if not resolved_root.is_dir():
|
||||
raise ValueError("مساحة العمل المحددة ليست مجلدًا.")
|
||||
if not isinstance(relative_paths, list) or not relative_paths:
|
||||
raise ValueError("حدد ملفًا واحدًا على الأقل لنسخة التنفيذ.")
|
||||
if len(relative_paths) > MAX_SNAPSHOT_FILES:
|
||||
raise ValueError("نسخة التنفيذ تقبل حتى 50 ملفًا في الطلب الواحد.")
|
||||
|
||||
selected: list[tuple[Path, str, os.stat_result]] = []
|
||||
seen: set[str] = set()
|
||||
total_bytes = 0
|
||||
for raw_path in relative_paths:
|
||||
source, relative = _safe_relative_path(resolved_root, raw_path)
|
||||
key = relative.casefold()
|
||||
if key in seen:
|
||||
raise ValueError("قائمة ملفات نسخة التنفيذ تحتوي مسارًا مكررًا.")
|
||||
seen.add(key)
|
||||
metadata = source.stat()
|
||||
total_bytes += metadata.st_size
|
||||
if total_bytes > MAX_SNAPSHOT_TOTAL_BYTES:
|
||||
raise ValueError("إجمالي نسخة التنفيذ يتجاوز 10 ميغابايت.")
|
||||
selected.append((source, relative, metadata))
|
||||
|
||||
staged_records: list[dict[str, Any]] = []
|
||||
temporary = tempfile.TemporaryDirectory(prefix="sovereignai-execution-")
|
||||
stage_root = Path(temporary.name).resolve(strict=True)
|
||||
try:
|
||||
copied_bytes = 0
|
||||
for source, relative, expected in selected:
|
||||
with source.open("rb") as input_file:
|
||||
opened = os.fstat(input_file.fileno())
|
||||
if (opened.st_dev, opened.st_ino, opened.st_size) != (
|
||||
expected.st_dev,
|
||||
expected.st_ino,
|
||||
expected.st_size,
|
||||
):
|
||||
raise ValueError("تغير الملف أثناء تجهيز نسخة التنفيذ؛ أعد المحاولة.")
|
||||
content = input_file.read(MAX_SNAPSHOT_FILE_BYTES + 1)
|
||||
if len(content) > MAX_SNAPSHOT_FILE_BYTES or len(content) != expected.st_size:
|
||||
raise ValueError("تغير حجم الملف أثناء تجهيز نسخة التنفيذ؛ أعد المحاولة.")
|
||||
latest = source.stat()
|
||||
if (latest.st_dev, latest.st_ino, latest.st_size) != (
|
||||
expected.st_dev,
|
||||
expected.st_ino,
|
||||
expected.st_size,
|
||||
):
|
||||
raise ValueError("تغير الملف أثناء تجهيز نسخة التنفيذ؛ أعد المحاولة.")
|
||||
|
||||
destination = stage_root.joinpath(*PurePosixPath(relative).parts)
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
with destination.open("xb") as output_file:
|
||||
output_file.write(content)
|
||||
copied_bytes += len(content)
|
||||
staged_records.append(
|
||||
{
|
||||
"path": relative,
|
||||
"size_bytes": len(content),
|
||||
"sha256": hashlib.sha256(content).hexdigest(),
|
||||
}
|
||||
)
|
||||
if copied_bytes != total_bytes:
|
||||
raise ValueError("تغير حجم نسخة الملفات أثناء تجهيزها؛ أعد المحاولة.")
|
||||
return StagedWorkspace(
|
||||
root=stage_root,
|
||||
files=staged_records,
|
||||
total_bytes=copied_bytes,
|
||||
_temporary=temporary,
|
||||
)
|
||||
except Exception:
|
||||
temporary.cleanup()
|
||||
raise
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Security and bounds tests for preparing an isolated command snapshot."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from app import execution_snapshot
|
||||
|
||||
|
||||
class ExecutionSnapshotTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temporary = tempfile.TemporaryDirectory(
|
||||
dir=Path(__file__).resolve().parents[1]
|
||||
)
|
||||
self.root = Path(self.temporary.name).resolve()
|
||||
(self.root / "src").mkdir()
|
||||
(self.root / "src" / "main.py").write_bytes(b"print('safe')\n")
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.temporary.cleanup()
|
||||
|
||||
def stage(self, paths: list[str]) -> execution_snapshot.StagedWorkspace:
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{"SOVEREIGNAI_ALLOWED_WORKSPACES": str(self.root)},
|
||||
clear=False,
|
||||
), patch.object(tempfile, "tempdir", str(self.root.parent)):
|
||||
return execution_snapshot.stage_selected_files(self.root, paths)
|
||||
|
||||
def test_stages_only_explicit_files_and_reports_hash_and_size(self) -> None:
|
||||
(self.root / "src" / "notes.md").write_bytes(b"read-only input\n")
|
||||
staged = self.stage(["src/main.py", "src/notes.md"])
|
||||
try:
|
||||
self.assertEqual(
|
||||
(staged.root / "src" / "main.py").read_bytes(), b"print('safe')\n"
|
||||
)
|
||||
self.assertFalse((staged.root / "src" / "other.py").exists())
|
||||
self.assertEqual([item["path"] for item in staged.files], [
|
||||
"src/main.py", "src/notes.md"
|
||||
])
|
||||
self.assertEqual(staged.total_bytes, len(b"print('safe')\nread-only input\n"))
|
||||
self.assertTrue(all(len(str(item["sha256"])) == 64 for item in staged.files))
|
||||
staged_path = staged.root
|
||||
finally:
|
||||
staged.close()
|
||||
self.assertFalse(staged_path.exists())
|
||||
|
||||
def test_rejects_paths_outside_hidden_ignored_secret_and_unsupported_files(self) -> None:
|
||||
(self.root / ".env").write_text("TOKEN=x", encoding="utf-8")
|
||||
(self.root / "src" / "api_token.py").write_text("TOKEN=x", encoding="utf-8")
|
||||
(self.root / "src" / "secrets.json").write_text("{}", encoding="utf-8")
|
||||
(self.root / "src" / "image.png").write_bytes(b"image")
|
||||
(self.root / ".git").mkdir()
|
||||
(self.root / ".git" / "config").write_text("secret", encoding="utf-8")
|
||||
|
||||
for path in (
|
||||
"../outside.py", ".env", ".git/config", "src/api_token.py",
|
||||
"src/secrets.json", "src/image.png", "src/CON.py", "src/trailing.py."
|
||||
):
|
||||
with self.subTest(path=path), self.assertRaises(ValueError):
|
||||
self.stage([path])
|
||||
|
||||
def test_rejects_duplicate_empty_and_unallowlisted_workspace(self) -> None:
|
||||
for paths in ([], ["src/main.py", "src/main.py"]):
|
||||
with self.subTest(paths=paths), self.assertRaises(ValueError):
|
||||
self.stage(paths)
|
||||
|
||||
outside = self.root.parent / (self.root.name + "-outside")
|
||||
outside.mkdir()
|
||||
try:
|
||||
with self.assertRaises(ValueError):
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{"SOVEREIGNAI_ALLOWED_WORKSPACES": str(self.root)},
|
||||
clear=False,
|
||||
):
|
||||
execution_snapshot.stage_selected_files(outside, ["file.py"])
|
||||
finally:
|
||||
outside.rmdir()
|
||||
|
||||
def test_enforces_per_file_and_total_size_limits(self) -> None:
|
||||
large = self.root / "src" / "large.txt"
|
||||
large.write_bytes(b"x" * (execution_snapshot.MAX_SNAPSHOT_FILE_BYTES + 1))
|
||||
with self.assertRaisesRegex(ValueError, "512 كيلوبايت"):
|
||||
self.stage(["src/large.txt"])
|
||||
|
||||
(self.root / "src" / "a.txt").write_bytes(b"a" * 6)
|
||||
(self.root / "src" / "b.txt").write_bytes(b"b" * 6)
|
||||
with patch.object(execution_snapshot, "MAX_SNAPSHOT_TOTAL_BYTES", 10):
|
||||
with self.assertRaisesRegex(ValueError, "10 ميغابايت"):
|
||||
self.stage(["src/a.txt", "src/b.txt"])
|
||||
|
||||
def test_rejects_symlinked_input_when_supported(self) -> None:
|
||||
outside = self.root.parent / (self.root.name + "-linked.py")
|
||||
outside.write_text("outside = True\n", encoding="utf-8")
|
||||
link = self.root / "src" / "linked.py"
|
||||
try:
|
||||
try:
|
||||
link.symlink_to(outside)
|
||||
except OSError as exc:
|
||||
self.skipTest(f"symlinks unavailable in this Windows context: {exc}")
|
||||
with self.assertRaisesRegex(ValueError, "الروابط الرمزية"):
|
||||
self.stage(["src/linked.py"])
|
||||
finally:
|
||||
if link.exists() or link.is_symlink():
|
||||
link.unlink()
|
||||
outside.unlink(missing_ok=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user