diff --git a/frontend/src-tauri/src/extension_call_authorization.rs b/frontend/src-tauri/src/extension_call_authorization.rs new file mode 100644 index 0000000..7ab80f4 --- /dev/null +++ b/frontend/src-tauri/src/extension_call_authorization.rs @@ -0,0 +1,278 @@ +//! Host-memory call reviews. The UI/registry must establish actual user consent +//! before confirm; no renderer command or automatic-consent policy is added here. +use crate::{ + extension_mcp_tools::{Description, Tool}, + extension_permit::{Claims, ExecutionKind}, + workspace::{HostError, Result}, +}; +use serde::Serialize; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::{ + collections::{BTreeMap, BTreeSet}, + time::{Duration, Instant}, +}; +use zeroize::Zeroizing; +#[derive(Clone, Serialize)] +pub struct Identity { + instance_id: String, + kind: ExecutionKind, + source: String, + namespace: String, + package_id: String, + version: String, + vault_id: String, + permissions: BTreeSet, + execution_digest: String, +} +impl Identity { + /// Only called after launch permit/entry/context validation. + pub(crate) fn from_claims(claims: &Claims) -> Result { + let bytes = Zeroizing::new( + serde_json::to_vec(claims) + .map_err(|_| HostError::new("EXTENSION_CALL_BINDING_INVALID"))?, + ); + let mut hash = Sha256::new(); + hash.update(b"OpenNexus execution identity v1\0"); + hash.update(bytes.as_slice()); + Ok(Self { + instance_id: uuid::Uuid::new_v4().to_string(), + kind: claims.kind, + source: claims.source.clone(), + namespace: claims.namespace.clone(), + package_id: claims.package_id.clone(), + version: claims.version.clone(), + vault_id: claims.vault_id.clone(), + permissions: claims.permissions.clone(), + execution_digest: format!("{:x}", hash.finalize()), + }) + } +} +#[derive(Serialize)] +pub struct Review { + pub review_id: String, + pub identity: Identity, + pub tool: Description, + pub arguments: Value, + pub contract_digest: String, + pub valid_for_seconds: u64, +} +struct Pending { + name: String, + arguments: Value, + contract_digest: String, + expires: Instant, + bytes: usize, +} +/// An in-process, non-cloneable, non-serializable, single-consumption capability. +/// Tool name and arguments cannot be replaced after review confirmation. +pub struct ApprovedCall { + instance: String, + epoch: String, + call: Pending, +} +pub(crate) struct Invocation { + pub name: String, + pub arguments: Value, + pub contract_digest: String, +} +pub(crate) struct Gate { + identity: Identity, + epoch: String, + pending: BTreeMap, +} +impl Gate { + pub(crate) fn new(identity: Identity) -> Self { + Self { + identity, + epoch: uuid::Uuid::new_v4().to_string(), + pending: BTreeMap::new(), + } + } + pub(crate) fn invalidate(&mut self) { + self.epoch = uuid::Uuid::new_v4().to_string(); + self.pending.clear(); + } + pub(crate) fn review(&mut self, tool: &Tool, arguments: Value) -> Result { + tool.validate_arguments(&arguments)?; + self.pending + .retain(|_, pending| pending.expires > Instant::now()); + let bytes = serde_json::to_vec(&arguments) + .map_err(|_| HostError::new("EXTENSION_CALL_REVIEW_INVALID"))? + .len(); + if self.pending.len() >= 64 + || self.pending.values().map(|p| p.bytes).sum::() + bytes > 2 * 1024 * 1024 + { + return Err(HostError::new("EXTENSION_CALL_REVIEW_LIMIT")); + } + let tool = tool.description(); + let contract_digest = contract_digest(&tool)?; + let review_id = uuid::Uuid::new_v4().to_string(); + self.pending.insert( + review_id.clone(), + Pending { + name: tool.name.clone(), + arguments: arguments.clone(), + contract_digest: contract_digest.clone(), + expires: Instant::now() + Duration::from_secs(120), + bytes, + }, + ); + Ok(Review { + review_id, + identity: self.identity.clone(), + tool, + arguments, + contract_digest, + valid_for_seconds: 120, + }) + } + /// The authenticated Host approval route must verify user consent first. + pub(crate) fn confirm(&mut self, review_id: &str) -> Result { + let call = self + .pending + .remove(review_id) + .ok_or_else(|| HostError::new("EXTENSION_CALL_REVIEW_UNKNOWN"))?; + if call.expires <= Instant::now() { + return Err(HostError::new("EXTENSION_CALL_REVIEW_EXPIRED")); + } + Ok(ApprovedCall { + instance: self.identity.instance_id.clone(), + epoch: self.epoch.clone(), + call, + }) + } + pub(crate) fn consume(&self, approved: ApprovedCall) -> Result { + if approved.instance != self.identity.instance_id { + return Err(HostError::new("EXTENSION_CALL_INSTANCE_MISMATCH")); + } + if approved.epoch != self.epoch { + return Err(HostError::new("EXTENSION_CALL_CATALOG_CHANGED")); + } + if approved.call.expires <= Instant::now() { + return Err(HostError::new("EXTENSION_CALL_REVIEW_EXPIRED")); + } + Ok(Invocation { + name: approved.call.name, + arguments: approved.call.arguments, + contract_digest: approved.call.contract_digest, + }) + } +} +pub(crate) fn contract_digest(tool: &Description) -> Result { + let mut hash = Sha256::new(); + hash.update(b"OpenNexus MCP tool contract v1\0"); + hash.update( + serde_json::to_vec(tool).map_err(|_| HostError::new("EXTENSION_CALL_REVIEW_INVALID"))?, + ); + Ok(format!("{:x}", hash.finalize())) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::extension_mcp_tools::Catalog; + use serde_json::json; + fn identity() -> Identity { + Identity { + instance_id: uuid::Uuid::new_v4().to_string(), + kind: ExecutionKind::Mcp, + source: "https://catalog.example/".into(), + namespace: "examples".into(), + package_id: "echo".into(), + version: "1.0.0".into(), + vault_id: uuid::Uuid::new_v4().to_string(), + permissions: BTreeSet::new(), + execution_digest: "a".repeat(64), + } + } + fn tool() -> std::sync::Arc { + Catalog::discover(|_| { + Ok(json!({"tools":[{"name":"echo","inputSchema":{"type":"object"}}]})) + }) + .unwrap() + .tool("echo") + .unwrap() + } + #[test] + fn review_freezes_arguments_and_is_single_use_instance_epoch_and_expiry_bound() { + let mut gate = Gate::new(identity()); + let tool = tool(); + let mut review = gate.review(&tool, json!({"value":"original"})).unwrap(); + review.arguments = json!({"value":"tampered"}); + review.tool.name = "tampered".into(); + let approved = gate.confirm(&review.review_id).unwrap(); + assert_eq!( + gate.confirm(&review.review_id).err().unwrap().code, + "EXTENSION_CALL_REVIEW_UNKNOWN" + ); + let call = gate.consume(approved).unwrap(); + assert_eq!(call.name, "echo"); + assert_eq!(call.arguments, json!({"value":"original"})); + assert_eq!( + call.contract_digest, + contract_digest(&tool.description()).unwrap() + ); + let review = gate.review(&tool, json!({})).unwrap(); + let approved = gate.confirm(&review.review_id).unwrap(); + assert_eq!( + Gate::new(identity()).consume(approved).err().unwrap().code, + "EXTENSION_CALL_INSTANCE_MISMATCH" + ); + let review = gate.review(&tool, json!({})).unwrap(); + let approved = gate.confirm(&review.review_id).unwrap(); + let pending = gate.review(&tool, json!({})).unwrap(); + gate.invalidate(); + assert_eq!( + gate.consume(approved).err().unwrap().code, + "EXTENSION_CALL_CATALOG_CHANGED" + ); + assert_eq!( + gate.confirm(&pending.review_id).err().unwrap().code, + "EXTENSION_CALL_REVIEW_UNKNOWN" + ); + let review = gate.review(&tool, json!({})).unwrap(); + gate.pending.get_mut(&review.review_id).unwrap().expires = Instant::now(); + assert_eq!( + gate.confirm(&review.review_id).err().unwrap().code, + "EXTENSION_CALL_REVIEW_EXPIRED" + ); + let review = gate.review(&tool, json!({})).unwrap(); + let mut approved = gate.confirm(&review.review_id).unwrap(); + approved.call.expires = Instant::now(); + assert_eq!( + gate.consume(approved).err().unwrap().code, + "EXTENSION_CALL_REVIEW_EXPIRED" + ); + } + #[test] + fn pending_review_count_and_bytes_are_bounded_and_invalidated_slots_are_reusable() { + let tool = tool(); + let mut gate = Gate::new(identity()); + for _ in 0..64 { + gate.review(&tool, json!({})).unwrap(); + } + assert_eq!( + gate.review(&tool, json!({})).err().unwrap().code, + "EXTENSION_CALL_REVIEW_LIMIT" + ); + gate.invalidate(); + for _ in 0..10 { + gate.review(&tool, json!({"value":"x".repeat(200_000)})) + .unwrap(); + } + assert_eq!( + gate.review(&tool, json!({"value":"x".repeat(200_000)})) + .err() + .unwrap() + .code, + "EXTENSION_CALL_REVIEW_LIMIT" + ); + for value in gate.pending.values_mut() { + value.expires = Instant::now(); + } + gate.review(&tool, json!({"value":"x".repeat(200_000)})) + .unwrap(); + assert_eq!(gate.pending.len(), 1); + } +} diff --git a/frontend/src-tauri/src/extension_container.rs b/frontend/src-tauri/src/extension_container.rs index 22460ee..247ea10 100644 --- a/frontend/src-tauri/src/extension_container.rs +++ b/frontend/src-tauri/src/extension_container.rs @@ -874,6 +874,7 @@ mod tests { "mcp_pages", "mcp_bad_result", "mcp_idle_change", + "mcp_review_lock", ]; if _mcp_deadline { mcp_modes.push("mcp_deadline"); @@ -898,7 +899,7 @@ mod tests { let cancel = Arc::new(AtomicBool::new(false)); assert_eq!( session - .call_tool("echo", serde_json::json!({}), &cancel) + .test_call_tool("echo", serde_json::json!({}), &cancel) .unwrap_err() .code, "EXTENSION_MCP_NOT_INITIALIZED" @@ -915,7 +916,7 @@ mod tests { ); assert_eq!( session - .call_tool("echo", serde_json::json!({}), &cancel) + .test_call_tool("echo", serde_json::json!({}), &cancel) .unwrap_err() .code, "EXTENSION_MCP_CATALOG_REQUIRED" @@ -923,14 +924,14 @@ mod tests { assert_eq!(session.refresh_tools(&cancel).unwrap()[0].name, "echo"); assert_eq!( session - .call_tool("missing", serde_json::json!({}), &cancel) + .test_call_tool("missing", serde_json::json!({}), &cancel) .unwrap_err() .code, "EXTENSION_MCP_TOOL_NOT_FOUND" ); assert_eq!( session - .call_tool("echo", serde_json::json!({"unexpected":1}), &cancel) + .test_call_tool("echo", serde_json::json!({"unexpected":1}), &cancel) .unwrap_err() .code, "EXTENSION_MCP_ARGUMENTS_INVALID" @@ -944,9 +945,42 @@ mod tests { } else { None }; + let mut earlier = None; + if mode == "mcp" { + let review = session.review_call("echo", serde_json::json!({})).unwrap(); + let identity = serde_json::to_value(&review.identity).unwrap(); + assert_eq!(identity["package_id"], mcp.package_id); + assert_eq!(identity["vault_id"], mcp.vault_id); + assert_eq!(identity["source"], mcp.source); + assert_eq!(identity["execution_digest"].as_str().unwrap().len(), 64); + earlier = Some(session.confirm_call(&review.review_id).unwrap()); + } let tool_started = std::time::Instant::now(); - let result = session.call_tool("echo", serde_json::json!({}), &cancel); + let result = if mode == "mcp" { + let mut review = + session.review_call("echo", serde_json::json!({})).unwrap(); + review.arguments = serde_json::json!({"unexpected":1}); + let approved = session.confirm_call(&review.review_id).unwrap(); + session.call_tool(approved, &cancel) + } else if mode == "mcp_review_lock" { + let review = session.review_call("echo", serde_json::json!({})).unwrap(); + let approved = session.confirm_call(&review.review_id).unwrap(); + broker.lock(); + session.call_tool(approved, &cancel) + } else { + session.test_call_tool("echo", serde_json::json!({}), &cancel) + }; match mode { + "mcp_review_lock" => { + assert_eq!(result.unwrap_err().code, "CREDENTIALS_LOCKED"); + broker + .unlock(Zeroizing::new(b"native fixture passphrase".to_vec())) + .unwrap(); + assert_eq!( + session.refresh_tools(&cancel).err().unwrap().code, + "EXTENSION_MCP_SESSION_FAILED" + ); + } "mcp_bad_result" => assert_eq!( result.unwrap_err().code, "EXTENSION_MCP_TOOL_RESULT_INVALID" @@ -956,7 +990,7 @@ mod tests { std::thread::sleep(std::time::Duration::from_millis(200)); assert_eq!( session - .call_tool("echo", serde_json::json!({}), &cancel) + .test_call_tool("echo", serde_json::json!({}), &cancel) .unwrap_err() .code, "EXTENSION_MCP_CATALOG_REQUIRED" @@ -1005,7 +1039,7 @@ mod tests { assert_eq!(result.unwrap_err().code, "EXTENSION_MCP_REMOTE_ERROR"); assert_eq!( session - .call_tool("echo", serde_json::json!({}), &cancel) + .test_call_tool("echo", serde_json::json!({}), &cancel) .unwrap()["content"][0]["text"], "native MCP success" ); @@ -1014,9 +1048,15 @@ mod tests { assert_eq!(result.unwrap()["content"][0]["text"], "native MCP success"); assert!(session.take_tools_changed()); assert!(!session.take_tools_changed()); + if let Some(approved) = earlier { + assert_eq!( + session.call_tool(approved, &cancel).unwrap_err().code, + "EXTENSION_CALL_CATALOG_CHANGED" + ); + } assert_eq!( session - .call_tool("echo", serde_json::json!({}), &cancel) + .test_call_tool("echo", serde_json::json!({}), &cancel) .unwrap_err() .code, "EXTENSION_MCP_CATALOG_REQUIRED" diff --git a/frontend/src-tauri/src/extension_launch_authorization.rs b/frontend/src-tauri/src/extension_launch_authorization.rs index 8911906..8312669 100644 --- a/frontend/src-tauri/src/extension_launch_authorization.rs +++ b/frontend/src-tauri/src/extension_launch_authorization.rs @@ -22,6 +22,7 @@ pub struct Context<'a> { pub struct PreparedLaunch { data: LaunchData, lease: crate::extension_permit::Lease, + identity: crate::extension_call_authorization::Identity, path: std::path::PathBuf, entry: String, tree: String, @@ -29,6 +30,7 @@ pub struct PreparedLaunch { pub struct LeasedSuspended<'a> { process: crate::extension_process::Suspended<'a>, lease: crate::extension_permit::Lease, + identity: crate::extension_call_authorization::Identity, } impl PreparedLaunch { pub fn create_suspended<'a>( @@ -48,6 +50,7 @@ impl PreparedLaunch { Ok(LeasedSuspended { process, lease: self.lease, + identity: self.identity, }) } pub fn create_suspended_with_stdio<'a>( @@ -70,6 +73,7 @@ impl PreparedLaunch { LeasedSuspended { process, lease: self.lease, + identity: self.identity, }, io, )) @@ -80,7 +84,7 @@ impl<'a> LeasedSuspended<'a> { /// 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) } + unsafe { self.process.resume_with_lease(self.lease, self.identity) } } } struct EnvironmentValues(BTreeMap); @@ -153,6 +157,7 @@ impl Context<'_> { Ok(PreparedLaunch { data, lease, + identity: crate::extension_call_authorization::Identity::from_claims(claims)?, path: entry.path().to_owned(), entry: entry.relative_name().to_owned(), tree: entry.tree_sha256().to_owned(), diff --git a/frontend/src-tauri/src/extension_mcp.rs b/frontend/src-tauri/src/extension_mcp.rs index 930294d..9318d9e 100644 --- a/frontend/src-tauri/src/extension_mcp.rs +++ b/frontend/src-tauri/src/extension_mcp.rs @@ -1,5 +1,5 @@ -//! Serial MCP session over an already-authorized native instance. Package trust, -//! tool consent/schema validation and registry routing remain Host responsibilities. +//! Serial MCP session over an already-authorized native instance. The Host +//! approval route still must establish user consent and current installation/trust. use crate::{ extension_io::{Event, Pump}, extension_process::Running, @@ -93,6 +93,7 @@ pub struct Session<'a, 'p> { failed: bool, tools_changed: bool, catalog: Option, + calls: crate::extension_call_authorization::Gate, } impl<'a, 'p> Session<'a, 'p> { pub fn new(process: &'a Running<'p>, io: HostIo) -> Result { @@ -105,6 +106,7 @@ impl<'a, 'p> Session<'a, 'p> { failed: false, tools_changed: false, catalog: None, + calls: crate::extension_call_authorization::Gate::new(process.call_identity()?), }) } pub fn initialize(&mut self, cancel: &AtomicBool) -> Result { @@ -149,6 +151,7 @@ impl<'a, 'p> Session<'a, 'p> { self.require_tools()?; self.drain_pending()?; self.catalog = None; + self.calls.invalidate(); self.tools_changed = false; let started = Instant::now(); let catalog = crate::extension_mcp_tools::Catalog::discover(|cursor| { @@ -201,12 +204,62 @@ impl<'a, 'p> Session<'a, 'p> { } Ok(value) } + pub fn review_call( + &mut self, + name: &str, + arguments: Value, + ) -> Result { + self.require_tools()?; + self.drain_pending()?; + let tool = self + .catalog + .as_ref() + .ok_or_else(|| HostError::new("EXTENSION_MCP_CATALOG_REQUIRED"))? + .tool(name)?; + self.calls.review(&tool, arguments) + } + /// Only invoke from an authenticated Host route after the user approved this + /// exact review. This method is not registered as a renderer/Core command. + pub fn confirm_call( + &mut self, + review_id: &str, + ) -> Result { + self.require_tools()?; + self.drain_pending()?; + self.calls.confirm(review_id) + } pub fn call_tool( + &mut self, + approved: crate::extension_call_authorization::ApprovedCall, + cancel: &AtomicBool, + ) -> Result { + self.require_tools()?; + self.drain_pending()?; + let call = self.calls.consume(approved)?; + let tool = self + .catalog + .as_ref() + .ok_or_else(|| HostError::new("EXTENSION_MCP_CATALOG_REQUIRED"))? + .tool(&call.name)?; + if crate::extension_call_authorization::contract_digest(&tool.description())? + != call.contract_digest + { + return Err(HostError::new("EXTENSION_CALL_CATALOG_CHANGED")); + } + self.invoke_tool(&call.name, call.arguments, cancel) + } + #[cfg(test)] + pub(crate) fn test_call_tool( &mut self, name: &str, arguments: Value, cancel: &AtomicBool, ) -> Result { + let review = self.review_call(name, arguments)?; + let approved = self.confirm_call(&review.review_id)?; + self.call_tool(approved, cancel) + } + fn invoke_tool(&mut self, name: &str, arguments: Value, cancel: &AtomicBool) -> Result { self.require_tools()?; self.drain_pending()?; if name.is_empty() @@ -256,6 +309,7 @@ impl<'a, 'p> Session<'a, 'p> { } else if method == "notifications/tools/list_changed" { self.tools_changed = true; self.catalog = None; + self.calls.invalidate(); } Ok(true) } @@ -294,6 +348,7 @@ impl<'a, 'p> Session<'a, 'p> { } fn abort(&mut self) { self.failed = true; + self.calls.invalidate(); let _ = self.process.terminate(); } fn request( diff --git a/frontend/src-tauri/src/extension_mcp_tools.rs b/frontend/src-tauri/src/extension_mcp_tools.rs index a40f612..b8abd70 100644 --- a/frontend/src-tauri/src/extension_mcp_tools.rs +++ b/frontend/src-tauri/src/extension_mcp_tools.rs @@ -77,6 +77,9 @@ pub struct Tool { output: Option, } impl Tool { + pub(crate) fn description(&self) -> Description { + self.description.clone() + } fn parse(value: &Value) -> Result { bounded(value, 192 * 1024, false)?; let name = value["name"] diff --git a/frontend/src-tauri/src/extension_process.rs b/frontend/src-tauri/src/extension_process.rs index b3f318b..0762c20 100644 --- a/frontend/src-tauri/src/extension_process.rs +++ b/frontend/src-tauri/src/extension_process.rs @@ -94,6 +94,8 @@ pub struct Running<'a> { process: Process<'a>, #[cfg(feature = "desktop")] revocation: Option, + #[cfg(feature = "desktop")] + identity: Option, } impl<'a> Suspended<'a> { /// Creates hidden, with no inherited handles and an explicit environment and @@ -244,15 +246,18 @@ impl<'a> Suspended<'a> { process: self.0, #[cfg(feature = "desktop")] revocation: None, + #[cfg(feature = "desktop")] + identity: 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( + pub(crate) unsafe fn resume_with_lease( self, lease: crate::extension_permit::Lease, + identity: crate::extension_call_authorization::Identity, ) -> Result> { let watch = crate::extension_revocation::Watch::arm(&self.0.job, lease)?; watch.check()?; @@ -262,12 +267,20 @@ impl<'a> Suspended<'a> { let running = Running { process: self.0, revocation: Some(watch), + identity: Some(identity), }; running.check_authorization()?; Ok(running) } } impl Running<'_> { + #[cfg(feature = "desktop")] + pub(crate) fn call_identity(&self) -> Result { + self.check_authorization()?; + self.identity + .clone() + .ok_or_else(|| HostError::new("EXTENSION_CALL_BINDING_REQUIRED")) + } #[cfg(feature = "desktop")] pub fn start_io( &self, diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index ff3b033..d604c0c 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -90,3 +90,6 @@ pub mod extension_mcp; #[cfg(all(windows, feature = "desktop"))] pub mod extension_mcp_tools; + +#[cfg(all(windows, feature = "desktop"))] +pub mod extension_call_authorization;