diff --git a/docs/development/OpenNexus生产化实施进度-2026-09-08.md b/docs/development/OpenNexus生产化实施进度-2026-09-08.md index 739d555..bda7167 100644 --- a/docs/development/OpenNexus生产化实施进度-2026-09-08.md +++ b/docs/development/OpenNexus生产化实施进度-2026-09-08.md @@ -321,3 +321,12 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写 - 4 项真实 Windows 进程测试与全目标 Clippy -D warnings 通过,日志 `.build/extension-job-tests.log`。三个 ignored 条目是由父测试实际启动的辅助入口。验证进程配额为 1 时第二进程被拒绝且从未恢复、关闭 Job 后进程退出、受管理后代计数归零、显式终止,以及正常 32 MiB 分配成功/600 MiB 分配失败。 - 测试初次使用固定等待观察进程计数失败,改为最多 5 秒等待计数收敛后通过;不将单次固定延时当作清理证明。生产参数测试读取 OS 返回的内存、进程和 CPU 配置,但尚未完成长时间 CPU 压力、10 秒超限终止及 scratch 配额测试。 - API 依据 Microsoft Job Objects 与 JOBOBJECT_CPU_RATE_CONTROL_INFORMATION 文档: https://learn.microsoft.com/en-us/windows/win32/procthread/job-objects 、 https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-jobobject_cpu_rate_control_information 。AppContainer、文件/网络 broker、恶意程序矩阵及实际扩展调用仍待完成,不能据此通过 C 项或开启 extensions capability。 + + +## 增量:AppContainer 配置所有权与原生身份验证 + +- 新增 extension_container::Profile,为每个实例创建随机 OpenNexus.sandbox 名称、无声明能力的 AppContainer 配置,校验 SID 并提供 OS 返回的配置目录。同名已存在时拒绝,不接管或删除旧配置;显式 remove 报告清理失败,Drop 尝试最终清理并释放 SID。 +- 真实 Windows 测试创建两个配置验证 SID 不同、重复名称失败不破坏原配置、显式删除后可重新创建。另用 SECURITY_CAPABILITIES 创建挂起的系统进程,先加入 Job,再读取 TokenIsAppContainer、TokenAppContainerSid 与 TokenCapabilities,确认容器身份、精确 SID 和零能力;没有恢复命令解释器执行。 +- 空环境及仅 SystemRoot 的启动尝试返回 Windows 203;使用明确的 SystemRoot 和指向容器自身目录的 LOCALAPPDATA/TEMP/TMP 后通过。此处是测试启动环境,不是完整生产环境/参数构造器。 +- 两项 AppContainer 原生测试与全目标 Clippy -D warnings 通过,日志 `.build/extension-container-tests.log`。API 依据 Microsoft CreateAppContainerProfile / DeleteAppContainerProfile / Launch an AppContainer 文档。所有测试创建的配置通过所属对象清理,未借用既有用户配置。 +- 生产启动器、包只读 ACL、独立 scratch 配额、原始网络拒绝实测、文件与网络 broker、崩溃后孤立配置清理以及恶意程序矩阵仍待完成。当前不声明完整沙箱,也未开启 extensions capability。 diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml index a103b41..b58c621 100644 --- a/frontend/src-tauri/Cargo.toml +++ b/frontend/src-tauri/Cargo.toml @@ -48,7 +48,7 @@ cap-fs-ext = "4.0.2" jsonschema = { version = "0.55", default-features = false } [target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_System_SystemInformation", "Win32_Storage_FileSystem", "Win32_System_RemoteDesktop", "Win32_UI_WindowsAndMessaging", "Win32_Graphics_Gdi", "Win32_System_LibraryLoader"] } +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Isolation", "Win32_Security_Authorization", "Win32_System_Com", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_System_SystemInformation", "Win32_Storage_FileSystem", "Win32_System_RemoteDesktop", "Win32_UI_WindowsAndMessaging", "Win32_Graphics_Gdi", "Win32_System_LibraryLoader"] } [build-dependencies] tauri-build = { version = "2", optional = true , features = [] } diff --git a/frontend/src-tauri/src/extension_container.rs b/frontend/src-tauri/src/extension_container.rs new file mode 100644 index 0000000..0089130 --- /dev/null +++ b/frontend/src-tauri/src/extension_container.rs @@ -0,0 +1,360 @@ +//! Per-instance AppContainer profile ownership. No existing profile is adopted. +use crate::workspace::{HostError, Result}; +use windows_sys::Win32::Security::{ + FreeSid, IsValidSid, + Isolation::{CreateAppContainerProfile, DeleteAppContainerProfile}, + PSID, +}; + +pub struct Profile { + name: Vec, + sid: PSID, + exists: bool, +} +impl Profile { + pub fn create() -> Result { + Self::create_named(format!( + "OpenNexus.sandbox.{}", + uuid::Uuid::new_v4().simple() + )) + } + fn create_named(name: String) -> Result { + let name: Vec = name.encode_utf16().chain(Some(0)).collect(); + let mut sid = std::ptr::null_mut(); + let status = unsafe { + CreateAppContainerProfile( + name.as_ptr(), + name.as_ptr(), + name.as_ptr(), + std::ptr::null(), + 0, + &mut sid, + ) + }; + if status < 0 { + // In particular, ERROR_ALREADY_EXISTS must not transfer ownership. + if !sid.is_null() { + unsafe { + FreeSid(sid); + } + } + return Err(HostError::new("EXTENSION_CONTAINER_CREATE_FAILED")); + } + let profile = Self { + name, + sid, + exists: true, + }; + if sid.is_null() || unsafe { IsValidSid(sid) } == 0 { + return Err(HostError::new("EXTENSION_CONTAINER_SID_INVALID")); + } + Ok(profile) + } + /// Borrowed SID for SECURITY_CAPABILITIES. Valid only while this owner lives. + pub fn sid(&self) -> PSID { + self.sid + } + pub fn folder(&self) -> Result { + use std::os::windows::ffi::OsStringExt; + use windows_sys::Win32::{ + Foundation::LocalFree, + Security::{ + Authorization::ConvertSidToStringSidW, Isolation::GetAppContainerFolderPath, + }, + System::Com::CoTaskMemFree, + }; + let mut string = std::ptr::null_mut(); + if unsafe { ConvertSidToStringSidW(self.sid, &mut string) } == 0 { + return Err(HostError::new("EXTENSION_CONTAINER_SID_INVALID")); + } + let mut folder = std::ptr::null_mut(); + let status = unsafe { GetAppContainerFolderPath(string, &mut folder) }; + unsafe { + LocalFree(string.cast()); + } + if status < 0 || folder.is_null() { + return Err(HostError::new("EXTENSION_CONTAINER_FOLDER_FAILED")); + } + let mut length = 0; + while length < 32768 && unsafe { *folder.add(length) } != 0 { + length += 1; + } + let result = if length == 32768 { + Err(HostError::new("EXTENSION_CONTAINER_FOLDER_FAILED")) + } else { + Ok(std::path::PathBuf::from(std::ffi::OsString::from_wide( + unsafe { std::slice::from_raw_parts(folder, length) }, + ))) + }; + unsafe { + CoTaskMemFree(folder.cast()); + } + result + } + /// Stop all container processes and close their handles before removal. + pub fn remove(mut self) -> Result<()> { + self.remove_inner() + } + fn remove_inner(&mut self) -> Result<()> { + if self.exists { + if unsafe { DeleteAppContainerProfile(self.name.as_ptr()) } < 0 { + return Err(HostError::new("EXTENSION_CONTAINER_CLEANUP_FAILED")); + } + self.exists = false; + } + Ok(()) + } +} +impl Drop for Profile { + fn drop(&mut self) { + // Explicit remove reports failures; drop is a final best-effort retry. + let _ = self.remove_inner(); + if !self.sid.is_null() { + unsafe { + FreeSid(self.sid); + } + self.sid = std::ptr::null_mut(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{ + mem::size_of, + os::windows::{ + ffi::OsStrExt, + io::{AsHandle, AsRawHandle, FromRawHandle, OwnedHandle}, + }, + }; + use windows_sys::Win32::{ + Security::{ + EqualSid, GetTokenInformation, TokenIsAppContainer, SECURITY_CAPABILITIES, TOKEN_QUERY, + }, + System::Threading::*, + }; + + #[test] + fn distinct_profiles_and_collision_do_not_take_ownership_of_existing_data() { + let one = Profile::create().unwrap(); + let two = Profile::create().unwrap(); + assert_eq!(unsafe { EqualSid(one.sid(), two.sid()) }, 0); + let name = String::from_utf16(&one.name[..one.name.len() - 1]).unwrap(); + assert!(Profile::create_named(name.clone()).is_err()); + // Repeated collision must still fail: the failing owner did not delete it. + assert!(Profile::create_named(name.clone()).is_err()); + one.remove().unwrap(); + Profile::create_named(name).unwrap().remove().unwrap(); + two.remove().unwrap(); + } + + struct Attributes { + buffer: Vec, + } + impl Attributes { + fn new() -> Self { + let mut bytes = 0; + unsafe { + InitializeProcThreadAttributeList(std::ptr::null_mut(), 1, 0, &mut bytes); + } + assert!(bytes > 0); + let mut result = Self { + buffer: vec![0; bytes.div_ceil(size_of::())], + }; + assert_ne!( + unsafe { + InitializeProcThreadAttributeList( + result.buffer.as_mut_ptr().cast(), + 1, + 0, + &mut bytes, + ) + }, + 0 + ); + result + } + } + impl Drop for Attributes { + fn drop(&mut self) { + unsafe { + DeleteProcThreadAttributeList(self.buffer.as_mut_ptr().cast()); + } + } + } + struct Process { + process: OwnedHandle, + _thread: OwnedHandle, + } + impl Drop for Process { + fn drop(&mut self) { + unsafe { + TerminateProcess(self.process.as_raw_handle(), 1); + WaitForSingleObject(self.process.as_raw_handle(), 5000); + } + } + } + #[test] + fn real_suspended_process_has_appcontainer_token_before_job_resume() { + let profile = Profile::create().unwrap(); + let mut attributes = Attributes::new(); + let caps = SECURITY_CAPABILITIES { + AppContainerSid: profile.sid(), + Capabilities: std::ptr::null_mut(), + CapabilityCount: 0, + Reserved: 0, + }; + assert_ne!( + unsafe { + UpdateProcThreadAttribute( + attributes.buffer.as_mut_ptr().cast(), + 0, + PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES as usize, + (&caps as *const SECURITY_CAPABILITIES).cast(), + size_of::(), + std::ptr::null_mut(), + std::ptr::null(), + ) + }, + 0 + ); + let mut startup = STARTUPINFOEXW::default(); + startup.StartupInfo.cb = size_of::() as u32; + startup.lpAttributeList = attributes.buffer.as_mut_ptr().cast(); + let executable = std::path::PathBuf::from(std::env::var_os("SystemRoot").unwrap()) + .join("System32/cmd.exe"); + let executable: Vec = executable + .as_os_str() + .encode_wide() + .chain(Some(0)) + .collect(); + let folder = profile.folder().unwrap(); + let environment: Vec = format!( + "LOCALAPPDATA={}\0SystemRoot={}\0TEMP={}\0TMP={}\0\0", + folder.display(), + std::env::var("SystemRoot").unwrap(), + folder.join("Temp").display(), + folder.join("Temp").display() + ) + .encode_utf16() + .collect(); + let mut info = PROCESS_INFORMATION::default(); + assert_ne!( + unsafe { + CreateProcessW( + executable.as_ptr(), + std::ptr::null_mut(), + std::ptr::null(), + std::ptr::null(), + 0, + CREATE_SUSPENDED + | CREATE_NO_WINDOW + | EXTENDED_STARTUPINFO_PRESENT + | CREATE_UNICODE_ENVIRONMENT, + environment.as_ptr().cast(), + std::ptr::null(), + &startup.StartupInfo, + &mut info, + ) + }, + 0, + "AppContainer process creation failed: {}", + std::io::Error::last_os_error() + ); + let process = Process { + process: unsafe { OwnedHandle::from_raw_handle(info.hProcess) }, + _thread: unsafe { OwnedHandle::from_raw_handle(info.hThread) }, + }; + let job = crate::extension_job::Job::new().unwrap(); + unsafe { + job.assign_suspended(process.process.as_handle()).unwrap(); + } + let mut token = std::ptr::null_mut(); + assert_ne!( + unsafe { OpenProcessToken(process.process.as_raw_handle(), TOKEN_QUERY, &mut token) }, + 0 + ); + let token = unsafe { OwnedHandle::from_raw_handle(token) }; + let mut contained = 0u32; + let mut length = 0; + assert_ne!( + unsafe { + GetTokenInformation( + token.as_raw_handle(), + TokenIsAppContainer, + (&mut contained as *mut u32).cast(), + size_of::() as u32, + &mut length, + ) + }, + 0 + ); + assert_eq!(contained, 1); + use windows_sys::Win32::Security::{ + TokenAppContainerSid, TokenCapabilities, TOKEN_APPCONTAINER_INFORMATION, + }; + let mut required = 0; + unsafe { + GetTokenInformation( + token.as_raw_handle(), + TokenAppContainerSid, + std::ptr::null_mut(), + 0, + &mut required, + ); + } + assert!(required > 0 && required < 4096); + let mut data = vec![0usize; (required as usize).div_ceil(size_of::())]; + assert_ne!( + unsafe { + GetTokenInformation( + token.as_raw_handle(), + TokenAppContainerSid, + data.as_mut_ptr().cast(), + required, + &mut required, + ) + }, + 0 + ); + let identity = unsafe { &*data.as_ptr().cast::() }; + assert_ne!( + unsafe { EqualSid(identity.TokenAppContainer, profile.sid()) }, + 0 + ); + let mut required = 0; + unsafe { + GetTokenInformation( + token.as_raw_handle(), + TokenCapabilities, + std::ptr::null_mut(), + 0, + &mut required, + ); + } + assert!((4..4096).contains(&required)); + let mut capabilities = vec![0usize; (required as usize).div_ceil(size_of::())]; + assert_ne!( + unsafe { + GetTokenInformation( + token.as_raw_handle(), + TokenCapabilities, + capabilities.as_mut_ptr().cast(), + required, + &mut required, + ) + }, + 0 + ); + assert_eq!(unsafe { *capabilities.as_ptr().cast::() }, 0); + + // No command interpreter instruction was resumed by this identity test. + job.terminate().unwrap(); + drop(token); + drop(process); + drop(job); + drop(attributes); + profile.remove().unwrap(); + } +} diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index bd575c3..fe9fcc1 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -54,3 +54,6 @@ pub mod extension_trust; #[cfg(windows)] pub mod extension_job; + +#[cfg(windows)] +pub mod extension_container;