feat(sandbox): 通过执行租约撤销活动实例

This commit is contained in:
2026-09-08 23:46:03 +08:00
parent 3e391f0312
commit 73e50909ff
7 changed files with 381 additions and 21 deletions
@@ -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 与全部资源策略,整体生产化尚未完成。
+86 -5
View File
@@ -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!(
@@ -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<LeasedSuspended<'a>> {
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<crate::extension_process::Running<'a>> {
unsafe { self.process.resume_with_lease(self.lease) }
}
}
struct EnvironmentValues(BTreeMap<String, String>);
impl Drop for EnvironmentValues {
fn drop(&mut self) {
@@ -66,6 +106,35 @@ pub fn credential_id(claims: &Claims, reference: &str) -> Result<CredentialId> {
})
}
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<PreparedLaunch> {
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(
+80 -7
View File
@@ -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<AtomicU64>, u64),
credential: Option<(Arc<AtomicU64>, 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<AtomicU64>) {
let epoch = signal.load(Ordering::SeqCst);
self.credential = Some((signal, epoch));
}
}
pub struct Authority {
key: [u8; 32],
generation: Arc<AtomicU64>,
}
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<Permit> {
let generation = self.generation.load(Ordering::SeqCst);
let encoded = claims.encoded(now_ms)?;
let mut mac = Hmac::<Sha256>::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::<Sha256>::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<Lease> {
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);
}
+48 -9
View File
@@ -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<crate::extension_revocation::Watch>,
}
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<Running<'a>> {
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<u32> {
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> {
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> {
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<Option<u32>> {
@@ -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<()> {
@@ -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<bool>, Condvar)>,
failed: Arc<AtomicBool>,
worker: Option<JoinHandle<()>>,
}
impl Watch {
pub(crate) fn arm(job: &Job, lease: Lease) -> Result<Self> {
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();
}
}
}
+3
View File
@@ -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;