From 3e391f03122129d844c7558b773140f9d08812c8 Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Tue, 8 Sep 2026 23:34:13 +0800 Subject: [PATCH] =?UTF-8?q?feat(sandbox):=20=E4=BB=8E=E8=AE=B8=E5=8F=AF?= =?UTF-8?q?=E6=B4=BE=E7=94=9F=E5=90=AF=E5=8A=A8=E6=95=B0=E6=8D=AE=E4=B8=8E?= =?UTF-8?q?=E9=99=90=E5=AE=9A=E8=8C=83=E5=9B=B4=E7=9A=84=E5=AF=86=E9=92=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../OpenNexus生产化实施进度-2026-09-08.md | 11 + frontend/src-tauri/src/extension_container.rs | 52 +++ .../src/extension_launch_authorization.rs | 301 ++++++++++++++++++ frontend/src-tauri/src/extension_permit.rs | 12 +- frontend/src-tauri/src/extension_pinned.rs | 8 + frontend/src-tauri/src/lib.rs | 3 + 6 files changed, 386 insertions(+), 1 deletion(-) create mode 100644 frontend/src-tauri/src/extension_launch_authorization.rs diff --git a/docs/development/OpenNexus生产化实施进度-2026-09-08.md b/docs/development/OpenNexus生产化实施进度-2026-09-08.md index 68dcd4e..b35d159 100644 --- a/docs/development/OpenNexus生产化实施进度-2026-09-08.md +++ b/docs/development/OpenNexus生产化实施进度-2026-09-08.md @@ -408,3 +408,14 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写 - 实际 Windows 测试确认绑定路径为卷 GUID 路径、内容对应已持有文件、缺失/越界入口拒绝、父目录重命名失败;全部所有者释放后重命名成功。真实 AppContainer 原生参数/环境探针通过 create_bound 启动成功,网络/取消/短期限探针也使用持续持有的绑定路径运行。 - 51 项扩展回归通过,4 项 ignored 为三个由父测试实际驱动的 Job 辅助入口及需显式执行的 60 秒验收,本轮未重跑长时项。Clippy 首次指出冗余 Ok/?,修正后全目标 -D warnings 通过。日志 `.build/extension-entry-binding-tests.log`、`.build/extension-entry-binding-clippy.log`。API 依据 [Microsoft GetFinalPathNameByHandleW](https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-getfinalpathnamebyhandlew)。 - 这补上了普通祖先路径替换的绑定原语与实测,仍未完成并发新增未知文件、已有可写映射、完整攻击矩阵、许可原子核验或所有目标系统验证,不能据此认定 C-02 或整体生产化完成。 + + +## 增量:从执行许可构造参数、环境及包域凭据 + +- Claims 增加 Plugin/MCP 执行类型并纳入 HMAC 摘要,防止类型切换后沿用原许可。现有逐字段篡改测试覆盖该字段;许可仍为 Host 内存态能力,未增加旧许可兼容或 renderer 签发入口。 +- 新增 extension_launch_authorization::Context::build,先核验许可有效期/HMAC、Host 当前 Vault/策略/平台,以及 BoundEntry 相对入口与展开树摘要,再从已签发声明构造参数和环境,不接受独立的替换参数或环境值。 +- CredentialScope 的值解释为包域内不透明凭据引用。凭据归属由 HTTPS 来源、命名空间、包 ID 的带长度摘要派生,再按已绑定执行类型进入 Plugin 或 MCP 域;不接受调用者另报凭据域,也不回退 Provider。版本未加入凭据归属摘要,保留同包升级后的引用稳定性;新执行仍须独立完成信任和权限确认。 +- 环境秘密通过现有 Stronghold broker 解密,拒绝缺失、锁定、无效引用和非 UTF-8 值;解析前后核对锁定代次。临时环境值由清理所有者清零,原始解密字节及最终 UTF-16 启动缓冲继续使用各自清理机制;错误只返回稳定代码,不包含值。 +- 实际 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 和完整资源限制。目前未开放第三方执行,整体生产化继续进行。 diff --git a/frontend/src-tauri/src/extension_container.rs b/frontend/src-tauri/src/extension_container.rs index 4ae4fc3..0cc7bb2 100644 --- a/frontend/src-tauri/src/extension_container.rs +++ b/frontend/src-tauri/src/extension_container.rs @@ -702,6 +702,7 @@ mod tests { .collect::>(); let folder = profile.folder().unwrap(); let system = std::path::PathBuf::from(std::env::var_os("SystemRoot").unwrap()); + #[cfg(not(feature = "desktop"))] let data = crate::extension_launch_data::LaunchData::new( &executable, &args, @@ -715,6 +716,57 @@ mod tests { .unwrap(); #[cfg(feature = "desktop")] { + use crate::extension_launch_authorization::{credential_id, Context}; + use crate::extension_permit::{Authority, Claims, Environment, ExecutionKind}; + use zeroize::Zeroizing; + let credential_directory = tempfile::tempdir().unwrap(); + let mut broker = crate::credentials::CredentialBroker::new( + credential_directory.path().join("credentials.v1"), + ); + broker + .unlock(Zeroizing::new(b"native fixture passphrase".to_vec())) + .unwrap(); + let claims = Claims { + kind: ExecutionKind::Mcp, + source: "https://catalog.example/".into(), + namespace: "examples".into(), + package_id: "native-probe".into(), + version: "1.0.0".into(), + archive_sha256: "a".repeat(64), + tree_sha256: bound_entry.tree_sha256().into(), + signer_sha256: "b".repeat(64), + entry: bound_entry.relative_name().into(), + arguments: args, + environment: [( + "CUSTOM".into(), + Environment::CredentialScope("custom".into()), + )] + .into_iter() + .collect(), + permissions: Default::default(), + vault_id: uuid::Uuid::new_v4().to_string(), + platform: "windows".into(), + policy_version: "1".into(), + expires_at_ms: 1000, + }; + broker + .put( + &credential_id(&claims, "custom").unwrap(), + Zeroizing::new("declared=值".as_bytes().to_vec()), + ) + .unwrap(); + let authority = Authority::default(); + let permit = authority.issue(&claims, 1).unwrap(); + let context = Context { + vault_id: &claims.vault_id, + policy_version: "1", + system_root: &system, + container_data: &folder, + scratch: &folder.join("Temp"), + }; + let data = context + .build(&authority, &permit, &claims, &bound_entry, &broker, 2) + .unwrap(); let suspended = crate::extension_process::Suspended::create_bound(&profile, &bound_entry, data) .unwrap(); diff --git a/frontend/src-tauri/src/extension_launch_authorization.rs b/frontend/src-tauri/src/extension_launch_authorization.rs new file mode 100644 index 0000000..4732151 --- /dev/null +++ b/frontend/src-tauri/src/extension_launch_authorization.rs @@ -0,0 +1,301 @@ +//! Derive launch bytes from a verified permit and Host-bound entry/context. +//! This preparation step does not authorize resume or establish sandbox readiness. +use crate::{ + credentials::{CredentialBroker, CredentialId, Scope}, + extension_launch_data::LaunchData, + extension_permit::{Authority, Claims, Environment, ExecutionKind, Permit}, + extension_pinned::BoundEntry, + workspace::{HostError, Result}, +}; +use sha2::{Digest, Sha256}; +use std::{collections::BTreeMap, path::Path, sync::atomic::Ordering}; +use zeroize::Zeroize; + +/// Only the Host's selected workspace, policy and container supply these values. +pub struct Context<'a> { + pub vault_id: &'a str, + pub policy_version: &'a str, + pub system_root: &'a Path, + pub container_data: &'a Path, + pub scratch: &'a Path, +} +struct EnvironmentValues(BTreeMap); +impl Drop for EnvironmentValues { + fn drop(&mut self) { + for value in self.0.values_mut() { + value.zeroize(); + } + } +} +/// Credential setup and execution must use the same derived identity. The +/// reference is an opaque ID inside this package's domain, never a caller scope. +pub fn credential_id(claims: &Claims, reference: &str) -> Result { + if reference.is_empty() + || reference.len() > 128 + || !reference + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b)) + { + return Err(HostError::new("CREDENTIAL_ID_INVALID")); + } + let source = reqwest::Url::parse(&claims.source) + .map_err(|_| HostError::new("EXTENSION_PERMIT_INVALID"))?; + if source.scheme() != "https" + || source.host_str().is_none() + || !source.username().is_empty() + || source.password().is_some() + || source.query().is_some() + || source.fragment().is_some() + { + return Err(HostError::new("EXTENSION_PERMIT_INVALID")); + } + let mut digest = Sha256::new(); + digest.update(b"OpenNexus extension credential owner v1\0"); + for value in [source.as_str(), &claims.namespace, &claims.package_id] { + digest.update((value.len() as u64).to_be_bytes()); + digest.update(value.as_bytes()); + } + let owner = format!("ext.{:x}", digest.finalize()); + let scope = match claims.kind { + ExecutionKind::Plugin => Scope::Plugin(owner), + ExecutionKind::Mcp => Scope::Mcp(owner), + }; + Ok(CredentialId { + scope, + id: reference.to_owned(), + }) +} +impl Context<'_> { + /// Caller must still recheck live trust/permit/session state immediately + /// before resume; returning encoded data is not an execution lease. + pub fn build( + &self, + authority: &Authority, + permit: &Permit, + claims: &Claims, + entry: &BoundEntry<'_>, + broker: &CredentialBroker, + now_ms: u64, + ) -> Result { + authority.verify(permit, claims, now_ms)?; + if claims.vault_id != self.vault_id + || claims.policy_version != self.policy_version + || claims.platform != std::env::consts::OS + { + return Err(HostError::new("EXTENSION_EXECUTION_CONTEXT_CHANGED")); + } + if claims.entry != entry.relative_name() || claims.tree_sha256 != entry.tree_sha256() { + return Err(HostError::new("EXTENSION_ENTRY_PERMIT_MISMATCH")); + } + let signal = broker.lock_signal(); + let epoch = signal.load(Ordering::SeqCst); + let mut values = EnvironmentValues(BTreeMap::new()); + let mut used_secret = false; + for (name, declaration) in &claims.environment { + match declaration { + Environment::Literal(value) => { + values.0.insert(name.clone(), value.clone()); + } + Environment::CredentialScope(reference) => { + used_secret = true; + let id = credential_id(claims, reference)?; + let value = broker + .resolve(&id.scope, &id) + .map_err(|code| { + HostError::new(match code.as_str() { + "CREDENTIALS_LOCKED" => "CREDENTIALS_LOCKED", + "CREDENTIAL_SCOPE_DENIED" => "CREDENTIAL_SCOPE_DENIED", + "CREDENTIAL_ID_INVALID" => "CREDENTIAL_ID_INVALID", + _ => "EXTENSION_CREDENTIAL_UNAVAILABLE", + }) + })? + .ok_or_else(|| HostError::new("EXTENSION_CREDENTIAL_MISSING"))?; + let value = std::str::from_utf8(&value) + .map_err(|_| HostError::new("EXTENSION_CREDENTIAL_ENCODING_INVALID"))?; + // Insert directly into the cleaning owner, never an error or log. + values.0.insert(name.clone(), value.to_owned()); + } + } + } + let data = LaunchData::new( + entry.path(), + &claims.arguments, + self.system_root, + self.container_data, + self.scratch, + &values.0, + )?; + if used_secret && (broker.is_locked() || signal.load(Ordering::SeqCst) != epoch) { + return Err(HostError::new("CREDENTIALS_LOCKED")); + } + Ok(data) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{extension_package::Inventory, extension_pinned::PinnedPackage}; + use zeroize::Zeroizing; + #[test] + fn permit_entry_context_and_package_scoped_secrets_drive_launch_data() { + let temp = tempfile::tempdir().unwrap(); + let package = temp.path().join("package"); + std::fs::create_dir(&package).unwrap(); + std::fs::write(package.join("entry.exe"), b"verified").unwrap(); + let dir = + cap_std::fs::Dir::open_ambient_dir(&package, cap_std::ambient_authority()).unwrap(); + let inventory = Inventory { + files: [( + "entry.exe".into(), + format!("{:x}", Sha256::digest(b"verified")), + )] + .into_iter() + .collect(), + expanded_size: 8, + manifest: "entry.exe".into(), + }; + let tree = crate::extension_unpack::verify_tree(&dir, &inventory).unwrap(); + let pinned = PinnedPackage::open(&dir, &inventory, &tree).unwrap(); + let entry = pinned.bind_entry("entry.exe").unwrap(); + let claims = Claims { + kind: ExecutionKind::Mcp, + source: "https://catalog.example/".into(), + namespace: "examples".into(), + package_id: "reviewer".into(), + version: "1.0.0".into(), + archive_sha256: "a".repeat(64), + tree_sha256: tree, + signer_sha256: "b".repeat(64), + entry: "entry.exe".into(), + arguments: vec!["--stdio".into()], + environment: [("TOKEN".into(), Environment::CredentialScope("token".into()))] + .into_iter() + .collect(), + permissions: ["notes.read".into()].into_iter().collect(), + vault_id: uuid::Uuid::new_v4().to_string(), + platform: "windows".into(), + policy_version: "1".into(), + expires_at_ms: 1000, + }; + let system = std::path::PathBuf::from(std::env::var_os("SystemRoot").unwrap()); + let context = Context { + vault_id: &claims.vault_id, + policy_version: "1", + system_root: &system, + container_data: temp.path(), + scratch: temp.path(), + }; + let authority = Authority::default(); + let permit = authority.issue(&claims, 1).unwrap(); + let mut broker = CredentialBroker::new(temp.path().join("credentials.v1")); + broker + .unlock(Zeroizing::new(b"fixture passphrase 123".to_vec())) + .unwrap(); + broker + .put( + &CredentialId { + scope: Scope::Provider, + id: "token".into(), + }, + Zeroizing::new(b"provider-fixture-secret".to_vec()), + ) + .unwrap(); + assert_eq!( + context + .build(&authority, &permit, &claims, &entry, &broker, 2) + .err() + .unwrap() + .code, + "EXTENSION_CREDENTIAL_MISSING" + ); + let id = credential_id(&claims, "token").unwrap(); + broker + .put(&id, Zeroizing::new(b"mcp-fixture-secret".to_vec())) + .unwrap(); + let data = context + .build(&authority, &permit, &claims, &entry, &broker, 2) + .unwrap(); + let environment = Zeroizing::new(String::from_utf16(data.environment()).unwrap()); + assert!(environment.contains("TOKEN=mcp-fixture-secret\0")); + assert!(!environment.contains("provider-fixture-secret")); + drop(data); + let mut changed = claims.clone(); + changed.arguments.push("different".into()); + assert_eq!( + context + .build(&authority, &permit, &changed, &entry, &broker, 2) + .err() + .unwrap() + .code, + "EXTENSION_PERMIT_MISMATCH" + ); + for field in ["kind", "source", "namespace", "package_id"] { + let mut changed = claims.clone(); + match field { + "kind" => changed.kind = ExecutionKind::Plugin, + "source" => changed.source = "https://other.example/".into(), + "namespace" => changed.namespace = "others".into(), + _ => changed.package_id = "another".into(), + } + let fresh = authority.issue(&changed, 1).unwrap(); + assert_eq!( + context + .build(&authority, &fresh, &changed, &entry, &broker, 2) + .err() + .unwrap() + .code, + "EXTENSION_CREDENTIAL_MISSING" + ); + } + for field in ["entry", "tree", "vault", "policy"] { + let mut changed = claims.clone(); + match field { + "entry" => changed.entry = "another.exe".into(), + "tree" => changed.tree_sha256 = "c".repeat(64), + "vault" => changed.vault_id = uuid::Uuid::new_v4().to_string(), + _ => changed.policy_version = "2".into(), + } + let fresh = authority.issue(&changed, 1).unwrap(); + let code = context + .build(&authority, &fresh, &changed, &entry, &broker, 2) + .err() + .unwrap() + .code; + assert_eq!( + code, + if field == "entry" || field == "tree" { + "EXTENSION_ENTRY_PERMIT_MISMATCH" + } else { + "EXTENSION_EXECUTION_CONTEXT_CHANGED" + } + ); + } + broker.put(&id, Zeroizing::new(vec![0xff])).unwrap(); + assert_eq!( + context + .build(&authority, &permit, &claims, &entry, &broker, 2) + .err() + .unwrap() + .code, + "EXTENSION_CREDENTIAL_ENCODING_INVALID" + ); + broker.lock(); + assert_eq!( + context + .build(&authority, &permit, &claims, &entry, &broker, 2) + .err() + .unwrap() + .code, + "CREDENTIALS_LOCKED" + ); + assert_eq!( + context + .build(&authority, &permit, &claims, &entry, &broker, 1000) + .err() + .unwrap() + .code, + "EXTENSION_PERMIT_EXPIRED" + ); + } +} diff --git a/frontend/src-tauri/src/extension_permit.rs b/frontend/src-tauri/src/extension_permit.rs index 3f72308..24e97a9 100644 --- a/frontend/src-tauri/src/extension_permit.rs +++ b/frontend/src-tauri/src/extension_permit.rs @@ -12,13 +12,21 @@ use zeroize::Zeroize; #[serde(tag = "kind", content = "value", deny_unknown_fields)] pub enum Environment { Literal(String), - // A scoped credential declaration, never resolved secret plaintext. + // An opaque credential reference in the Host-derived package scope, never plaintext. CredentialScope(String), } +#[derive(Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ExecutionKind { + Plugin, + Mcp, +} + #[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct Claims { + pub kind: ExecutionKind, pub source: String, pub namespace: String, pub package_id: String, @@ -176,6 +184,7 @@ mod tests { use super::*; fn claims() -> Claims { Claims { + kind: ExecutionKind::Mcp, source: "https://catalog.example/".into(), namespace: "examples".into(), package_id: "note-reviewer".into(), @@ -205,6 +214,7 @@ mod tests { match &mut changed[field] { serde_json::Value::String(s) => { *s = match field.as_str() { + "kind" => "plugin".into(), "source" => "https://other.example/".into(), "version" => "1.0.1".into(), "archive_sha256" | "tree_sha256" | "signer_sha256" => "d".repeat(64), diff --git a/frontend/src-tauri/src/extension_pinned.rs b/frontend/src-tauri/src/extension_pinned.rs index 8da65af..c8cee63 100644 --- a/frontend/src-tauri/src/extension_pinned.rs +++ b/frontend/src-tauri/src/extension_pinned.rs @@ -18,11 +18,18 @@ pub struct PinnedPackage { /// The package borrow and all ancestor handles must outlive the process using /// this path. Only files present in the verified package can produce this guard. pub struct BoundEntry<'a> { + name: String, path: std::path::PathBuf, _ancestors: Vec, _package: &'a PinnedPackage, } impl BoundEntry<'_> { + pub fn relative_name(&self) -> &str { + &self.name + } + pub fn tree_sha256(&self) -> &str { + self._package.tree_sha256() + } pub fn path(&self) -> &std::path::Path { &self.path } @@ -228,6 +235,7 @@ impl PinnedPackage { return Err(bad()); } Ok(BoundEntry { + name: name.to_owned(), path: current, _ancestors: handles, _package: self, diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index e1c8046..7664970 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -69,3 +69,6 @@ pub mod extension_deadline; #[cfg(all(windows, feature = "desktop"))] pub mod extension_pinned; + +#[cfg(all(windows, feature = "desktop"))] +pub mod extension_launch_authorization;