diff --git a/docs/development/OpenNexus生产化实施进度-2026-09-08.md b/docs/development/OpenNexus生产化实施进度-2026-09-08.md index 9f30775..06b9ab9 100644 --- a/docs/development/OpenNexus生产化实施进度-2026-09-08.md +++ b/docs/development/OpenNexus生产化实施进度-2026-09-08.md @@ -970,3 +970,10 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写 - C-04 driver 固定执行四个精确 Rust Host oracle。真实后台 MCP 分别触发 CPU、512 MiB 内存、16 进程和 256 MiB scratch 上限,全部返回对应错误、清空进程树并能启动替代实例;scratch 超限本轮约 92 ms 可见。真实 60 秒工具期限在 60.015 秒左右回收两级进程树,期限前后 Host 均成功保存并重开本地笔记。文件 broker 第 33 个同秒请求返回 `EXTENSION_BROKER_RATE_LIMITED`。 - 提交 `d68a6a3` 后由统一 runner 正式复跑,summary/case 均为 `PASSED`,四组断言全部通过;证据目录为 `H:\OpenNexus-acceptance\c04-d68a6a3-b3ce7ce456834b89b8b508611854e7c9-report`,报告标记 `contains_credentials=false`,并列出 scratch ACL、路径虚拟化、Job、进程、实例、broker 与真实恶意夹具的全部实现文件摘要。严格 Clippy `-D warnings` 通过;desktop Rust 全量回归 146 通过、0 失败、14 ignored,总耗时 744 秒;runner 7 项、Server Sync 30 项、前端类型检查和 542 项前端测试均通过。前端测试出现一次对未启动 `localhost:3000` 的预期连接拒绝诊断,但测试进程仍以 104 个文件、542 项全部通过结束。 - 测试依赖和断链目录在验证后从 G/H 盘清理,G 盘可用空间约 49.95 GiB。C-04 可记为通过,正式验收累计 19/30;A-01/A-04、C-01/C-02、D-02/D-04 与 E-01~E-05 尚未通过,不能据此声明完整第三阶段生产验收完成。 + +## 增量:扩展授权 HTTPS Host broker + +- 新增 `opennexus/network.fetch` MCP 反向请求,由 Host 代替零网络能力的 AppContainer 发起 HTTPS GET/POST。允许范围来自签名执行声明中的 `network.https:`,仅接受精确 scheme、主机和端口匹配;执行租约同时绑定许可撤销、到期和凭据锁定代际。 +- 每次调用重新解析 DNS 并固定本次连接地址;任一解析结果属于 loopback、私网、链路本地、CGNAT、元数据、文档、基准、保留、组播或非全局 IPv6 范围即拒绝。客户端禁用代理和重定向,限制连接/总时限、每分钟 60 次、请求 1 MiB、响应 4 MiB,只返回状态、受限 Content-Type 和 UTF-8 正文。 +- 原生 AppContainer MCP 实例已实际发起对已授权 `https://127.0.0.1/` 的 broker 请求,Host 在建连前返回 `EXTENSION_NETWORK_ADDRESS_DENIED`,随后同一 MCP 会话继续完成工具调用。网络策略单元测试和严格 Clippy 通过。 +- C-02 仍保持未通过:还需受控公网 TLS 服务的成功矩阵,以及 shell、环境、子进程、链接、DNS 重绑定和重定向各 100 轮生产驱动与正式 runner 证据。 diff --git a/docs/development/OpenNexus第三阶段生产验收清单-2026-09-10.md b/docs/development/OpenNexus第三阶段生产验收清单-2026-09-10.md index 4b21b6e..3eed597 100644 --- a/docs/development/OpenNexus第三阶段生产验收清单-2026-09-10.md +++ b/docs/development/OpenNexus第三阶段生产验收清单-2026-09-10.md @@ -17,7 +17,7 @@ | B-03 | 通过 | Windows 会话锁定、手工锁定、改密和损坏库恢复通过。 | | B-04 | 通过 | 5 个迁移边界共 100 次真实强杀及 6 个清理边界通过。 | | C-01 | 阻塞 | AppContainer 文件与 loopback 拒绝已有真实测试;尚缺各恶意类别 100 次以及需要管理员抓包的公网、私网、元数据和原始 socket 零包证据。当前会话不是管理员。 | -| C-02 | 未通过 | 文件 broker 与工具调用已有实现;尚缺授权 HTTPS broker,以及 shell、环境、子进程、链接、DNS 重绑定和重定向各 100 轮的统一生产驱动。 | +| C-02 | 未通过 | 文件 broker、工具调用和绑定签名许可的 Host HTTPS broker 已实现;尚缺授权公网 HTTPS 的真实成功矩阵,以及 shell、环境、子进程、链接、DNS 重绑定和重定向各 100 轮的统一生产驱动。 | | C-03 | 通过 | 16 个许可字段绑定、旧 Python 启动拒绝和重启许可失效通过。 | | C-04 | 通过 | 当前提交 `d68a6a3` 的统一 runner 报告为 `PASSED`;报告目录为 `H:\OpenNexus-acceptance\c04-d68a6a3-b3ce7ce456834b89b8b508611854e7c9-report`。 | | D-01 | 通过 | 跨语言签名、归档边界、恶意 ZIP 与目录逃逸矩阵通过。 | diff --git a/frontend/src-tauri/Cargo.lock b/frontend/src-tauri/Cargo.lock index 751d758..25ca456 100644 --- a/frontend/src-tauri/Cargo.lock +++ b/frontend/src-tauri/Cargo.lock @@ -1774,6 +1774,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -4264,7 +4265,9 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", + "futures-channel", "futures-core", + "futures-util", "http", "http-body", "http-body-util", diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml index c9fd8f1..7203cdf 100644 --- a/frontend/src-tauri/Cargo.toml +++ b/frontend/src-tauri/Cargo.toml @@ -26,7 +26,7 @@ tempfile = "3" fs2 = "0.4" tauri = { version = "2", optional = true, features = ["tray-icon"] } rfd = { version = "0.15", optional = true } -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"], optional = true } +reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"], optional = true } base64 = { version = "0.22", optional = true } tokio = { version = "1", features = ["rt", "sync", "time", "macros"], optional = true } hmac = { version = "0.12", default-features = false } diff --git a/frontend/src-tauri/src/extension_instance.rs b/frontend/src-tauri/src/extension_instance.rs index 4d79da2..1cabb7e 100644 --- a/frontend/src-tauri/src/extension_instance.rs +++ b/frontend/src-tauri/src/extension_instance.rs @@ -433,6 +433,20 @@ fn run_with_access( now_ms()?, )? }; + let network = { + let credentials = spec + .credentials + .lock() + .map_err(|_| HostError::new("CREDENTIALS_LOCKED"))?; + let mut lease = spec + .authority + .lease(&spec.permit, &spec.claims, now_ms()?)?; + lease.bind_credential(credentials.lock_signal()); + if credentials.is_locked() { + return Err(HostError::new("CREDENTIALS_LOCKED")); + } + crate::extension_network_broker::Broker::new(lease, &spec.claims)? + }; let (suspended, io) = prepared.create_suspended_with_stdio(profile, &entry)?; (spec.before_resume)(&spec.claims)?; if control.stop.load(Ordering::Acquire) { @@ -444,7 +458,7 @@ fn run_with_access( { *control.job.lock().unwrap() = Some(running.test_job()?); } - let mut session = Session::new(&running, io)?; + let mut session = Session::new_with_network(&running, io, Some(network))?; session.initialize(&control.stop)?; let tools = session.refresh_tools(&control.stop)?; *control.identity.lock().unwrap_or_else(|e| e.into_inner()) = Some(running.call_identity()?); @@ -575,7 +589,13 @@ mod tests { entry: "entry.exe".into(), arguments: vec![mode.into()], environment: BTreeMap::new(), - permissions: Default::default(), + permissions: if mode == "mcp_network_denied" { + ["network.https:https://127.0.0.1/".into()] + .into_iter() + .collect() + } else { + Default::default() + }, vault_id: vault_id.clone(), platform: "windows".into(), policy_version: "1".into(), @@ -657,6 +677,24 @@ mod tests { .unwrap(), 0 ); + let denied_network = unsafe { registry.start(make("mcp_network_denied")) }.unwrap(); + wait_for(|| denied_network.snapshot().status != Status::Starting); + assert_eq!(denied_network.snapshot().status, Status::Ready); + let review = denied_network + .review("echo".into(), json!({})) + .unwrap() + .wait(Duration::from_secs(5)) + .unwrap(); + assert!(denied_network + .invoke_confirmed(review.review_id) + .unwrap() + .wait(Duration::from_secs(5)) + .is_ok()); + denied_network.stop(); + wait_for(|| { + registry.reap(); + registry.entries.is_empty() + }); let second = unsafe { registry.start(make("mcp_cancel")) }.unwrap(); wait_for(|| second.snapshot().status != Status::Starting); assert_eq!( diff --git a/frontend/src-tauri/src/extension_mcp.rs b/frontend/src-tauri/src/extension_mcp.rs index 1f5cc20..d769084 100644 --- a/frontend/src-tauri/src/extension_mcp.rs +++ b/frontend/src-tauri/src/extension_mcp.rs @@ -93,9 +93,17 @@ pub struct Session<'a, 'p> { tools_changed: bool, catalog: Option, calls: crate::extension_call_authorization::Gate, + network: Option, } impl<'a, 'p> Session<'a, 'p> { pub fn new(process: &'a Running<'p>, io: HostIo) -> Result { + Self::new_with_network(process, io, None) + } + pub fn new_with_network( + process: &'a Running<'p>, + io: HostIo, + network: Option, + ) -> Result { Ok(Self { process, pump: process.start_io(io)?, @@ -106,6 +114,7 @@ impl<'a, 'p> Session<'a, 'p> { tools_changed: false, catalog: None, calls: crate::extension_call_authorization::Gate::new(process.call_identity()?), + network, }) } pub fn initialize(&mut self, cancel: &AtomicBool) -> Result { @@ -303,8 +312,30 @@ impl<'a, 'p> Session<'a, 'p> { return Ok(false); }; if let Some(id) = &message.id { - self.send(if method == "ping" { json!({"jsonrpc":"2.0","id":id,"result":{}}) } - else { json!({"jsonrpc":"2.0","id":id,"error":{"code":-32601,"message":"Method not supported"}}) })?; + let response = if method == "ping" { + json!({"jsonrpc":"2.0","id":id,"result":{}}) + } else if method == "opennexus/network.fetch" { + let result = message + .params + .clone() + .ok_or_else(invalid) + .and_then(|params| serde_json::from_value(params).map_err(|_| invalid())) + .and_then(|request| { + self.network + .as_mut() + .ok_or_else(|| HostError::new("EXTENSION_NETWORK_PERMISSION_DENIED"))? + .fetch(request) + }); + match result { + Ok(value) => json!({"jsonrpc":"2.0","id":id,"result":value}), + Err(error) => { + json!({"jsonrpc":"2.0","id":id,"error":{"code":-32001,"message":error.code}}) + } + } + } else { + json!({"jsonrpc":"2.0","id":id,"error":{"code":-32601,"message":"Method not supported"}}) + }; + self.send(response)?; } else if method == "notifications/tools/list_changed" { self.tools_changed = true; self.catalog = None; diff --git a/frontend/src-tauri/src/extension_network_broker.rs b/frontend/src-tauri/src/extension_network_broker.rs new file mode 100644 index 0000000..ca70723 --- /dev/null +++ b/frontend/src-tauri/src/extension_network_broker.rs @@ -0,0 +1,273 @@ +//! 由 Host 执行的扩展 HTTPS 代理。沙箱进程本身始终不获得网络能力。 +use crate::{ + extension_permit::{Claims, Lease}, + workspace::{HostError, Result}, +}; +use reqwest::{blocking::Client, redirect::Policy, Method, Url}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::BTreeSet, + io::Read, + net::{IpAddr, SocketAddr, ToSocketAddrs}, + time::{Duration, Instant}, +}; + +const PERMISSION_PREFIX: &str = "network.https:"; +const MAX_REQUEST_BYTES: usize = 1024 * 1024; +const MAX_RESPONSE_BYTES: usize = 4 * 1024 * 1024; +const MAX_CALLS_PER_MINUTE: usize = 60; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FetchRequest { + pub url: String, + #[serde(default = "default_method")] + pub method: String, + #[serde(default)] + pub body: String, + #[serde(default)] + pub content_type: Option, +} + +fn default_method() -> String { + "GET".into() +} + +#[derive(Serialize)] +pub struct FetchResponse { + pub status: u16, + pub body: String, + pub content_type: Option, +} + +pub struct Broker { + lease: Lease, + origins: BTreeSet, + calls: Vec, +} + +impl Broker { + pub fn new(lease: Lease, claims: &Claims) -> Result { + let mut origins = BTreeSet::new(); + for permission in &claims.permissions { + let Some(value) = permission.strip_prefix(PERMISSION_PREFIX) else { + continue; + }; + origins.insert(canonical_origin(value)?); + } + Ok(Self { + lease, + origins, + calls: Vec::new(), + }) + } + + pub fn fetch(&mut self, request: FetchRequest) -> Result { + self.lease.check()?; + let now = Instant::now(); + self.calls + .retain(|called| now.duration_since(*called) < Duration::from_secs(60)); + if self.calls.len() >= MAX_CALLS_PER_MINUTE { + return Err(HostError::new("EXTENSION_NETWORK_RATE_LIMITED")); + } + self.calls.push(now); + + if request.url.len() > 4096 + || request.body.len() > MAX_REQUEST_BYTES + || request + .content_type + .as_ref() + .is_some_and(|value| value.len() > 128 || value.chars().any(char::is_control)) + { + return Err(HostError::new("EXTENSION_NETWORK_REQUEST_INVALID")); + } + let url = Url::parse(&request.url) + .map_err(|_| HostError::new("EXTENSION_NETWORK_REQUEST_INVALID"))?; + validate_url(&url)?; + if !self.origins.contains(&origin(&url)?) { + return Err(HostError::new("EXTENSION_NETWORK_PERMISSION_DENIED")); + } + let host = url + .host_str() + .ok_or_else(|| HostError::new("EXTENSION_NETWORK_REQUEST_INVALID"))?; + let port = url + .port_or_known_default() + .ok_or_else(|| HostError::new("EXTENSION_NETWORK_REQUEST_INVALID"))?; + let addresses: Vec = (host, port) + .to_socket_addrs() + .map_err(|_| HostError::new("EXTENSION_NETWORK_DNS_FAILED"))? + .collect(); + if addresses.is_empty() || addresses.iter().any(|address| prohibited(address.ip())) { + return Err(HostError::new("EXTENSION_NETWORK_ADDRESS_DENIED")); + } + + let client = Client::builder() + .no_proxy() + .redirect(Policy::none()) + .connect_timeout(Duration::from_secs(5)) + .timeout(Duration::from_secs(15)) + .resolve_to_addrs(host, &addresses) + .build() + .map_err(|_| HostError::new("EXTENSION_NETWORK_UNAVAILABLE"))?; + let method = match request.method.as_str() { + "GET" => Method::GET, + "POST" => Method::POST, + _ => return Err(HostError::new("EXTENSION_NETWORK_METHOD_DENIED")), + }; + if method == Method::GET && !request.body.is_empty() { + return Err(HostError::new("EXTENSION_NETWORK_REQUEST_INVALID")); + } + let mut builder = client.request(method, url); + if !request.body.is_empty() { + builder = builder.body(request.body); + } + if let Some(content_type) = request.content_type { + builder = builder.header(reqwest::header::CONTENT_TYPE, content_type); + } + let response = builder + .send() + .map_err(|_| HostError::new("EXTENSION_NETWORK_REQUEST_FAILED"))?; + let status = response.status().as_u16(); + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(|value| value.chars().take(128).collect()); + let mut bytes = Vec::new(); + response + .take((MAX_RESPONSE_BYTES + 1) as u64) + .read_to_end(&mut bytes) + .map_err(|_| HostError::new("EXTENSION_NETWORK_RESPONSE_INVALID"))?; + if bytes.len() > MAX_RESPONSE_BYTES { + return Err(HostError::new("EXTENSION_NETWORK_RESPONSE_TOO_LARGE")); + } + let body = String::from_utf8(bytes) + .map_err(|_| HostError::new("EXTENSION_NETWORK_RESPONSE_INVALID"))?; + self.lease.check()?; + Ok(FetchResponse { + status, + body, + content_type, + }) + } +} + +fn canonical_origin(value: &str) -> Result { + let url = + Url::parse(value).map_err(|_| HostError::new("EXTENSION_NETWORK_PERMISSION_INVALID"))?; + validate_url(&url).map_err(|_| HostError::new("EXTENSION_NETWORK_PERMISSION_INVALID"))?; + if url.path() != "/" || url.query().is_some() || url.fragment().is_some() { + return Err(HostError::new("EXTENSION_NETWORK_PERMISSION_INVALID")); + } + origin(&url) +} + +fn origin(url: &Url) -> Result { + let host = url + .host_str() + .ok_or_else(|| HostError::new("EXTENSION_NETWORK_REQUEST_INVALID"))?; + let port = url + .port_or_known_default() + .ok_or_else(|| HostError::new("EXTENSION_NETWORK_REQUEST_INVALID"))?; + Ok(format!("https://{host}:{port}")) +} + +fn validate_url(url: &Url) -> Result<()> { + if url.scheme() != "https" + || url.host_str().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.fragment().is_some() + { + return Err(HostError::new("EXTENSION_NETWORK_REQUEST_INVALID")); + } + Ok(()) +} + +fn prohibited(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(ip) => { + let octets = ip.octets(); + ip.is_private() + || ip.is_loopback() + || ip.is_link_local() + || ip.is_unspecified() + || ip.is_multicast() + || octets[0] == 0 + || octets[0] >= 224 + || (octets[0] == 100 && (64..=127).contains(&octets[1])) + || (octets[0] == 192 && octets[1] == 0 && octets[2] == 0) + || (octets[0] == 192 && octets[1] == 0 && octets[2] == 2) + || (octets[0] == 192 && octets[1] == 88 && octets[2] == 99) + || (octets[0] == 198 && (octets[1] == 18 || octets[1] == 19)) + || (octets[0] == 198 && octets[1] == 51 && octets[2] == 100) + || (octets[0] == 203 && octets[1] == 0 && octets[2] == 113) + } + IpAddr::V6(ip) => { + let segments = ip.segments(); + let first = segments[0]; + ip.is_loopback() + || ip.is_unspecified() + || ip.is_multicast() + || (first & 0xe000) != 0x2000 + || (first & 0xfe00) == 0xfc00 + || (first & 0xffc0) == 0xfe80 + || (segments[0] == 0x2001 && segments[1] == 0x0db8) + || ip + .to_ipv4_mapped() + .is_some_and(|mapped| prohibited(mapped.into())) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_exact_https_origins_are_accepted() { + assert_eq!( + canonical_origin("https://example.com/").unwrap(), + "https://example.com:443" + ); + assert_eq!( + canonical_origin("https://example.com:8443/").unwrap(), + "https://example.com:8443" + ); + for value in [ + "http://example.com/", + "https://user@example.com/", + "https://example.com/path", + ] { + assert_eq!( + canonical_origin(value).unwrap_err().code, + "EXTENSION_NETWORK_PERMISSION_INVALID" + ); + } + } + + #[test] + fn local_metadata_and_special_addresses_are_denied() { + for value in [ + "127.0.0.1", + "10.0.0.1", + "172.16.0.1", + "192.168.1.1", + "100.64.0.1", + "169.254.169.254", + "192.0.2.1", + "198.18.0.1", + "198.51.100.1", + "203.0.113.1", + "0.0.0.0", + "::1", + "fc00::1", + "fe80::1", + "2001:db8::1", + ] { + assert!(prohibited(value.parse().unwrap()), "{value}"); + } + assert!(!prohibited("8.8.8.8".parse().unwrap())); + assert!(!prohibited("2606:4700:4700::1111".parse().unwrap())); + } +} diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index 0627662..c7432ea 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -79,6 +79,9 @@ mod extension_revocation; #[cfg(all(windows, feature = "desktop"))] pub mod extension_file_broker; +#[cfg(all(windows, feature = "desktop"))] +pub mod extension_network_broker; + #[cfg(windows)] pub mod extension_stdio; diff --git a/frontend/src-tauri/tests/fixtures/sandbox_network_probe.rs b/frontend/src-tauri/tests/fixtures/sandbox_network_probe.rs index dc94a11..96b3bb7 100644 --- a/frontend/src-tauri/tests/fixtures/sandbox_network_probe.rs +++ b/frontend/src-tauri/tests/fixtures/sandbox_network_probe.rs @@ -77,6 +77,12 @@ fn main() { request = read(&mut input); assert!(request.contains("tools/call")); } + if args[1] == "mcp_network_denied" { + println!("{}", r#"{"jsonrpc":"2.0","id":"network-request","method":"opennexus/network.fetch","params":{"url":"https://127.0.0.1/","method":"GET"}}"#); + std::io::stdout().flush().unwrap(); + let response = read(&mut input); + assert!(response.contains("network-request") && response.contains("EXTENSION_NETWORK_ADDRESS_DENIED")); + } println!(r#"{{"jsonrpc":"2.0","id":"server-ping","method":"ping"}}"#); std::io::stdout().flush().unwrap(); let ping = read(&mut input);