diff --git a/docs/development/OpenNexus生产化实施进度-2026-09-08.md b/docs/development/OpenNexus生产化实施进度-2026-09-08.md index 2e151b0..339f40d 100644 --- a/docs/development/OpenNexus生产化实施进度-2026-09-08.md +++ b/docs/development/OpenNexus生产化实施进度-2026-09-08.md @@ -359,3 +359,13 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写 - 真实 AppContainer 探针使用该构造器启动,比较完整 argv 和完整环境变量集合:空串、空格、中文/emoji、引号/反斜杠、换行及 &|%PATH% 均原样收到,环境恰好为四个基础变量加 CUSTOM,未继承 PATH 等 Host 环境。测试也保留容器 SID/零能力/先挂起后入 Job 的核对。 - 45 项扩展回归通过、3 项 Job 辅助入口 ignored(由父测试实际调用),全目标 Clippy -D warnings 通过。日志 `.build/extension-launch-regression-tests.log`、`.build/extension-launch-clippy.log`。API 依据 [Microsoft CRT 参数解析](https://learn.microsoft.com/en-us/cpp/c-language/parsing-c-command-line-arguments?view=msvc-170) 和 [CreateProcessW](https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-createprocessw)。尚未覆盖所有第三方运行时的自定义命令行解析。 - 当前仍未开放第三方执行:正式启动器的许可/包句柄绑定、生产环境凭据解析、scratch 限额、broker 与完整端到端验收继续实施,不能据本轮参数测试认定生产化完成。 + + +## 增量:Windows 挂起进程所有权与恢复边界 + +- 新增 extension_process:使用零能力 SECURITY_CAPABILITIES、明确环境、容器自身工作目录、不可继承句柄和隐藏窗口创建挂起进程,先加入默认资源 Job,再核验 TokenIsAppContainer、精确 SID 和零 TokenCapabilities。属性列表、原生句柄及 Job 均有所有权清理;创建、入 Job 或身份核验失败不返回可恢复进程。 +- Suspended 与 Running 分开,resume 消耗挂起对象,要求 ResumeThread 返回预期挂起计数 1;不能经同一对象重复恢复。Profile 借用延续到进程对象释放,防止 Host 提前删除配置。Running 提供最多 60 秒的有界等待和整组主动终止;对象释放也终止 Job,关闭句柄并等待根进程退出。 +- resume 是带明确安全前置条件的底层接口,尚不替代执行许可、包/入口句柄、在线信任、Vault、broker 和完整资源策略核验。上层未满足全部条件时仍必须拒绝运行。未新增 renderer 启动命令,也未开启 extensions capability。 +- 原生参数/环境探针、IPv4/IPv6 环回探针已切换到此正式进程模块执行,相关行为回归通过。新增实际 Windows 测试验证不匹配 SID 拒绝、丢弃未恢复进程后句柄进入终止状态、入口不存在与损坏终止符失败不消耗 Profile。运行中的等待探针验证零时限返回未完成、超过 60 秒的等待请求拒绝、主动终止后五秒内观察到退出。 +- 47 项扩展回归通过,3 项 ignored 为由父测试驱动的 Job 辅助入口;全目标 Clippy -D warnings 通过。日志 `.build/extension-process-regression-tests.log`、`.build/extension-process-clippy.log`。 +- 该模块只收拢已验证的原生生命周期,不构成完整生产扩展启动器。许可和包句柄原子绑定、撤销停止、生产 broker、scratch 配额、工具总期限及崩溃后的配置清理仍未完成,整体生产化继续实施。 diff --git a/frontend/src-tauri/src/extension_container.rs b/frontend/src-tauri/src/extension_container.rs index 2ef9c13..ee02d27 100644 --- a/frontend/src-tauri/src/extension_container.rs +++ b/frontend/src-tauri/src/extension_container.rs @@ -327,6 +327,16 @@ mod tests { command: Option, mut data: Option, ) -> Option { + if let Some(data) = data.take() { + let suspended = + crate::extension_process::Suspended::create(profile, executable, data).unwrap(); + // Controlled test fixture only; no user extension is authorized here. + let running = unsafe { suspended.resume().unwrap() }; + let result = running.wait(std::time::Duration::from_secs(10)).unwrap(); + assert!(result.is_some()); + running.terminate().unwrap(); + return result; + } let resume = command.is_some() || data.is_some(); let mut attributes = Attributes::new(); let caps = SECURITY_CAPABILITIES { @@ -677,6 +687,32 @@ mod tests { checked_executable_data(&profile, &executable, None, Some(data)), Some(0) ); + let data = crate::extension_launch_data::LaunchData::new( + &executable, + &["wait".to_owned()], + &system, + &folder, + &folder.join("Temp"), + &std::collections::BTreeMap::new(), + ) + .unwrap(); + let suspended = + crate::extension_process::Suspended::create(&profile, &executable, data).unwrap(); + let running = unsafe { suspended.resume().unwrap() }; + assert_eq!(running.wait(std::time::Duration::ZERO).unwrap(), None); + assert_eq!( + running + .wait(std::time::Duration::from_millis(60001)) + .unwrap_err() + .code, + "EXTENSION_PROCESS_WAIT_INVALID" + ); + running.terminate().unwrap(); + assert!(running + .wait(std::time::Duration::from_secs(5)) + .unwrap() + .is_some()); + drop(running); for ip in ["127.0.0.1:0", "[::1]:0"] { let tcp = TcpListener::bind(ip).unwrap(); let udp = UdpSocket::bind(ip).unwrap(); @@ -702,11 +738,19 @@ mod tests { assert_eq!(&bytes[..5], b"probe"); udp.set_nonblocking(true).unwrap(); } - let command = format!("\"{}\" {mode} {address}", executable.display()); + let data = crate::extension_launch_data::LaunchData::new( + &executable, + &[mode.to_owned(), address.clone()], + &system, + &folder, + &folder.join("Temp"), + &std::collections::BTreeMap::new(), + ) + .unwrap(); // Loopback isolation can silently drop packets. TCP must // explicitly report denial or timeout; UDP send may succeed, // but no datagram may reach the controlled listener below. - let exit = checked_executable(&profile, &executable, Some(command)); + let exit = checked_executable_data(&profile, &executable, None, Some(data)); eprintln!("container network probe {mode} {address}: {exit:?}"); if mode == "tcp" { assert!(matches!(exit, Some(77 | 80)), "{mode} {address}: {exit:?}"); diff --git a/frontend/src-tauri/src/extension_launch_data.rs b/frontend/src-tauri/src/extension_launch_data.rs index 4e7b1e1..1efb732 100644 --- a/frontend/src-tauri/src/extension_launch_data.rs +++ b/frontend/src-tauri/src/extension_launch_data.rs @@ -143,7 +143,7 @@ impl LaunchData { result.environment.push(0); Ok(result) } - pub fn command_mut(&mut self) -> &mut [u16] { + pub(crate) fn command_mut(&mut self) -> &mut [u16] { &mut self.command } /// Pass with CREATE_UNICODE_ENVIRONMENT; never substitute a null pointer. diff --git a/frontend/src-tauri/src/extension_process.rs b/frontend/src-tauri/src/extension_process.rs new file mode 100644 index 0000000..6f8a6c0 --- /dev/null +++ b/frontend/src-tauri/src/extension_process.rs @@ -0,0 +1,339 @@ +//! Windows process ownership primitive. A suspended process is not execution +//! authorization; the extension runtime must complete its checks before resume. +use crate::{ + extension_container::Profile, + extension_job::Job, + extension_launch_data::LaunchData, + workspace::{HostError, Result}, +}; +use std::{ + mem::size_of, + os::windows::{ + ffi::OsStrExt, + io::{AsHandle, AsRawHandle, FromRawHandle, OwnedHandle}, + }, + path::Path, + time::Duration, +}; +use windows_sys::Win32::{ + Foundation::{WAIT_OBJECT_0, WAIT_TIMEOUT}, + Security::*, + System::Threading::*, +}; + +struct Attributes { + buffer: Vec, + initialized: bool, +} +impl Attributes { + fn new() -> Result { + let bad = || HostError::new("EXTENSION_PROCESS_ATTRIBUTES_FAILED"); + let mut bytes = 0; + unsafe { + InitializeProcThreadAttributeList(std::ptr::null_mut(), 1, 0, &mut bytes); + } + if bytes == 0 || bytes > 65536 { + return Err(bad()); + } + let mut value = Self { + buffer: vec![0; bytes.div_ceil(size_of::())], + initialized: false, + }; + if unsafe { + InitializeProcThreadAttributeList(value.buffer.as_mut_ptr().cast(), 1, 0, &mut bytes) + } == 0 + { + return Err(bad()); + } + value.initialized = true; + // The attribute stores a pointer to SECURITY_CAPABILITIES. The caller + // updates it with storage that remains alive until CreateProcessW. + Ok(value) + } +} +impl Drop for Attributes { + fn drop(&mut self) { + if self.initialized { + unsafe { + DeleteProcThreadAttributeList(self.buffer.as_mut_ptr().cast()); + } + } + } +} +struct Handles { + process: OwnedHandle, + thread: OwnedHandle, +} +impl Drop for Handles { + fn drop(&mut self) { + unsafe { + TerminateProcess(self.process.as_raw_handle(), 1); + WaitForSingleObject(self.process.as_raw_handle(), 5000); + } + } +} +struct Process<'a> { + handles: Handles, + job: Job, + _profile: &'a Profile, +} +impl Drop for Process<'_> { + fn drop(&mut self) { + let _ = self.job.terminate(); + } +} +pub struct Suspended<'a>(Process<'a>); +pub struct Running<'a>(Process<'a>); +impl<'a> Suspended<'a> { + /// Creates hidden, with no inherited handles and an explicit environment and + /// current directory. The profile borrow prevents cleanup while this owner + /// exists. This API never resumes extension instructions. + pub fn create(profile: &'a Profile, executable: &Path, mut data: LaunchData) -> Result { + let bad = || HostError::new("EXTENSION_PROCESS_CREATE_FAILED"); + if !executable.is_absolute() || data.command_mut().last() != Some(&0) { + return Err(bad()); + } + let executable: Vec = executable.as_os_str().encode_wide().collect(); + if executable.is_empty() || executable.len() > 32766 || executable.contains(&0) { + return Err(bad()); + } + let executable: Vec<_> = executable.into_iter().chain(Some(0)).collect(); + let folder = profile.folder()?; + let directory: Vec = folder.as_os_str().encode_wide().chain(Some(0)).collect(); + let mut attributes = Attributes::new()?; + let caps = SECURITY_CAPABILITIES { + AppContainerSid: profile.sid(), + Capabilities: std::ptr::null_mut(), + CapabilityCount: 0, + Reserved: 0, + }; + if 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 + { + return Err(HostError::new("EXTENSION_PROCESS_ATTRIBUTES_FAILED")); + } + let job = Job::new()?; + let mut startup = STARTUPINFOEXW::default(); + startup.StartupInfo.cb = size_of::() as u32; + startup.lpAttributeList = attributes.buffer.as_mut_ptr().cast(); + let mut info = PROCESS_INFORMATION::default(); + let environment = data.environment().as_ptr(); + if unsafe { + CreateProcessW( + executable.as_ptr(), + data.command_mut().as_mut_ptr(), + std::ptr::null(), + std::ptr::null(), + 0, + CREATE_SUSPENDED + | CREATE_NO_WINDOW + | EXTENDED_STARTUPINFO_PRESENT + | CREATE_UNICODE_ENVIRONMENT, + environment.cast(), + directory.as_ptr(), + &startup.StartupInfo, + &mut info, + ) + } == 0 + { + return Err(bad()); + } + let handles = Handles { + process: unsafe { OwnedHandle::from_raw_handle(info.hProcess) }, + thread: unsafe { OwnedHandle::from_raw_handle(info.hThread) }, + }; + unsafe { + job.assign_suspended(handles.process.as_handle())?; + } + let process = Process { + handles, + job, + _profile: profile, + }; + verify_identity(&process.handles, profile)?; + Ok(Self(process)) + } + /// # Safety + /// Caller must hold the verified package/entry handles and revalidate the + /// current execution permit, trust, Vault binding, environment declarations, + /// broker and all resource policy requirements immediately before this call. + /// None of those authorization checks is supplied by this low-level module. + pub unsafe fn resume(self) -> Result> { + if unsafe { ResumeThread(self.0.handles.thread.as_raw_handle()) } != 1 { + return Err(HostError::new("EXTENSION_PROCESS_RESUME_FAILED")); + } + Ok(Running(self.0)) + } +} +impl Running<'_> { + /// A bounded observation only. The runtime must enforce the tool deadline. + pub fn wait(&self, timeout: Duration) -> Result> { + let milliseconds = u32::try_from(timeout.as_millis()) + .ok() + .filter(|n| *n <= 60000) + .ok_or_else(|| HostError::new("EXTENSION_PROCESS_WAIT_INVALID"))?; + match unsafe { WaitForSingleObject(self.0.handles.process.as_raw_handle(), milliseconds) } { + WAIT_TIMEOUT => Ok(None), + WAIT_OBJECT_0 => { + let mut code = 0; + if unsafe { GetExitCodeProcess(self.0.handles.process.as_raw_handle(), &mut code) } + == 0 + { + return Err(HostError::new("EXTENSION_PROCESS_QUERY_FAILED")); + } + Ok(Some(code)) + } + _ => Err(HostError::new("EXTENSION_PROCESS_WAIT_FAILED")), + } + } + /// Terminates the entire managed group, including descendants. + pub fn terminate(&self) -> Result<()> { + self.0.job.terminate() + } +} +fn verify_identity(handles: &Handles, profile: &Profile) -> Result<()> { + let bad = || HostError::new("EXTENSION_PROCESS_IDENTITY_INVALID"); + let mut token = std::ptr::null_mut(); + if unsafe { OpenProcessToken(handles.process.as_raw_handle(), TOKEN_QUERY, &mut token) } == 0 { + return Err(bad()); + } + let token = unsafe { OwnedHandle::from_raw_handle(token) }; + let mut contained = 0u32; + let mut length = 0; + if unsafe { + GetTokenInformation( + token.as_raw_handle(), + TokenIsAppContainer, + (&mut contained as *mut u32).cast(), + size_of::() as u32, + &mut length, + ) + } == 0 + || contained != 1 + || length != 4 + { + return Err(bad()); + } + let query = |class: TOKEN_INFORMATION_CLASS| -> Result> { + let mut bytes = 0; + unsafe { + GetTokenInformation( + token.as_raw_handle(), + class, + std::ptr::null_mut(), + 0, + &mut bytes, + ); + } + if !(4..=4096).contains(&bytes) { + return Err(bad()); + } + let mut buffer = vec![0usize; (bytes as usize).div_ceil(size_of::())]; + let capacity = bytes; + if unsafe { + GetTokenInformation( + token.as_raw_handle(), + class, + buffer.as_mut_ptr().cast(), + capacity, + &mut bytes, + ) + } == 0 + || bytes < 4 + || bytes > capacity + { + return Err(bad()); + } + Ok(buffer) + }; + let identity = query(TokenAppContainerSid)?; + if identity.len() * size_of::() < size_of::() { + return Err(bad()); + } + let identity = unsafe { &*identity.as_ptr().cast::() }; + if identity.TokenAppContainer.is_null() + || unsafe { EqualSid(identity.TokenAppContainer, profile.sid()) } == 0 + { + return Err(bad()); + } + let capabilities = query(TokenCapabilities)?; + if unsafe { *capabilities.as_ptr().cast::() } != 0 { + return Err(bad()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + fn data(profile: &Profile, entry: &Path) -> LaunchData { + let system = std::path::PathBuf::from(std::env::var_os("SystemRoot").unwrap()); + let folder = profile.folder().unwrap(); + LaunchData::new( + entry, + &[], + &system, + &folder, + &folder.join("Temp"), + &std::collections::BTreeMap::new(), + ) + .unwrap() + } + #[test] + fn dropped_suspended_owner_terminates_process_and_wrong_identity_is_rejected() { + let profile = Profile::create().unwrap(); + let other = Profile::create().unwrap(); + let entry = std::path::PathBuf::from(std::env::var_os("SystemRoot").unwrap()) + .join("System32/cmd.exe"); + let suspended = Suspended::create(&profile, &entry, data(&profile, &entry)).unwrap(); + assert_eq!( + verify_identity(&suspended.0.handles, &other) + .unwrap_err() + .code, + "EXTENSION_PROCESS_IDENTITY_INVALID" + ); + let observer = suspended.0.handles.process.try_clone().unwrap(); + assert_eq!( + unsafe { WaitForSingleObject(observer.as_raw_handle(), 0) }, + WAIT_TIMEOUT + ); + drop(suspended); // Never resumed any command interpreter instruction. + assert_eq!( + unsafe { WaitForSingleObject(observer.as_raw_handle(), 5000) }, + WAIT_OBJECT_0 + ); + drop(observer); + other.remove().unwrap(); + profile.remove().unwrap(); + } + #[test] + fn invalid_command_and_missing_entry_fail_without_consuming_profile() { + let profile = Profile::create().unwrap(); + let directory = tempfile::tempdir().unwrap(); + let entry = directory.path().join("does-not-exist.exe"); + let error = Suspended::create(&profile, &entry, data(&profile, &entry)) + .err() + .unwrap(); + assert_eq!(error.code, "EXTENSION_PROCESS_CREATE_FAILED"); + let mut invalid = data(&profile, &entry); + *invalid.command_mut().last_mut().unwrap() = 1; + assert_eq!( + Suspended::create(&profile, &entry, invalid) + .err() + .unwrap() + .code, + "EXTENSION_PROCESS_CREATE_FAILED" + ); + assert!(profile.folder().unwrap().is_dir()); + profile.remove().unwrap(); + } +} diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index a7b8663..4dedfea 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -60,3 +60,6 @@ pub mod extension_container; #[cfg(windows)] pub mod extension_launch_data; + +#[cfg(windows)] +pub mod extension_process; diff --git a/frontend/src-tauri/tests/fixtures/sandbox_network_probe.rs b/frontend/src-tauri/tests/fixtures/sandbox_network_probe.rs index 3022d5c..9b0a67b 100644 --- a/frontend/src-tauri/tests/fixtures/sandbox_network_probe.rs +++ b/frontend/src-tauri/tests/fixtures/sandbox_network_probe.rs @@ -3,6 +3,10 @@ use std::net::{SocketAddr, TcpStream, UdpSocket}; use std::time::Duration; fn main() { let args: Vec<_> = std::env::args().collect(); + if args.get(1).is_some_and(|s| s == "wait") { + std::thread::sleep(Duration::from_secs(60)); + std::process::exit(84); + } if args.get(1).is_some_and(|s| s == "launch") { let expected = [ "launch",