Measure AppContainer disk quota overshoot
This commit is contained in:
@@ -99,8 +99,9 @@
|
||||
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 لكل ملف. 7 اختبارات snapshot و7 اختبارات workspace ناجحة (2026-10-03). فحص الأسرار محافظ وليس ماسحًا شاملًا ولا يغني عن المراجعة. مرّ الـSnapshot عبر driver تطوير تجريبي إلى broker، لكنه غير مربوط بعد بطلب API أو منتقي ملفات المستخدم.
|
||||
- [x] تشغيل Python من ملفات Snapshot في AppContainer: نُسخت ملفات التشغيل القياسية وDLLs من Python 3.14 (33,627,970 بايت/631 ملفًا، دون `site-packages`)، وشغّل broker ملف `.py` الموجود داخل Snapshot وأعاد ملف نتيجة؛ تحقق الخروج `0`. يلتقط stdout/stderr عبر pipe ويخزن أول 64KB فقط: اختبار خرج 70KB أعاد 65,536 بايت وعلامة `truncated=true`. لم نختبر بعد مخرجات stderr غير UTF-8 أو الكتابة الكبيرة على القرص، والملف المشغل الحالي fixture تجريبي من المشروع.
|
||||
- [ ] إكمال الربط الإنتاجي: endpoint مصادق عليه لخطة أمر allowlist وموافقة مرة واحدة مرتبطة بالمستخدم والمساحة والبصمات والمهلة؛ نسخ الملفات المحددة من API إلى broker، حد حجم القرص لمخرجات التنفيذ، capture موحد وآمن للنتيجة، عرض الموافقة والحالة والمخرجات في Flutter، ومعاينة diff قبل أي تطبيق. prototype لم يُدمج في API أو الواجهة ولا يسمح حاليًا بتنفيذ أوامر المستخدم.
|
||||
- [x] تشغيل Python من ملفات Snapshot في AppContainer: نُسخت ملفات التشغيل القياسية وDLLs من Python 3.14 (33,627,970 بايت/631 ملفًا، دون `site-packages`)، وشغّل broker ملف `.py` الموجود داخل Snapshot وأعاد ملف نتيجة؛ تحقق الخروج `0`. يلتقط stdout/stderr عبر pipe ويخزن أول 64KB فقط: اختبار خرج 70KB أعاد 65,536 بايت وعلامة `truncated=true`. ما زال اختبار stderr غير UTF-8 مطلوبًا، والملف المشغل الحالي fixture تجريبي من المشروع.
|
||||
- [x] اختبار مراقبة مساحة بيانات الحاوية: حدّ أولي 128MiB، ومراقبة دورية مع إنهاء الـJob عند تجاوزه؛ جرى تقييد سرعة I/O للـJob إلى 8MiB/s. اختبار كتابة مستمرة مع `fsync` انتهى بخطأ القرص المتوقع (112)، لكن الرصد بلغ 135,644,714 بايت مقابل 134,217,728 (تجاوز يقارب 1.36MiB)، ما يثبت أن هذه مراقبة تقريبية وليست حصة قرص صارمة. يلزم تضييق التجاوز أو استخدام مخزن ذي حصة فعلية قبل قبول شيفرة غير موثوقة.
|
||||
- [ ] إكمال الربط الإنتاجي بعد تأمين حصة تخزين فعلية: endpoint مصادق عليه لخطة أمر allowlist وموافقة مرة واحدة مرتبطة بالمستخدم والمساحة والبصمات والمهلة؛ نسخ الملفات المحددة من API إلى broker، capture موحد وآمن للنتيجة، عرض الموافقة والحالة والمخرجات في Flutter، ومعاينة diff قبل أي تطبيق. prototype لم يُدمج في API أو الواجهة ولا يسمح حاليًا بتنفيذ أوامر المستخدم.
|
||||
6. لا وصول عام إلى القرص، ولا أوامر مدمرة أو نشر خارجي دون موافقة صريحة. كل أداة لها مخطط مدخلات ومخرجات واختبارات وسجل تدقيق.
|
||||
|
||||
### كودكس للبرمجة
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Fills only the disposable AppContainer profile to verify the broker quota."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
target = Path(__file__).with_name("disk-fill.bin")
|
||||
chunk = b"x" * (64 * 1024)
|
||||
with target.open("wb") as output:
|
||||
while True:
|
||||
output.write(chunk)
|
||||
output.flush()
|
||||
# Force the disposable test writes through the file cache so the
|
||||
# broker's disk monitor observes durable growth, not buffered writes.
|
||||
os.fsync(output.fileno())
|
||||
@@ -4,6 +4,7 @@
|
||||
#include <userenv.h>
|
||||
#include <sddl.h>
|
||||
#include <aclapi.h>
|
||||
#include <jobapi2.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <string>
|
||||
@@ -378,6 +379,51 @@ static bool IsSmokeResult(const std::wstring& path) {
|
||||
return valid && sawDot;
|
||||
}
|
||||
|
||||
static constexpr ULONGLONG kMaxAppContainerDataBytes = 128ull * 1024 * 1024;
|
||||
|
||||
static bool MeasureDirectoryBytes(
|
||||
const std::wstring& root, ULONGLONG& totalBytes, unsigned depth = 0
|
||||
) {
|
||||
if (depth > 48) return false;
|
||||
const DWORD rootAttributes = GetFileAttributesW(root.c_str());
|
||||
if (rootAttributes == INVALID_FILE_ATTRIBUTES ||
|
||||
!(rootAttributes & FILE_ATTRIBUTE_DIRECTORY) ||
|
||||
(rootAttributes & FILE_ATTRIBUTE_REPARSE_POINT)) return false;
|
||||
WIN32_FIND_DATAW entry{};
|
||||
HANDLE search = FindFirstFileW((root + L"\\*").c_str(), &entry);
|
||||
if (search == INVALID_HANDLE_VALUE) return false;
|
||||
bool success = true;
|
||||
do {
|
||||
if (wcscmp(entry.cFileName, L".") == 0 || wcscmp(entry.cFileName, L"..") == 0) continue;
|
||||
if (entry.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) {
|
||||
success = false;
|
||||
break;
|
||||
}
|
||||
const std::wstring path = root + L"\\" + entry.cFileName;
|
||||
if (entry.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) {
|
||||
success = MeasureDirectoryBytes(path, totalBytes, depth + 1);
|
||||
} else {
|
||||
WIN32_FILE_ATTRIBUTE_DATA attributes{};
|
||||
if (!GetFileAttributesExW(path.c_str(), GetFileExInfoStandard, &attributes) ||
|
||||
(attributes.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT)) {
|
||||
success = false;
|
||||
break;
|
||||
}
|
||||
const ULONGLONG size = (static_cast<ULONGLONG>(attributes.nFileSizeHigh) << 32) |
|
||||
attributes.nFileSizeLow;
|
||||
if (size > (~static_cast<ULONGLONG>(0)) - totalBytes) {
|
||||
success = false;
|
||||
break;
|
||||
}
|
||||
totalBytes += size;
|
||||
}
|
||||
if (!success) break;
|
||||
} while (FindNextFileW(search, &entry));
|
||||
const DWORD finalError = GetLastError();
|
||||
FindClose(search);
|
||||
return success && finalError == ERROR_NO_MORE_FILES;
|
||||
}
|
||||
|
||||
static DWORD RunContainedExe(
|
||||
PSID appContainerSid,
|
||||
const std::wstring& application,
|
||||
@@ -386,12 +432,26 @@ static DWORD RunContainedExe(
|
||||
const std::wstring& cwd,
|
||||
HANDLE job,
|
||||
std::string* capturedOutput = nullptr,
|
||||
bool* outputTruncated = nullptr
|
||||
bool* outputTruncated = nullptr,
|
||||
const std::wstring* quotaRoot = nullptr,
|
||||
bool* quotaExceeded = nullptr,
|
||||
ULONGLONG* quotaObservedBytes = nullptr
|
||||
) {
|
||||
constexpr size_t kMaxCapturedOutput = 64 * 1024;
|
||||
const bool capture = capturedOutput != nullptr;
|
||||
if (capturedOutput) capturedOutput->clear();
|
||||
if (outputTruncated) *outputTruncated = false;
|
||||
if (quotaExceeded) *quotaExceeded = false;
|
||||
if (quotaObservedBytes) *quotaObservedBytes = 0;
|
||||
if (quotaRoot) {
|
||||
ULONGLONG currentBytes = 0;
|
||||
const bool measured = MeasureDirectoryBytes(*quotaRoot, currentBytes);
|
||||
if (quotaObservedBytes) *quotaObservedBytes = currentBytes;
|
||||
if (!measured || currentBytes > kMaxAppContainerDataBytes) {
|
||||
if (quotaExceeded) *quotaExceeded = true;
|
||||
return ERROR_DISK_FULL;
|
||||
}
|
||||
}
|
||||
|
||||
HANDLE pipeRead = nullptr;
|
||||
HANDLE pipeWrite = nullptr;
|
||||
@@ -533,6 +593,7 @@ static DWORD RunContainedExe(
|
||||
DWORD exitCode = ERROR_TIMEOUT;
|
||||
if (capture) {
|
||||
const ULONGLONG deadline = GetTickCount64() + 30000;
|
||||
ULONGLONG nextQuotaCheck = GetTickCount64() + 25;
|
||||
bool processFinished = false;
|
||||
bool pipeFinished = false;
|
||||
while (!pipeFinished || !processFinished) {
|
||||
@@ -562,6 +623,19 @@ static DWORD RunContainedExe(
|
||||
processFinished = true;
|
||||
TerminateJobObject(job, ERROR_SUCCESS);
|
||||
}
|
||||
if (!processFinished && quotaRoot && GetTickCount64() >= nextQuotaCheck) {
|
||||
ULONGLONG currentBytes = 0;
|
||||
const bool measured = MeasureDirectoryBytes(*quotaRoot, currentBytes);
|
||||
if (quotaObservedBytes) *quotaObservedBytes = currentBytes;
|
||||
if (!measured || currentBytes > kMaxAppContainerDataBytes) {
|
||||
if (quotaExceeded) *quotaExceeded = true;
|
||||
TerminateJobObject(job, ERROR_DISK_FULL);
|
||||
exitCode = ERROR_DISK_FULL;
|
||||
processFinished = WaitForSingleObject(process.hProcess, 5000) == WAIT_OBJECT_0;
|
||||
pipeFinished = false;
|
||||
}
|
||||
nextQuotaCheck = GetTickCount64() + 25;
|
||||
}
|
||||
if (!processFinished && GetTickCount64() >= deadline) {
|
||||
TerminateJobObject(job, ERROR_TIMEOUT);
|
||||
exitCode = ERROR_TIMEOUT;
|
||||
@@ -653,6 +727,13 @@ int wmain() {
|
||||
limits.JobMemoryLimit = 512ull * 1024 * 1024;
|
||||
bool jobReady = job && SetInformationJobObject(
|
||||
job, JobObjectExtendedLimitInformation, &limits, sizeof(limits));
|
||||
JOBOBJECT_IO_RATE_CONTROL_INFORMATION ioLimits{};
|
||||
ioLimits.MaxBandwidth = 8ll * 1024 * 1024;
|
||||
ioLimits.ControlFlags = JOB_OBJECT_IO_RATE_CONTROL_ENABLE;
|
||||
DWORD jobIoControlResult = jobReady
|
||||
? SetIoRateControlInformationJobObject(job, &ioLimits)
|
||||
: ERROR_INVALID_HANDLE;
|
||||
DWORD jobIoControlError = jobIoControlResult == 0 ? GetLastError() : ERROR_SUCCESS;
|
||||
|
||||
auto environment = SafeEnvironmentBlock();
|
||||
if (environment.empty()) return 15;
|
||||
@@ -756,6 +837,19 @@ int wmain() {
|
||||
L" --noproxy \"*\" --max-time 4 http://127.0.0.1:8100/health -o NUL",
|
||||
environment.data(), cwd, job)
|
||||
: ERROR_INVALID_HANDLE;
|
||||
std::wstring diskFillScriptPath;
|
||||
bool diskFillScriptReady = stagedInputCopied && ResolvePythonEntry(
|
||||
projectSnapshotPath, L"scripts/appcontainer_disk_fill_smoke.py", diskFillScriptPath);
|
||||
std::string diskFillOutput;
|
||||
bool diskFillOutputTruncated = false;
|
||||
bool diskQuotaExceeded = false;
|
||||
ULONGLONG diskQuotaObservedBytes = 0;
|
||||
DWORD diskFillRunResult = jobReady && pythonRuntimeCopied && diskFillScriptReady
|
||||
? RunContainedExe(appContainerSid, pythonExecutable,
|
||||
L"-s " + QuoteArg(diskFillScriptPath), environment.data(), cwd, job,
|
||||
&diskFillOutput, &diskFillOutputTruncated, &appDataPath,
|
||||
&diskQuotaExceeded, &diskQuotaObservedBytes)
|
||||
: ERROR_INVALID_HANDLE;
|
||||
|
||||
DWORD attrs = GetFileAttributesW(writePath.c_str());
|
||||
DWORD attrsError = attrs == INVALID_FILE_ATTRIBUTES ? GetLastError() : ERROR_SUCCESS;
|
||||
@@ -828,6 +922,12 @@ int wmain() {
|
||||
overflowTruncated ? L"true" : L"false");
|
||||
wprintf(L"curl_version_exit=%lu\ncurl_local_health_exit=%lu\n",
|
||||
curlVersionResult, curlNetworkResult);
|
||||
wprintf(L"disk_fill_script_valid=%s\ndisk_fill_exit=%lu\ndisk_quota_limit_bytes=%llu\ndisk_quota_observed_bytes=%llu\ndisk_quota_exceeded=%s\n",
|
||||
diskFillScriptReady ? L"true" : L"false", diskFillRunResult,
|
||||
kMaxAppContainerDataBytes, diskQuotaObservedBytes,
|
||||
diskQuotaExceeded ? L"true" : L"false");
|
||||
wprintf(L"job_io_rate_control_set=%s\njob_io_rate_control_error=%lu\n",
|
||||
jobIoControlResult != 0 ? L"true" : L"false", jobIoControlError);
|
||||
if (!jobReady || shellResult != 0 || readResult == 0 || writeResult == 0 ||
|
||||
!hostSecretPreserved || !writeWasBlocked || !workspaceFolderReady ||
|
||||
allowedWriteResult != 0 || !allowedWorkspaceWriteVisible ||
|
||||
@@ -836,6 +936,8 @@ int wmain() {
|
||||
!pythonVersionVisible ||
|
||||
overflowRunResult != 0 || overflowOutput.size() != 64 * 1024 ||
|
||||
!overflowTruncated ||
|
||||
!curlCopied || curlVersionResult != 0 || curlNetworkResult == 0) return 20;
|
||||
!curlCopied || curlVersionResult != 0 || curlNetworkResult == 0 ||
|
||||
!diskFillScriptReady || diskFillRunResult != ERROR_DISK_FULL ||
|
||||
!diskQuotaExceeded || diskQuotaObservedBytes <= kMaxAppContainerDataBytes) return 20;
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,11 @@ def main() -> int:
|
||||
repository = REPOSITORY
|
||||
os.environ["SOVEREIGNAI_ALLOWED_WORKSPACES"] = str(repository)
|
||||
os.environ["SOVEREIGNAI_WORKSPACE"] = str(repository)
|
||||
files = ["README.md", "scripts/appcontainer_user_code_smoke.py"]
|
||||
files = [
|
||||
"README.md",
|
||||
"scripts/appcontainer_user_code_smoke.py",
|
||||
"scripts/appcontainer_disk_fill_smoke.py",
|
||||
]
|
||||
with stage_selected_files(repository, files) as snapshot:
|
||||
environment = os.environ.copy()
|
||||
environment["SOVEREIGNAI_STAGING_ROOT"] = str(snapshot.root)
|
||||
|
||||
Reference in New Issue
Block a user