diff --git a/docs/development/OpenNexus生产化实施进度-2026-09-08.md b/docs/development/OpenNexus生产化实施进度-2026-09-08.md index b35d159..8a0258c 100644 --- a/docs/development/OpenNexus生产化实施进度-2026-09-08.md +++ b/docs/development/OpenNexus生产化实施进度-2026-09-08.md @@ -419,3 +419,13 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写 - 实际 Stronghold 测试验证 Provider 同名凭据不可见、正确 MCP 包域可用、重新签发后改变类型/来源/命名空间/包 ID 仍不能读取原凭据,以及入口/树/Vault/策略不匹配、参数篡改、过期、锁定和编码错误拒绝。真实 AppContainer 原生探针通过该准备链取得虚构测试凭据后启动,完整 argv/环境核对通过,未使用任何用户真实秘密。 - 52 项扩展回归通过,4 项 ignored 为三个由父测试驱动的 Job 辅助入口和单独执行的 60 秒验收;全目标 Clippy -D warnings 通过。日志 `.build/extension-launch-authorization-tests.log`、`.build/extension-launch-authorization-clippy.log`。 - 该组件只完成准备阶段,不是可直接恢复执行的授权租约;调用方仍须在 resume 前复核在线信任、活动安装身份、许可、Vault 和锁定代次,并完成运行中撤销/停止、broker 和完整资源限制。目前未开放第三方执行,整体生产化继续进行。 + + +## 增量:运行租约、锁定代次与主动进程树撤销 + +- 执行许可更新为内存态 v2 HMAC,包含签发代次;Authority.invalidate_all 与 Authority 释放都会推进代次。Lease 绑定原许可代次及由有效期计算的单调时钟期限,不能把撤销后的当前代次重新当作旧许可的有效代次。 +- Context.prepare 在解析凭据前捕获租约与保险库锁定代次,生成不可拆换的 PreparedLaunch;创建挂起进程前再次核对租约、绑定路径、相对入口和树摘要,创建后继续核对。LeasedSuspended 恢复时先建立原生撤销监视器,进程生命周期继续保留入口与 Profile 借用。 +- 监视器每 50 ms 检查许可代次、凭据代次与到期时间;任何失效均终止所属 Job,不要求调用者继续轮询。监视线程退出也有整组终止兜底;监视器不可用时不恢复执行。Running.start_tool_call 先检查租约,阻止已撤销实例启动新工具调用。 +- 真实 AppContainer 双进程探针验证:本轮许可作废后约 56.90 ms、保险库锁定后约 47.80 ms、签发器释放后约 55.45 ms 观察到进程树清空;之后新工具调用均被拒绝。两秒测试租约自然到期也主动清空进程树。创建前撤销和创建后恢复前撤销均拒绝继续,无普通子进程降级。 +- 原生参数/环境探针已使用 prepare → create_suspended → 带租约恢复路径。52 项扩展回归通过,4 项 ignored 为三个父测试实际驱动的 Job 辅助入口及单独执行的 60 秒验收;全目标 Clippy -D warnings 通过。日志 `.build/extension-revocation-tests.log`、`.build/extension-revocation-clippy.log`。 +- 本轮锁定由真实 CredentialBroker.lock 触发,尚不是操作系统锁屏事件的桌面端到端测试。监视器提供有界撤销,并非对核验与 ResumeThread 之间竞态作“零指令执行”证明。Host 仍需把实际 Vault 切换、权限撤回、在线签名撤销等事件接入签发器/实例注册表,并完成 broker 与全部资源策略,整体生产化尚未完成。 diff --git a/frontend/src-tauri/src/extension_container.rs b/frontend/src-tauri/src/extension_container.rs index 0cc7bb2..dd389ed 100644 --- a/frontend/src-tauri/src/extension_container.rs +++ b/frontend/src-tauri/src/extension_container.rs @@ -764,17 +764,98 @@ mod tests { container_data: &folder, scratch: &folder.join("Temp"), }; - let data = context - .build(&authority, &permit, &claims, &bound_entry, &broker, 2) + let prepared = context + .prepare(&authority, &permit, &claims, &bound_entry, &broker, 2) .unwrap(); - let suspended = - crate::extension_process::Suspended::create_bound(&profile, &bound_entry, data) - .unwrap(); + let suspended = prepared.create_suspended(&profile, &bound_entry).unwrap(); let running = unsafe { suspended.resume().unwrap() }; assert_eq!( running.wait(std::time::Duration::from_secs(5)).unwrap(), Some(0) ); + drop(running); + for cause in [ + "before_create", + "before_resume", + "permit", + "credential", + "owner_drop", + "expiry", + ] { + let mut issuer = Authority::default(); + let mut waiting = claims.clone(); + waiting.arguments = vec!["wait_tree".into()]; + waiting.expires_at_ms = if cause == "expiry" { 2_002 } else { 120_000 }; + let permit = issuer.issue(&waiting, 1).unwrap(); + let prepared = context + .prepare(&issuer, &permit, &waiting, &bound_entry, &broker, 2) + .unwrap(); + if cause == "before_create" { + issuer.invalidate_all(); + assert_eq!( + prepared + .create_suspended(&profile, &bound_entry) + .err() + .unwrap() + .code, + "EXTENSION_PERMIT_REVOKED" + ); + continue; + } + let suspended = prepared.create_suspended(&profile, &bound_entry).unwrap(); + if cause == "before_resume" { + issuer.invalidate_all(); + assert_eq!( + unsafe { suspended.resume() }.err().unwrap().code, + "EXTENSION_PERMIT_REVOKED" + ); + continue; + } + let running = unsafe { suspended.resume().unwrap() }; + let started = std::time::Instant::now(); + while running.active_test_processes().unwrap() != 2 + && started.elapsed() < std::time::Duration::from_secs(5) + { + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert_eq!(running.active_test_processes().unwrap(), 2); + let revoked = std::time::Instant::now(); + match cause { + "permit" => issuer.invalidate_all(), + "credential" => broker.lock(), + "expiry" => {} + _ => drop(issuer), + } + // The monitor must act without check_authorization or tool polling. + assert!(running + .wait(std::time::Duration::from_secs(5)) + .unwrap() + .is_some()); + while running.active_test_processes().unwrap() != 0 + && revoked.elapsed() < std::time::Duration::from_secs(5) + { + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert_eq!(running.active_test_processes().unwrap(), 0); + assert!(revoked.elapsed() < std::time::Duration::from_secs(5)); + let expected = match cause { + "credential" => "CREDENTIALS_LOCKED", + "expiry" => "EXTENSION_PERMIT_EXPIRED", + _ => "EXTENSION_PERMIT_REVOKED", + }; + assert_eq!(running.check_authorization().unwrap_err().code, expected); + assert_eq!(running.start_tool_call().err().unwrap().code, expected); + eprintln!( + "instance revocation {cause}: tree empty after {:?}", + revoked.elapsed() + ); + drop(running); + if cause == "credential" { + broker + .unlock(Zeroizing::new(b"native fixture passphrase".to_vec())) + .unwrap(); + } + } } #[cfg(not(feature = "desktop"))] assert_eq!( diff --git a/frontend/src-tauri/src/extension_launch_authorization.rs b/frontend/src-tauri/src/extension_launch_authorization.rs index 4732151..28979ec 100644 --- a/frontend/src-tauri/src/extension_launch_authorization.rs +++ b/frontend/src-tauri/src/extension_launch_authorization.rs @@ -19,6 +19,46 @@ pub struct Context<'a> { pub container_data: &'a Path, pub scratch: &'a Path, } +pub struct PreparedLaunch { + data: LaunchData, + lease: crate::extension_permit::Lease, + path: std::path::PathBuf, + entry: String, + tree: String, +} +pub struct LeasedSuspended<'a> { + process: crate::extension_process::Suspended<'a>, + lease: crate::extension_permit::Lease, +} +impl PreparedLaunch { + pub fn create_suspended<'a>( + self, + profile: &'a crate::extension_container::Profile, + entry: &'a BoundEntry<'a>, + ) -> Result> { + self.lease.check()?; + if self.path != entry.path() + || self.entry != entry.relative_name() + || self.tree != entry.tree_sha256() + { + return Err(HostError::new("EXTENSION_ENTRY_PERMIT_MISMATCH")); + } + let process = crate::extension_process::Suspended::create_bound(profile, entry, self.data)?; + self.lease.check()?; + Ok(LeasedSuspended { + process, + lease: self.lease, + }) + } +} +impl<'a> LeasedSuspended<'a> { + /// # Safety + /// Live trust, active installation, broker and all sandbox resource policy + /// requirements must also hold. A lease does not establish those conditions. + pub unsafe fn resume(self) -> Result> { + unsafe { self.process.resume_with_lease(self.lease) } + } +} struct EnvironmentValues(BTreeMap); impl Drop for EnvironmentValues { fn drop(&mut self) { @@ -66,6 +106,35 @@ pub fn credential_id(claims: &Claims, reference: &str) -> Result { }) } impl Context<'_> { + /// Capture epochs before resolving credentials; never adopt a newer lock + /// generation for launch bytes prepared under an earlier session. + pub fn prepare( + &self, + authority: &Authority, + permit: &Permit, + claims: &Claims, + entry: &BoundEntry<'_>, + broker: &CredentialBroker, + now_ms: u64, + ) -> Result { + let mut lease = authority.lease(permit, claims, now_ms)?; + if claims + .environment + .values() + .any(|v| matches!(v, Environment::CredentialScope(_))) + { + lease.bind_credential(broker.lock_signal()); + } + let data = self.build(authority, permit, claims, entry, broker, now_ms)?; + lease.check()?; + Ok(PreparedLaunch { + data, + lease, + path: entry.path().to_owned(), + entry: entry.relative_name().to_owned(), + tree: entry.tree_sha256().to_owned(), + }) + } /// Caller must still recheck live trust/permit/session state immediately /// before resume; returning encoded data is not an execution lease. pub fn build( diff --git a/frontend/src-tauri/src/extension_permit.rs b/frontend/src-tauri/src/extension_permit.rs index 24e97a9..a1fe2a0 100644 --- a/frontend/src-tauri/src/extension_permit.rs +++ b/frontend/src-tauri/src/extension_permit.rs @@ -6,6 +6,13 @@ use rand::RngCore; use serde::{Deserialize, Serialize}; use sha2::Sha256; use std::collections::{BTreeMap, BTreeSet}; +use std::{ + sync::{ + atomic::{AtomicU64, Ordering}, + Arc, + }, + time::{Duration, Instant}, +}; use zeroize::Zeroize; #[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -46,12 +53,46 @@ pub struct Claims { /// Opaque authenticator; the Host retains claims separately. No paths, arguments /// or credential declarations need to be passed to a renderer with the token. -pub struct Permit([u8; 32]); +pub struct Permit { + mac: [u8; 32], + generation: u64, +} +#[derive(Clone)] +pub struct Lease { + generation: (Arc, u64), + credential: Option<(Arc, u64)>, + expires: Instant, +} +impl Lease { + pub fn check(&self) -> Result<()> { + if self.generation.0.load(Ordering::SeqCst) != self.generation.1 { + return Err(HostError::new("EXTENSION_PERMIT_REVOKED")); + } + if self + .credential + .as_ref() + .is_some_and(|(signal, epoch)| signal.load(Ordering::SeqCst) != *epoch) + { + return Err(HostError::new("CREDENTIALS_LOCKED")); + } + if Instant::now() >= self.expires { + return Err(HostError::new("EXTENSION_PERMIT_EXPIRED")); + } + Ok(()) + } + #[cfg(windows)] + pub(crate) fn bind_credential(&mut self, signal: Arc) { + let epoch = signal.load(Ordering::SeqCst); + self.credential = Some((signal, epoch)); + } +} pub struct Authority { key: [u8; 32], + generation: Arc, } impl Drop for Authority { fn drop(&mut self) { + self.generation.fetch_add(1, Ordering::SeqCst); self.key.zeroize(); } } @@ -59,7 +100,10 @@ impl Default for Authority { fn default() -> Self { let mut key = [0; 32]; rand::rngs::OsRng.fill_bytes(&mut key); - Self { key } + Self { + key, + generation: Arc::new(AtomicU64::new(0)), + } } } impl Claims { @@ -157,23 +201,52 @@ impl Authority { /// Call only after consent and live trust validation. This authenticates the /// decision; it does not establish sandbox availability or grant broker access. pub fn issue(&self, claims: &Claims, now_ms: u64) -> Result { + let generation = self.generation.load(Ordering::SeqCst); let encoded = claims.encoded(now_ms)?; let mut mac = Hmac::::new_from_slice(&self.key).expect("HMAC key"); - mac.update(b"OpenNexus execution permit v1\0"); + mac.update(b"OpenNexus execution permit v2\0"); + mac.update(&generation.to_be_bytes()); mac.update(&encoded); - Ok(Permit(mac.finalize().into_bytes().into())) + Ok(Permit { + mac: mac.finalize().into_bytes().into(), + generation, + }) } pub fn verify(&self, permit: &Permit, actual: &Claims, now_ms: u64) -> Result<()> { + let generation = self.generation.load(Ordering::SeqCst); + if generation != permit.generation { + return Err(HostError::new("EXTENSION_PERMIT_REVOKED")); + } let encoded = actual.encoded(now_ms)?; let mut mac = Hmac::::new_from_slice(&self.key).expect("HMAC key"); - mac.update(b"OpenNexus execution permit v1\0"); + mac.update(b"OpenNexus execution permit v2\0"); + mac.update(&generation.to_be_bytes()); mac.update(&encoded); - mac.verify_slice(&permit.0) - .map_err(|_| HostError::new("EXTENSION_PERMIT_MISMATCH")) + mac.verify_slice(&permit.mac) + .map_err(|_| HostError::new("EXTENSION_PERMIT_MISMATCH"))?; + if generation != self.generation.load(Ordering::SeqCst) { + return Err(HostError::new("EXTENSION_PERMIT_REVOKED")); + } + Ok(()) + } + pub fn lease(&self, permit: &Permit, claims: &Claims, now_ms: u64) -> Result { + let started = Instant::now(); + self.verify(permit, claims, now_ms)?; + let expires = started + .checked_add(Duration::from_millis(claims.expires_at_ms - now_ms)) + .ok_or_else(|| HostError::new("EXTENSION_PERMIT_INVALID"))?; + let lease = Lease { + generation: (Arc::clone(&self.generation), permit.generation), + credential: None, + expires, + }; + lease.check()?; + Ok(lease) } /// Lock/logout/policy invalidation may discard all permits. Restart creates a /// fresh key, so an old process token cannot silently revive authorization. pub fn invalidate_all(&mut self) { + self.generation.fetch_add(1, Ordering::SeqCst); self.key.zeroize(); rand::rngs::OsRng.fill_bytes(&mut self.key); } diff --git a/frontend/src-tauri/src/extension_process.rs b/frontend/src-tauri/src/extension_process.rs index f4f1b0a..6d1349e 100644 --- a/frontend/src-tauri/src/extension_process.rs +++ b/frontend/src-tauri/src/extension_process.rs @@ -85,7 +85,11 @@ impl Drop for Process<'_> { } } pub struct Suspended<'a>(Process<'a>); -pub struct Running<'a>(Process<'a>); +pub struct Running<'a> { + process: Process<'a>, + #[cfg(feature = "desktop")] + revocation: Option, +} 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 @@ -187,25 +191,57 @@ impl<'a> Suspended<'a> { if unsafe { ResumeThread(self.0.handles.thread.as_raw_handle()) } != 1 { return Err(HostError::new("EXTENSION_PROCESS_RESUME_FAILED")); } - Ok(Running(self.0)) + Ok(Running { + process: self.0, + #[cfg(feature = "desktop")] + revocation: None, + }) + } + /// # Safety + /// The same complete resource/broker/trust preconditions as resume apply. + /// This additionally arms revocation monitoring before any instruction resumes. + #[cfg(feature = "desktop")] + pub unsafe fn resume_with_lease( + self, + lease: crate::extension_permit::Lease, + ) -> Result> { + let watch = crate::extension_revocation::Watch::arm(&self.0.job, lease)?; + watch.check()?; + if unsafe { ResumeThread(self.0.handles.thread.as_raw_handle()) } != 1 { + return Err(HostError::new("EXTENSION_PROCESS_RESUME_FAILED")); + } + let running = Running { + process: self.0, + revocation: Some(watch), + }; + running.check_authorization()?; + Ok(running) } } impl Running<'_> { + pub fn check_authorization(&self) -> Result<()> { + #[cfg(feature = "desktop")] + if let Some(watch) = &self.revocation { + return watch.check(); + } + Ok(()) + } #[cfg(test)] pub(crate) fn active_test_processes(&self) -> Result { - self.0.job.active_processes() + self.process.job.active_processes() } /// Arm before dispatching a tool request; finish after receiving its result. /// Failure to arm must prevent dispatch. This does not time server lifetime. pub fn start_tool_call(&self) -> Result { - crate::extension_deadline::ToolDeadline::arm(&self.0.job) + self.check_authorization()?; + crate::extension_deadline::ToolDeadline::arm(&self.process.job) } #[cfg(test)] pub(crate) fn start_test_tool_call( &self, budget: Duration, ) -> Result { - crate::extension_deadline::ToolDeadline::arm_test(&self.0.job, budget) + crate::extension_deadline::ToolDeadline::arm_test(&self.process.job, budget) } /// A bounded observation only. The runtime must enforce the tool deadline. pub fn wait(&self, timeout: Duration) -> Result> { @@ -213,12 +249,15 @@ impl Running<'_> { .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) } { + match unsafe { + WaitForSingleObject(self.process.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 + if unsafe { + GetExitCodeProcess(self.process.handles.process.as_raw_handle(), &mut code) + } == 0 { return Err(HostError::new("EXTENSION_PROCESS_QUERY_FAILED")); } @@ -229,7 +268,7 @@ impl Running<'_> { } /// Terminates the entire managed group, including descendants. pub fn terminate(&self) -> Result<()> { - self.0.job.terminate() + self.process.job.terminate() } } fn verify_identity(handles: &Handles, profile: &Profile) -> Result<()> { diff --git a/frontend/src-tauri/src/extension_revocation.rs b/frontend/src-tauri/src/extension_revocation.rs new file mode 100644 index 0000000..243e818 --- /dev/null +++ b/frontend/src-tauri/src/extension_revocation.rs @@ -0,0 +1,85 @@ +//! Native instance monitor; revocation does not depend on the caller polling. +use crate::{ + extension_job::Job, + extension_permit::Lease, + workspace::{HostError, Result}, +}; +use std::{ + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Condvar, Mutex, + }, + thread::JoinHandle, + time::Duration, +}; +struct KillOnExit(Job); +impl Drop for KillOnExit { + fn drop(&mut self) { + let _ = self.0.terminate(); + } +} +pub(crate) struct Watch { + lease: Lease, + cancelled: Arc<(Mutex, Condvar)>, + failed: Arc, + worker: Option>, +} +impl Watch { + pub(crate) fn arm(job: &Job, lease: Lease) -> Result { + lease.check()?; + let job = KillOnExit(job.clone_for_deadline()?); + let cancelled = Arc::new((Mutex::new(false), Condvar::new())); + let failed = Arc::new(AtomicBool::new(false)); + let thread_cancel = Arc::clone(&cancelled); + let thread_failed = Arc::clone(&failed); + let thread_lease = lease.clone(); + let worker = std::thread::Builder::new() + .name("extension-revocation".into()) + .spawn(move || { + let mut stop = thread_cancel.0.lock().unwrap_or_else(|e| e.into_inner()); + while !*stop { + if thread_lease.check().is_err() { + if job.0.terminate().is_err() { + thread_failed.store(true, Ordering::Release); + } + return; + } + stop = thread_cancel + .1 + .wait_timeout(stop, Duration::from_millis(50)) + .unwrap_or_else(|e| e.into_inner()) + .0; + } + }) + .map_err(|_| HostError::new("EXTENSION_REVOCATION_WATCH_UNAVAILABLE"))?; + Ok(Self { + lease, + cancelled, + failed, + worker: Some(worker), + }) + } + pub(crate) fn check(&self) -> Result<()> { + if self.failed.load(Ordering::Acquire) { + return Err(HostError::new("EXTENSION_RESOURCE_TERMINATE_FAILED")); + } + self.lease.check()?; + if self + .worker + .as_ref() + .is_some_and(|worker| worker.is_finished()) + { + return Err(HostError::new("EXTENSION_REVOCATION_WATCH_FAILED")); + } + Ok(()) + } +} +impl Drop for Watch { + fn drop(&mut self) { + *self.cancelled.0.lock().unwrap_or_else(|e| e.into_inner()) = true; + self.cancelled.1.notify_all(); + if let Some(worker) = self.worker.take() { + let _ = worker.join(); + } + } +} diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index 7664970..f9a61cb 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -72,3 +72,6 @@ pub mod extension_pinned; #[cfg(all(windows, feature = "desktop"))] pub mod extension_launch_authorization; + +#[cfg(all(windows, feature = "desktop"))] +mod extension_revocation;