//! Plugin package contributions for model-visible Tool schemas. //! //! This module registers *enabled* plugin package tool surface definitions and //! executes Tool calls through the minimal sandboxed `yoi-plugin-wasm-1` WASM //! ABI. It deliberately does not grant filesystem, environment, hook, service, //! ingress, or ambient network authority. WASM Tools can only reach outbound HTTPS //! through the explicit `yoi:https` host import, and filesystem read/list/write //! through the explicit `yoi:fs` host import, with matching permissions and //! scoped allowlist grants. use std::collections::{HashMap, HashSet}; use std::fs; use std::io::{Read as _, Write as _}; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, ToSocketAddrs}; use std::path::{Component, Path, PathBuf}; use std::sync::{Arc, Mutex, OnceLock}; use std::time::Duration; use async_trait::async_trait; use llm_worker::tool::{ Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOrigin, ToolOutput, }; use manifest::plugin::{ PLUGIN_COMPONENT_TOOL_WORLD, PLUGIN_RUNTIME_COMPONENT_KIND, PLUGIN_RUNTIME_WASM_ABI, PLUGIN_RUNTIME_WASM_KIND, PluginConfig, PluginDiscoveryLimits, PluginFsGrant, PluginFsOperation, PluginHostApi, PluginPermission, PluginSurface, PluginToolManifest, ResolvedPluginRecord, read_resolved_plugin_runtime_component, read_resolved_plugin_runtime_module, }; use serde::{Deserialize, Serialize}; use serde_json::Value; use super::{ FeatureDescriptor, FeatureId, FeatureInstallContext, FeatureInstallError, FeatureModule, FeatureRuntimeKind, ToolContribution, ToolDeclaration, }; /// Build Feature modules for enabled plugin packages when the profile exposes /// the plugin Tool surface feature. pub fn plugin_tool_features_if_enabled( feature_enabled: bool, config: &PluginConfig, ) -> Vec { if !feature_enabled { return Vec::new(); } plugin_tool_features(config) } /// Build Feature modules for enabled plugin packages that declare Tool surfaces. pub fn plugin_tool_features(config: &PluginConfig) -> Vec { config .resolved .iter() .filter(|record| record.enabled_surfaces.contains(&PluginSurface::Tool)) .filter(|record| !record.manifest.tools.is_empty()) .cloned() .map(PluginToolFeature::new) .collect() } #[derive(Clone, Debug)] pub struct PluginToolFeature { record: ResolvedPluginRecord, feature_id: FeatureId, } impl PluginToolFeature { pub fn new(record: ResolvedPluginRecord) -> Self { let feature_id = FeatureId::new(format!("plugin:{}:tool", record.identity)) .expect("source-qualified plugin identity yields non-empty feature id"); Self { record, feature_id } } pub fn origin(&self) -> ToolOrigin { ToolOrigin { kind: "plugin".into(), plugin_id: self.record.manifest.id.clone(), plugin_ref: self.record.identity.to_string(), source: self.record.identity.source.to_string(), digest: self.record.digest.clone(), package_version: self.record.version.clone(), package_api_version: self.record.manifest.schema_version, surface: "tool".into(), } } } /// Static, read-only eligibility information for a resolved plugin package. /// /// This inspection mirrors the registration-time permission checks without /// loading the WASM module, calling a plugin Tool, or executing plugin code. #[derive(Clone, Debug, PartialEq, Eq, Serialize)] pub struct PluginStaticInspection { pub runtime: PluginRuntimeEligibility, pub host_apis: Vec, pub tools: Vec, } impl PluginStaticInspection { pub fn statically_eligible(&self) -> bool { self.runtime.eligible && self.host_apis.iter().all(|api| api.eligible) && self.tools.iter().all(|tool| tool.eligible) } } #[derive(Clone, Debug, PartialEq, Eq, Serialize)] pub struct PluginRuntimeEligibility { pub eligible: bool, pub status: String, pub diagnostic: Option, } #[derive(Clone, Debug, PartialEq, Eq, Serialize)] pub struct PluginPermissionEligibility { pub permission: String, pub requested: bool, pub granted: bool, pub eligible: bool, pub diagnostic: Option, } #[derive(Clone, Debug, PartialEq, Eq, Serialize)] pub struct PluginToolEligibility { pub name: String, pub permission: String, pub requested: bool, pub granted: bool, pub eligible: bool, pub external_write: bool, pub diagnostic: Option, } /// Inspect static plugin runtime/tool eligibility without executing plugin code. pub fn inspect_resolved_plugin_static(record: &ResolvedPluginRecord) -> PluginStaticInspection { let runtime = match &record.manifest.runtime { Some(runtime) if runtime.kind == PLUGIN_RUNTIME_WASM_KIND && runtime.abi.as_deref() == Some(PLUGIN_RUNTIME_WASM_ABI) && runtime.entry.is_some() => { PluginRuntimeEligibility { eligible: true, status: format!("{PLUGIN_RUNTIME_WASM_KIND}/{PLUGIN_RUNTIME_WASM_ABI}"), diagnostic: None, } } Some(runtime) if runtime.kind == PLUGIN_RUNTIME_WASM_KIND => { let status = runtime .abi .as_deref() .map(|abi| format!("{PLUGIN_RUNTIME_WASM_KIND}/{abi}")) .unwrap_or_else(|| format!("{PLUGIN_RUNTIME_WASM_KIND}/")); PluginRuntimeEligibility { eligible: false, status, diagnostic: Some("unsupported or missing plugin runtime ABI".to_string()), } } Some(runtime) if runtime.kind == PLUGIN_RUNTIME_COMPONENT_KIND && runtime.world.as_deref() == Some(PLUGIN_COMPONENT_TOOL_WORLD) && runtime.component.is_some() => { PluginRuntimeEligibility { eligible: true, status: format!("{PLUGIN_RUNTIME_COMPONENT_KIND}/{PLUGIN_COMPONENT_TOOL_WORLD}"), diagnostic: None, } } Some(runtime) if runtime.kind == PLUGIN_RUNTIME_COMPONENT_KIND => { let status = runtime .world .as_deref() .map(|world| format!("{PLUGIN_RUNTIME_COMPONENT_KIND}/{world}")) .unwrap_or_else(|| format!("{PLUGIN_RUNTIME_COMPONENT_KIND}/")); PluginRuntimeEligibility { eligible: false, status, diagnostic: Some("unsupported or missing plugin component world".to_string()), } } Some(runtime) => PluginRuntimeEligibility { eligible: false, status: runtime.kind.clone(), diagnostic: Some(format!( "unsupported plugin runtime kind `{}`", runtime.kind )), }, None => PluginRuntimeEligibility { eligible: false, status: "none".to_string(), diagnostic: Some("plugin runtime is not declared".to_string()), }, }; let host_apis = [PluginHostApi::Https, PluginHostApi::Fs] .into_iter() .filter_map(|api| { let permission = PluginPermission::host_api(api); let requested = permission_requested(record, &permission); let granted = grant_allows(record, &permission); if !requested && !granted { return None; } let diagnostic = authorize_plugin_host_api(record, api) .err() .map(|error| error.bounded_message()); Some(PluginPermissionEligibility { permission: permission.label(), requested, granted, eligible: diagnostic.is_none(), diagnostic, }) }) .collect(); let duplicate_tool_names = duplicate_tool_names(record); let tools = record .manifest .tools .iter() .map(|tool| { let permission = PluginPermission::tool(&tool.name); let requested = permission_requested(record, &permission); let granted = grant_allows(record, &permission); let mut diagnostics = validate_plugin_tool_definition(tool, &duplicate_tool_names); if let Err(error) = authorize_plugin_tool(record, tool) { diagnostics.push(error.bounded_message()); } let diagnostic = join_tool_diagnostics(diagnostics); PluginToolEligibility { name: tool.name.clone(), permission: permission.label(), requested, granted, eligible: diagnostic.is_none(), external_write: tool.external_write, diagnostic, } }) .collect(); PluginStaticInspection { runtime, host_apis, tools, } } fn permission_requested(record: &ResolvedPluginRecord, permission: &PluginPermission) -> bool { record .manifest .permissions .iter() .any(|requested| requested == permission) } fn grant_allows(record: &ResolvedPluginRecord, permission: &PluginPermission) -> bool { record .grants .permissions .iter() .any(|granted| granted == permission) } fn duplicate_tool_names(record: &ResolvedPluginRecord) -> HashSet { let mut seen = HashSet::new(); let mut duplicates = HashSet::new(); for tool in &record.manifest.tools { if !seen.insert(tool.name.clone()) { duplicates.insert(tool.name.clone()); } } duplicates } fn validate_plugin_tool_definition( tool: &PluginToolManifest, duplicate_tool_names: &HashSet, ) -> Vec { let mut diagnostics = Vec::new(); if duplicate_tool_names.contains(&tool.name) { diagnostics.push(format!( "tool `{}` has duplicate name within plugin manifest", tool.name )); } if let Err(reason) = validate_tool_name(&tool.name) { diagnostics.push(format!("tool `{}` has invalid name: {reason}", tool.name)); } if let Err(reason) = validate_input_schema(&tool.input_schema) { diagnostics.push(format!( "tool `{}` has invalid input_schema: {reason}", tool.name )); } diagnostics } fn join_tool_diagnostics(diagnostics: Vec) -> Option { if diagnostics.is_empty() { None } else { Some(bounded_message(diagnostics.join("; "))) } } impl FeatureModule for PluginToolFeature { fn descriptor(&self) -> FeatureDescriptor { let mut descriptor = FeatureDescriptor { id: self.feature_id.clone(), runtime: FeatureRuntimeKind::ExternalPlugin, display_name: self.record.manifest.name.clone(), version: self.record.manifest.version.clone(), description: self.record.manifest.description.clone().unwrap_or_else(|| { format!("Plugin tool surface from {}", self.record.identity) }), tools: Vec::new(), hooks: Vec::new(), background_tasks: Vec::new(), provides_services: Vec::new(), requires_services: Vec::new(), protocol_providers: Vec::new(), }; for tool in &self.record.manifest.tools { descriptor = descriptor.with_tool(ToolDeclaration::new( tool.name.clone(), tool.description.clone(), )); } descriptor } fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> { validate_declared_tool_names(&self.record)?; let origin = self.origin(); let mut registered = 0usize; let mut denied = Vec::new(); for tool in &self.record.manifest.tools { validate_tool_name(&tool.name).map_err(|reason| { FeatureInstallError::Install(format!( "plugin `{}` tool `{}` has invalid name: {reason}", self.record.identity, tool.name )) })?; validate_input_schema(&tool.input_schema).map_err(|reason| { FeatureInstallError::Install(format!( "plugin `{}` tool `{}` has invalid input_schema: {reason}", self.record.identity, tool.name )) })?; if let Err(error) = authorize_plugin_tool(&self.record, tool) { let message = format!( "plugin `{}` tool `{}` registration denied: {}", self.record.identity, tool.name, error.bounded_message() ); context.diagnostics().warning(message.clone()); denied.push(message); continue; } context.tools().register(ToolContribution::new( tool.name.clone(), plugin_wasm_tool_definition( self.record.clone(), tool.name.clone(), tool.description.clone(), tool.input_schema.clone(), origin.clone(), ), ))?; registered += 1; } if registered == 0 && !denied.is_empty() { let summary = if denied.len() == 1 { denied.remove(0) } else { format!( "{} plugin tool registrations denied; first denial: {}", denied.len(), denied[0] ) }; return Err(FeatureInstallError::Install(bounded_message(summary))); } Ok(()) } } impl PluginHttpsClient for ReqwestPluginHttpsClient { fn execute( &self, request: &PluginHttpsRequest, url: &reqwest::Url, limits: PluginHttpsLimits, ) -> Result { let pinned_resolution = resolve_https_target_for_client(url, &SystemPluginHttpsResolver)?; let method = reqwest::Method::from_bytes(request.method.as_bytes()).map_err(|_| { PluginHttpsError::new(format!("unsupported HTTPS method `{}`", request.method)) })?; let mut client_builder = reqwest::blocking::Client::builder() .redirect(reqwest::redirect::Policy::none()) .timeout(limits.timeout) .no_proxy() .user_agent("yoi-plugin-https-host-api/0.1"); if let Some(pinned_resolution) = &pinned_resolution { for domain in &pinned_resolution.domains { client_builder = client_builder.resolve_to_addrs(domain, &pinned_resolution.addrs); } } let client = client_builder.build().map_err(|error| { PluginHttpsError::new(format!("HTTPS client build failed: {error}")) })?; let mut builder = client.request(method, url.clone()).timeout(limits.timeout); for header in &request.headers { let name = reqwest::header::HeaderName::from_bytes(header.name.as_bytes()).map_err(|_| { PluginHttpsError::new(format!( "invalid HTTPS request header name `{}`", header.name )) })?; let value = reqwest::header::HeaderValue::from_str(&header.value).map_err(|_| { PluginHttpsError::new(format!( "invalid HTTPS request header value for `{}`", header.name )) })?; builder = builder.header(name, value); } if let Some(body) = &request.body { builder = builder.body(body.clone()); } let mut response = builder.send().map_err(|error| { if error.is_timeout() { PluginHttpsError::new(format!("HTTPS request to {} timed out", safe_url(url))) } else { PluginHttpsError::new(format!( "HTTPS request to {} failed: {error}", safe_url(url) )) } })?; let status = response.status().as_u16(); let headers = collect_https_response_headers(response.headers()); let mut body = Vec::new(); let read_limit = limits.max_response_bytes.saturating_add(1) as u64; response .by_ref() .take(read_limit) .read_to_end(&mut body) .map_err(|error| { PluginHttpsError::new(format!("HTTPS response read failed: {error}")) })?; let truncated = body.len() > limits.max_response_bytes; if truncated { body.truncate(limits.max_response_bytes); } Ok(PluginHttpsResponse { status, headers, body: String::from_utf8_lossy(&body).into_owned(), truncated, }) } } fn execute_plugin_https_request( record: &ResolvedPluginRecord, client: &dyn PluginHttpsClient, request_bytes: &[u8], ) -> Result, PluginHttpsError> { if request_bytes.len() > PLUGIN_HTTPS_MAX_REQUEST_BYTES { return Err(PluginHttpsError::new(format!( "HTTPS request descriptor exceeds {} bytes", PLUGIN_HTTPS_MAX_REQUEST_BYTES ))); } authorize_plugin_host_api(record, PluginHostApi::Https).map_err(|error| { PluginHttpsError::new(format!( "plugin host API dispatch denied: {}", error.bounded_message() )) })?; let request: PluginHttpsRequest = serde_json::from_slice(request_bytes) .map_err(|error| PluginHttpsError::new(format!("invalid HTTPS request JSON: {error}")))?; let url = validate_plugin_https_request(record, &request)?; let mut response = client.execute(&request, &url, PluginHttpsLimits::default())?; enforce_https_response_bounds(&mut response, PluginHttpsLimits::default()); serde_json::to_vec(&response) .map_err(|error| PluginHttpsError::new(format!("failed to encode HTTPS response: {error}"))) } fn execute_plugin_fs_request( record: &ResolvedPluginRecord, operation: PluginFsRuntimeOperation, request_bytes: &[u8], ) -> Result, PluginFsError> { if request_bytes.len() > PLUGIN_FS_MAX_REQUEST_BYTES { return Err(PluginFsError::new(format!( "FS request descriptor exceeds {} bytes", PLUGIN_FS_MAX_REQUEST_BYTES ))); } authorize_plugin_host_api(record, PluginHostApi::Fs).map_err(|error| { PluginFsError::new(format!( "plugin host API dispatch denied: {}", error.bounded_message() )) })?; match operation { PluginFsRuntimeOperation::Read => { let request: PluginFsPathRequest = serde_json::from_slice(request_bytes).map_err(|error| { PluginFsError::new(format!("invalid FS read request JSON: {error}")) })?; execute_plugin_fs_read(record, &request.path) } PluginFsRuntimeOperation::List => { let request: PluginFsPathRequest = serde_json::from_slice(request_bytes).map_err(|error| { PluginFsError::new(format!("invalid FS list request JSON: {error}")) })?; execute_plugin_fs_list(record, &request.path) } PluginFsRuntimeOperation::Write => { let request: PluginFsWriteRequest = serde_json::from_slice(request_bytes).map_err(|error| { PluginFsError::new(format!("invalid FS write request JSON: {error}")) })?; execute_plugin_fs_write(record, &request.path, request.content.as_bytes()) } } } fn execute_plugin_fs_read( record: &ResolvedPluginRecord, path: &str, ) -> Result, PluginFsError> { let target = authorize_fs_path(record, PluginFsRuntimeOperation::Read, path)?; let meta = fs::metadata(&target.resolved).map_err(|error| { PluginFsError::new(format!( "FS read metadata failed for {}: {error}", safe_fs_path(path) )) })?; if !meta.is_file() { return Err(PluginFsError::new(format!( "FS read target is not a regular file: {}", safe_fs_path(path) ))); } let mut file = fs::File::open(&target.resolved).map_err(|error| { PluginFsError::new(format!( "FS read failed for {}: {error}", safe_fs_path(path) )) })?; let mut bytes = Vec::new(); std::io::Read::by_ref(&mut file) .take((PLUGIN_FS_MAX_READ_BYTES + 1) as u64) .read_to_end(&mut bytes) .map_err(|error| { PluginFsError::new(format!( "FS read failed for {}: {error}", safe_fs_path(path) )) })?; let truncated = bytes.len() > PLUGIN_FS_MAX_READ_BYTES; if truncated { bytes.truncate(PLUGIN_FS_MAX_READ_BYTES); } let response = PluginFsReadResponse { path: safe_fs_path(path), content: String::from_utf8_lossy(&bytes).into_owned(), truncated, }; serde_json::to_vec(&response) .map_err(|error| PluginFsError::new(format!("failed to encode FS read response: {error}"))) } fn execute_plugin_fs_list( record: &ResolvedPluginRecord, path: &str, ) -> Result, PluginFsError> { let target = authorize_fs_path(record, PluginFsRuntimeOperation::List, path)?; let meta = fs::metadata(&target.resolved).map_err(|error| { PluginFsError::new(format!( "FS list metadata failed for {}: {error}", safe_fs_path(path) )) })?; if !meta.is_dir() { return Err(PluginFsError::new(format!( "FS list target is not a directory: {}", safe_fs_path(path) ))); } let mut entries = Vec::new(); let mut truncated = false; for entry in fs::read_dir(&target.resolved).map_err(|error| { PluginFsError::new(format!( "FS list failed for {}: {error}", safe_fs_path(path) )) })? { let entry = entry.map_err(|error| { PluginFsError::new(format!( "FS list failed for {}: {error}", safe_fs_path(path) )) })?; if entries.len() >= PLUGIN_FS_MAX_LIST_ENTRIES { truncated = true; break; } let name = entry.file_name().to_string_lossy().into_owned(); let file_type = entry.file_type().map_err(|error| { PluginFsError::new(format!( "FS list failed for {}: {error}", safe_fs_path(path) )) })?; let kind = if file_type.is_dir() { "dir" } else if file_type.is_file() { "file" } else if file_type.is_symlink() { "symlink" } else { "other" }; entries.push(PluginFsDirEntry { name, kind: kind.to_string(), }); } entries.sort_by(|left, right| left.name.cmp(&right.name)); let response = PluginFsListResponse { path: safe_fs_path(path), entries, truncated, }; serde_json::to_vec(&response) .map_err(|error| PluginFsError::new(format!("failed to encode FS list response: {error}"))) } fn execute_plugin_fs_write( record: &ResolvedPluginRecord, path: &str, content: &[u8], ) -> Result, PluginFsError> { if content.len() > PLUGIN_FS_MAX_WRITE_BYTES { return Err(PluginFsError::new(format!( "FS write content exceeds {} bytes", PLUGIN_FS_MAX_WRITE_BYTES ))); } let target = authorize_fs_write_path(record, path)?; let lock = plugin_fs_write_lock(target.lock_key.clone()); let _guard = lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); let mut options = fs::OpenOptions::new(); options.write(true).create(true).truncate(true); #[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt as _; options.custom_flags(libc::O_NOFOLLOW); } let mut file = options.open(&target.resolved).map_err(|error| { PluginFsError::new(format!( "FS write failed for {}: {error}", safe_fs_path(path) )) })?; file.write_all(content).map_err(|error| { PluginFsError::new(format!( "FS write failed for {}: {error}", safe_fs_path(path) )) })?; file.sync_all().map_err(|error| { PluginFsError::new(format!( "FS write failed for {}: {error}", safe_fs_path(path) )) })?; let response = PluginFsWriteResponse { path: safe_fs_path(path), bytes_written: content.len(), }; serde_json::to_vec(&response) .map_err(|error| PluginFsError::new(format!("failed to encode FS write response: {error}"))) } fn enforce_https_response_bounds(response: &mut PluginHttpsResponse, limits: PluginHttpsLimits) { if response.body.len() > limits.max_response_bytes { truncate_string_to_boundary(&mut response.body, limits.max_response_bytes); response.truncated = true; } if response.headers.len() > PLUGIN_HTTPS_MAX_RESPONSE_HEADERS { response.headers.truncate(PLUGIN_HTTPS_MAX_RESPONSE_HEADERS); } for header in &mut response.headers { header.value = bounded_header_value(&header.value); } } fn truncate_string_to_boundary(value: &mut String, max_len: usize) { if value.len() <= max_len { return; } let mut boundary = max_len; while boundary > 0 && !value.is_char_boundary(boundary) { boundary -= 1; } value.truncate(boundary); } #[derive(Clone, Debug)] struct PluginFsResolvedPath { resolved: PathBuf, } #[derive(Clone, Debug)] struct PluginFsWritePath { resolved: PathBuf, lock_key: PathBuf, } fn authorize_fs_path( record: &ResolvedPluginRecord, operation: PluginFsRuntimeOperation, path: &str, ) -> Result { let relative = parse_plugin_fs_relative_path(path)?; let mut saw_operation_grant = false; let mut last_error: Option = None; for grant in &record.grants.fs { if !grant_allows_operation(grant, operation) { continue; } saw_operation_grant = true; match resolve_plugin_fs_grant_root(grant) .and_then(|root| resolve_existing_plugin_fs_path(&root, &relative, safe_fs_path(path))) { Ok(resolved) => return Ok(PluginFsResolvedPath { resolved }), Err(error) => last_error = Some(error), } } if !saw_operation_grant { return Err(PluginFsError::new(format!( "host_api.fs {} denied: no matching operation grant", operation.as_str() ))); } Err(last_error.unwrap_or_else(|| { PluginFsError::new(format!( "host_api.fs {} denied by scoped path policy", operation.as_str() )) })) } fn authorize_fs_write_path( record: &ResolvedPluginRecord, path: &str, ) -> Result { let relative = parse_plugin_fs_relative_path(path)?; if relative.as_os_str().is_empty() || relative.file_name().is_none() { return Err(PluginFsError::new("FS write path must name a file")); } let mut saw_operation_grant = false; let mut last_error: Option = None; for grant in &record.grants.fs { if !grant_allows_operation(grant, PluginFsRuntimeOperation::Write) { continue; } saw_operation_grant = true; match resolve_plugin_fs_grant_root(grant) .and_then(|root| resolve_writable_plugin_fs_path(&root, &relative, safe_fs_path(path))) { Ok(resolved) => return Ok(resolved), Err(error) => last_error = Some(error), } } if !saw_operation_grant { return Err(PluginFsError::new( "host_api.fs write denied: no matching operation grant", )); } Err(last_error .unwrap_or_else(|| PluginFsError::new("host_api.fs write denied by scoped path policy"))) } fn grant_allows_operation(grant: &PluginFsGrant, operation: PluginFsRuntimeOperation) -> bool { let requested = operation.grant_operation(); grant.operations.iter().any(|allowed| *allowed == requested) } fn parse_plugin_fs_relative_path(path: &str) -> Result { if path.as_bytes().len() > PLUGIN_FS_MAX_PATH_BYTES { return Err(PluginFsError::new(format!( "FS path exceeds {} bytes", PLUGIN_FS_MAX_PATH_BYTES ))); } if path.is_empty() { return Err(PluginFsError::new("FS path must not be empty")); } if path.contains('\0') { return Err(PluginFsError::new("FS path contains a NUL byte")); } let input = Path::new(path); if input.is_absolute() { return Err(PluginFsError::new( "FS request paths must be relative to a configured fs grant root", )); } let mut relative = PathBuf::new(); for component in input.components() { match component { Component::Normal(component) => relative.push(component), Component::CurDir => {} Component::ParentDir => { return Err(PluginFsError::new( "FS path traversal outside the grant root is denied", )); } Component::RootDir | Component::Prefix(_) => { return Err(PluginFsError::new( "FS request paths must be relative to a configured fs grant root", )); } } } Ok(relative) } fn resolve_plugin_fs_grant_root(grant: &PluginFsGrant) -> Result { let root = grant.root.trim(); if root.is_empty() || root.as_bytes().len() > PLUGIN_FS_MAX_PATH_BYTES { return Err(PluginFsError::new("configured fs grant root is invalid")); } let root_path = Path::new(root); if !root_path.is_absolute() { return Err(PluginFsError::new( "configured fs grant root must be an absolute path", )); } let root_meta = fs::symlink_metadata(root_path) .map_err(|_| PluginFsError::new("configured fs grant root is unavailable"))?; if root_meta.file_type().is_symlink() { return Err(PluginFsError::new( "configured fs grant root must not be a symlink", )); } let canonical = fs::canonicalize(root_path) .map_err(|_| PluginFsError::new("configured fs grant root is unavailable"))?; let metadata = fs::metadata(&canonical) .map_err(|_| PluginFsError::new("configured fs grant root is unavailable"))?; if !metadata.is_dir() { return Err(PluginFsError::new( "configured fs grant root must be a directory", )); } Ok(canonical) } fn resolve_existing_plugin_fs_path( root: &Path, relative: &Path, requested: String, ) -> Result { let target = root.join(relative); reject_symlink_components(root, relative, true, &requested)?; let canonical = fs::canonicalize(&target).map_err(|error| { PluginFsError::new(format!("FS path is unavailable for {requested}: {error}")) })?; if !canonical.starts_with(root) { return Err(PluginFsError::new(format!( "FS path escapes the configured grant root: {requested}" ))); } Ok(canonical) } fn resolve_writable_plugin_fs_path( root: &Path, relative: &Path, requested: String, ) -> Result { let Some(file_name) = relative.file_name() else { return Err(PluginFsError::new("FS write path must name a file")); }; let parent_relative = relative.parent().unwrap_or_else(|| Path::new("")); reject_symlink_components(root, parent_relative, true, &requested)?; let parent = fs::canonicalize(root.join(parent_relative)).map_err(|error| { PluginFsError::new(format!( "FS write parent is unavailable for {requested}: {error}" )) })?; if !parent.starts_with(root) { return Err(PluginFsError::new(format!( "FS write parent escapes the configured grant root: {requested}" ))); } let resolved = parent.join(file_name); if let Ok(meta) = fs::symlink_metadata(&resolved) { if meta.file_type().is_symlink() { return Err(PluginFsError::new(format!( "FS write target is a symlink: {requested}" ))); } if meta.is_dir() { return Err(PluginFsError::new(format!( "FS write target is a directory: {requested}" ))); } let canonical = fs::canonicalize(&resolved).map_err(|error| { PluginFsError::new(format!( "FS write target is unavailable for {requested}: {error}" )) })?; if !canonical.starts_with(root) { return Err(PluginFsError::new(format!( "FS write target escapes the configured grant root: {requested}" ))); } return Ok(PluginFsWritePath { resolved, lock_key: canonical, }); } if !resolved.starts_with(root) { return Err(PluginFsError::new(format!( "FS write target escapes the configured grant root: {requested}" ))); } Ok(PluginFsWritePath { lock_key: resolved.clone(), resolved, }) } fn reject_symlink_components( root: &Path, relative: &Path, include_leaf: bool, requested: &str, ) -> Result<(), PluginFsError> { let mut current = root.to_path_buf(); let components = relative.components().collect::>(); for (index, component) in components.iter().enumerate() { let Component::Normal(name) = component else { continue; }; current.push(name); if !include_leaf && index + 1 == components.len() { break; } let metadata = fs::symlink_metadata(¤t).map_err(|error| { PluginFsError::new(format!( "FS path component is unavailable for {requested}: {error}" )) })?; if metadata.file_type().is_symlink() { return Err(PluginFsError::new(format!( "FS path symlink escape is denied: {requested}" ))); } } Ok(()) } fn plugin_fs_write_lock(path: PathBuf) -> Arc> { static LOCKS: OnceLock>>>> = OnceLock::new(); let map = LOCKS.get_or_init(|| Mutex::new(HashMap::new())); let mut map = map.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); map.entry(path) .or_insert_with(|| Arc::new(Mutex::new(()))) .clone() } fn safe_fs_path(path: &str) -> String { let mut sanitized = redact_secret_like(path).replace('\0', " "); if sanitized.len() > 160 { let mut boundary = 160; while boundary > 0 && !sanitized.is_char_boundary(boundary) { boundary -= 1; } sanitized.truncate(boundary); sanitized.push('…'); } if sanitized.is_empty() { ".".to_string() } else { sanitized } } fn validate_plugin_https_request( record: &ResolvedPluginRecord, request: &PluginHttpsRequest, ) -> Result { let method = request.method.trim().to_ascii_uppercase(); if method != request.method || !PLUGIN_HTTPS_ALLOWED_METHODS.contains(&method.as_str()) { return Err(PluginHttpsError::new(format!( "HTTPS method `{}` is not allowed", request.method ))); } if request.headers.len() > PLUGIN_HTTPS_MAX_REQUEST_HEADERS { return Err(PluginHttpsError::new(format!( "HTTPS request has too many headers (max {})", PLUGIN_HTTPS_MAX_REQUEST_HEADERS ))); } for header in &request.headers { validate_https_header(header)?; } if let Some(body) = &request.body { if body.len() > PLUGIN_HTTPS_MAX_REQUEST_BODY_BYTES { return Err(PluginHttpsError::new(format!( "HTTPS request body exceeds {} bytes", PLUGIN_HTTPS_MAX_REQUEST_BODY_BYTES ))); } } let url = reqwest::Url::parse(&request.url) .map_err(|error| PluginHttpsError::new(format!("invalid HTTPS URL: {error}")))?; if url.scheme() != "https" { return Err(PluginHttpsError::new(format!( "unsupported URL scheme {:?}; only https is allowed", url.scheme() ))); } if url.host_str().is_none() { return Err(PluginHttpsError::new("HTTPS URL must include a host")); } if !url.username().is_empty() || url.password().is_some() { return Err(PluginHttpsError::new( "HTTPS URLs with embedded credentials are not allowed", )); } validate_static_https_target(&url)?; authorize_https_allowlist(record, &method, &url)?; Ok(url) } fn validate_https_header(header: &PluginHttpsHeader) -> Result<(), PluginHttpsError> { if header.name.is_empty() || header.name.len() > PLUGIN_HTTPS_MAX_HEADER_NAME_BYTES { return Err(PluginHttpsError::new( "HTTPS request header name is invalid", )); } if header.value.len() > PLUGIN_HTTPS_MAX_HEADER_VALUE_BYTES { return Err(PluginHttpsError::new(format!( "HTTPS request header `{}` exceeds {} bytes", header.name, PLUGIN_HTTPS_MAX_HEADER_VALUE_BYTES ))); } if is_sensitive_header(&header.name) { return Err(PluginHttpsError::new(format!( "HTTPS request header `{}` is credential-like and must be supplied by an explicit future secret-ref grant, not guest memory", header.name ))); } reqwest::header::HeaderName::from_bytes(header.name.as_bytes()).map_err(|_| { PluginHttpsError::new(format!( "invalid HTTPS request header name `{}`", header.name )) })?; reqwest::header::HeaderValue::from_str(&header.value).map_err(|_| { PluginHttpsError::new(format!( "invalid HTTPS request header value for `{}`", header.name )) })?; Ok(()) } fn authorize_https_allowlist( record: &ResolvedPluginRecord, method: &str, url: &reqwest::Url, ) -> Result<(), PluginHttpsError> { let host = canonical_host(url)?; let path = url.path(); let allowed = record.grants.https.iter().any(|grant| { canonical_grant_host(&grant.host).as_deref() == Some(host.as_str()) && grant .methods .iter() .any(|allowed_method| allowed_method.eq_ignore_ascii_case(method)) && (grant.path_prefixes.is_empty() || grant .path_prefixes .iter() .any(|prefix| !prefix.is_empty() && path.starts_with(prefix))) }); if allowed { return Ok(()); } Err(PluginHttpsError::new(format!( "HTTPS request {} {} is not covered by host/method/path grants", method, safe_url(url) ))) } fn canonical_grant_host(host: &str) -> Option { let value = normalize_host_literal(host.trim()); if value.is_empty() { None } else { Some(value) } } fn normalize_host_literal(host: &str) -> String { host.trim_end_matches('.') .trim_start_matches('[') .trim_end_matches(']') .to_ascii_lowercase() } fn has_usable_https_grant(record: &ResolvedPluginRecord) -> bool { record.grants.https.iter().any(|grant| { canonical_grant_host(&grant.host).is_some() && grant.methods.iter().any(|method| { let method = method.trim().to_ascii_uppercase(); PLUGIN_HTTPS_ALLOWED_METHODS.contains(&method.as_str()) }) }) } fn has_usable_fs_grant(record: &ResolvedPluginRecord) -> bool { record.grants.fs.iter().any(|grant| { !grant.root.trim().is_empty() && Path::new(&grant.root).is_absolute() && !grant.operations.is_empty() }) } fn canonical_host(url: &reqwest::Url) -> Result { url.host_str() .map(normalize_host_literal) .filter(|host| !host.is_empty()) .ok_or_else(|| PluginHttpsError::new("HTTPS URL must include a host")) } fn validate_static_https_target(url: &reqwest::Url) -> Result<(), PluginHttpsError> { let host = canonical_host(url)?; if is_forbidden_host_name(&host) { return Err(PluginHttpsError::new(format!( "HTTPS blocked local/private host {:?}", host ))); } if let Ok(ip) = host.parse::() { validate_public_ip(ip, &host)?; } if url.cannot_be_a_base() { return Err(PluginHttpsError::new( "HTTPS URL target is not hierarchical", )); } Ok(()) } fn resolve_https_target_for_client( url: &reqwest::Url, resolver: &dyn PluginHttpsResolver, ) -> Result, PluginHttpsError> { let host = canonical_host(url)?; if host.parse::().is_ok() { return Ok(None); } let port = url .port_or_known_default() .ok_or_else(|| PluginHttpsError::new("HTTPS URL uses a scheme without a default port"))?; let addrs = resolver.resolve(&host, port)?; if addrs.is_empty() { return Err(PluginHttpsError::new(format!( "DNS lookup for {:?} returned no addresses", host ))); } for addr in &addrs { validate_public_ip(addr.ip(), &host)?; } let mut domains = Vec::new(); if let Some(raw_host) = url.host_str() { let raw_host = raw_host .trim_start_matches('[') .trim_end_matches(']') .to_ascii_lowercase(); if !raw_host.is_empty() { domains.push(raw_host); } } if !domains.contains(&host) { domains.push(host); } Ok(Some(PinnedHttpsResolution { domains, addrs })) } fn validate_public_ip(ip: IpAddr, host: &str) -> Result<(), PluginHttpsError> { let forbidden = match ip { IpAddr::V4(ip) => is_forbidden_ipv4(ip), IpAddr::V6(ip) => is_forbidden_ipv6(ip), }; if forbidden { return Err(PluginHttpsError::new(format!( "HTTPS blocked local/private address {ip} for host {:?}", host ))); } Ok(()) } fn is_forbidden_host_name(host: &str) -> bool { let lower = host.trim_end_matches('.').to_ascii_lowercase(); lower == "localhost" || lower.ends_with(".localhost") } fn is_forbidden_ipv4(ip: Ipv4Addr) -> bool { ip.is_private() || ip.is_loopback() || ip.is_link_local() || ip.is_broadcast() || ip.is_documentation() || ip.is_unspecified() || ip.octets()[0] == 0 || ip.octets()[0] >= 224 || ip.octets()[0] == 100 && (64..=127).contains(&ip.octets()[1]) || ip.octets()[0] == 169 && ip.octets()[1] == 254 || ip.octets()[0] == 192 && ip.octets()[1] == 0 && ip.octets()[2] == 0 || ip.octets()[0] == 198 && (18..=19).contains(&ip.octets()[1]) } fn is_forbidden_ipv6(ip: Ipv6Addr) -> bool { if let Some(mapped) = ipv6_embedded_ipv4(ip) { return is_forbidden_ipv4(mapped); } ip.is_loopback() || ip.is_unspecified() || (ip.segments()[0] & 0xfe00) == 0xfc00 || (ip.segments()[0] & 0xffc0) == 0xfe80 || (ip.segments()[0] & 0xff00) == 0xff00 } fn ipv6_embedded_ipv4(ip: Ipv6Addr) -> Option { if let Some(mapped) = ip.to_ipv4_mapped() { return Some(mapped); } let segments = ip.segments(); if segments[..6] == [0, 0, 0, 0, 0, 0] { return Some(Ipv4Addr::new( (segments[6] >> 8) as u8, segments[6] as u8, (segments[7] >> 8) as u8, segments[7] as u8, )); } None } fn collect_https_response_headers(headers: &reqwest::header::HeaderMap) -> Vec { headers .iter() .filter(|(name, _)| !is_sensitive_header(name.as_str())) .take(PLUGIN_HTTPS_MAX_RESPONSE_HEADERS) .filter_map(|(name, value)| { value.to_str().ok().map(|value| PluginHttpsHeader { name: name.as_str().to_string(), value: bounded_header_value(value), }) }) .collect() } fn bounded_header_value(value: &str) -> String { let mut redacted = redact_secret_like(value); if redacted.len() > PLUGIN_HTTPS_MAX_HEADER_VALUE_BYTES { truncate_string_to_boundary(&mut redacted, PLUGIN_HTTPS_MAX_HEADER_VALUE_BYTES); redacted.push('…'); } redacted } fn is_sensitive_header(name: &str) -> bool { matches!( name.to_ascii_lowercase().as_str(), "authorization" | "proxy-authorization" | "cookie" | "set-cookie" | "x-api-key" | "x-auth-token" | "api-key" | "apikey" ) } fn safe_url(url: &reqwest::Url) -> String { let host = url.host_str().unwrap_or(""); let mut path = url.path().to_string(); if path.len() > 120 { truncate_string_to_boundary(&mut path, 120); path.push('…'); } match url.port() { Some(port) => format!("https://{host}:{port}{path}"), None => format!("https://{host}{path}"), } } fn redact_secret_like(message: &str) -> String { let mut value = message.to_string(); for needle in [ "authorization", "proxy-authorization", "cookie", "set-cookie", "x-api-key", "x-auth-token", "api-key", "token", "secret", "password", ] { value = redact_after_secret_word(&value, needle); } value } fn redact_after_secret_word(input: &str, needle: &str) -> String { let lower = input.to_ascii_lowercase(); let mut out = String::new(); let mut cursor = 0usize; while let Some(relative) = lower[cursor..].find(needle) { let start = cursor + relative; let mut end = start + needle.len(); out.push_str(&input[cursor..end]); let bytes = input.as_bytes(); while end < input.len() && matches!(bytes[end], b' ' | b'=' | b':' | b'\t') { out.push(bytes[end] as char); end += 1; } let secret_start = end; while end < input.len() && !matches!(bytes[end], b' ' | b',' | b';' | b'\n' | b'\r') { end += 1; } if end > secret_start { out.push_str(PLUGIN_HTTPS_REDACTION); } cursor = end; } out.push_str(&input[cursor..]); out } #[derive(Debug)] struct PluginPermissionError(String); impl PluginPermissionError { fn bounded_message(&self) -> String { bounded_message(&self.0) } } fn authorize_plugin_tool( record: &ResolvedPluginRecord, tool: &PluginToolManifest, ) -> Result<(), PluginPermissionError> { validate_grant_binding(record)?; require_permission( &record.manifest.permissions, &PluginPermission::surface(PluginSurface::Tool), "requested surfaces.tool permission is missing", )?; require_permission( &record.grants.permissions, &PluginPermission::surface(PluginSurface::Tool), "granted surfaces.tool permission is missing", )?; if !permission_allows_tool(&record.manifest.permissions, &tool.name) { return Err(PluginPermissionError(format!( "requested tool permission for `{}` is missing", tool.name ))); } if !permission_allows_tool(&record.grants.permissions, &tool.name) { return Err(PluginPermissionError(format!( "granted tool permission for `{}` is missing", tool.name ))); } if tool.external_write { require_permission( &record.manifest.permissions, &PluginPermission::ExternalWrite, "requested external_write permission is missing", )?; require_permission( &record.grants.permissions, &PluginPermission::ExternalWrite, "granted external_write permission is missing", )?; } Ok(()) } fn authorize_plugin_host_api( record: &ResolvedPluginRecord, api: PluginHostApi, ) -> Result<(), PluginPermissionError> { validate_grant_binding(record)?; let permission = PluginPermission::host_api(api); require_permission( &record.manifest.permissions, &permission, &format!("requested host_api.{api} permission is missing"), )?; require_permission( &record.grants.permissions, &permission, &format!("granted host_api.{api} permission is missing"), )?; match api { PluginHostApi::Https => { if !has_usable_https_grant(record) { return Err(PluginPermissionError( "granted host_api.https allowlist is missing".to_string(), )); } Ok(()) } PluginHostApi::Fs => { if !has_usable_fs_grant(record) { return Err(PluginPermissionError( "granted host_api.fs scope allowlist is missing".to_string(), )); } Ok(()) } } } fn validate_grant_binding(record: &ResolvedPluginRecord) -> Result<(), PluginPermissionError> { if let Some(message) = record .grants .binding_error(&record.identity, &record.digest, &record.manifest.version) { return Err(PluginPermissionError(message.to_string())); } Ok(()) } fn require_permission( permissions: &[PluginPermission], expected: &PluginPermission, missing_message: &str, ) -> Result<(), PluginPermissionError> { if permissions.iter().any(|permission| permission == expected) { return Ok(()); } Err(PluginPermissionError(missing_message.to_string())) } fn permission_allows_tool(permissions: &[PluginPermission], tool_name: &str) -> bool { permissions.iter().any(|permission| match permission { PluginPermission::Tool { name } => name == tool_name, PluginPermission::ToolNamespace { namespace } => { !namespace.is_empty() && tool_name.starts_with(namespace) } _ => false, }) } const PLUGIN_WASM_HOST_MODULE: &str = "yoi:tool"; const PLUGIN_WASM_ENTRYPOINT: &str = "yoi_tool_call"; const PLUGIN_WASM_MAX_INPUT_BYTES: usize = 64 * 1024; const PLUGIN_WASM_MAX_OUTPUT_BYTES: usize = 64 * 1024; const PLUGIN_WASM_MAX_SUMMARY_BYTES: usize = 1024; const PLUGIN_WASM_FUEL: u64 = 5_000_000; const PLUGIN_WASM_TIMEOUT: Duration = Duration::from_secs(1); const PLUGIN_WASM_MEMORY_BYTES: usize = 2 * 1024 * 1024; const PLUGIN_WASM_TABLE_ELEMENTS: usize = 256; const PLUGIN_WASM_HTTPS_MODULE: &str = "yoi:https"; const PLUGIN_WASM_FS_MODULE: &str = "yoi:fs"; const PLUGIN_HTTPS_MAX_REQUEST_BYTES: usize = 48 * 1024; const PLUGIN_HTTPS_MAX_REQUEST_BODY_BYTES: usize = 32 * 1024; const PLUGIN_HTTPS_MAX_REQUEST_HEADERS: usize = 16; const PLUGIN_HTTPS_MAX_RESPONSE_HEADERS: usize = 16; const PLUGIN_HTTPS_MAX_HEADER_NAME_BYTES: usize = 64; const PLUGIN_HTTPS_MAX_HEADER_VALUE_BYTES: usize = 1024; const PLUGIN_HTTPS_MAX_RESPONSE_BYTES: usize = 64 * 1024; const PLUGIN_HTTPS_TIMEOUT: Duration = Duration::from_secs(5); const PLUGIN_HTTPS_ALLOWED_METHODS: &[&str] = &["GET", "POST", "PUT", "PATCH", "DELETE"]; const PLUGIN_HTTPS_REDACTION: &str = ""; const PLUGIN_FS_MAX_REQUEST_BYTES: usize = 64 * 1024; const PLUGIN_FS_MAX_PATH_BYTES: usize = 4096; const PLUGIN_FS_MAX_READ_BYTES: usize = 64 * 1024; const PLUGIN_FS_MAX_WRITE_BYTES: usize = 64 * 1024; const PLUGIN_FS_MAX_LIST_ENTRIES: usize = 256; fn wasm_component_store_limits() -> wasmtime::StoreLimits { wasmtime::StoreLimitsBuilder::new() .memory_size(PLUGIN_WASM_MEMORY_BYTES) .table_elements(PLUGIN_WASM_TABLE_ELEMENTS) .instances(1) .tables(1) .memories(1) .trap_on_grow_failure(true) .build() } #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum PluginFsRuntimeOperation { Read, List, Write, } impl PluginFsRuntimeOperation { fn grant_operation(self) -> PluginFsOperation { match self { Self::Read => PluginFsOperation::Read, Self::List => PluginFsOperation::List, Self::Write => PluginFsOperation::Write, } } fn as_str(self) -> &'static str { match self { Self::Read => "read", Self::List => "list", Self::Write => "write", } } } #[derive(Clone, Debug, Deserialize)] #[serde(deny_unknown_fields)] struct PluginFsPathRequest { path: String, } #[derive(Clone, Debug, Deserialize)] #[serde(deny_unknown_fields)] struct PluginFsWriteRequest { path: String, content: String, } #[derive(Clone, Debug, Serialize)] struct PluginFsReadResponse { path: String, content: String, truncated: bool, } #[derive(Clone, Debug, Serialize)] struct PluginFsListResponse { path: String, entries: Vec, truncated: bool, } #[derive(Clone, Debug, Serialize)] struct PluginFsDirEntry { name: String, kind: String, } #[derive(Clone, Debug, Serialize)] struct PluginFsWriteResponse { path: String, bytes_written: usize, } #[derive(Debug)] struct PluginFsError { message: String, } impl PluginFsError { fn new(message: impl Into) -> Self { let message = message.into(); Self { message: bounded_message(redact_secret_like(&message)), } } } impl std::fmt::Display for PluginFsError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(&self.message) } } impl std::error::Error for PluginFsError {} #[derive(Clone, Debug, Deserialize)] #[serde(deny_unknown_fields)] struct PluginHttpsRequest { method: String, url: String, #[serde(default)] headers: Vec, #[serde(default)] body: Option, } #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(deny_unknown_fields)] struct PluginHttpsHeader { name: String, value: String, } #[derive(Clone, Debug, Serialize)] struct PluginHttpsResponse { status: u16, headers: Vec, body: String, truncated: bool, } #[derive(Clone, Copy, Debug)] struct PluginHttpsLimits { timeout: Duration, max_response_bytes: usize, } impl Default for PluginHttpsLimits { fn default() -> Self { Self { timeout: PLUGIN_HTTPS_TIMEOUT, max_response_bytes: PLUGIN_HTTPS_MAX_RESPONSE_BYTES, } } } trait PluginHttpsClient: Send + Sync { fn execute( &self, request: &PluginHttpsRequest, url: &reqwest::Url, limits: PluginHttpsLimits, ) -> Result; } struct ReqwestPluginHttpsClient; struct SystemPluginHttpsResolver; #[derive(Clone, Debug)] struct PinnedHttpsResolution { domains: Vec, addrs: Vec, } trait PluginHttpsResolver { fn resolve(&self, host: &str, port: u16) -> Result, PluginHttpsError>; } impl PluginHttpsResolver for SystemPluginHttpsResolver { fn resolve(&self, host: &str, port: u16) -> Result, PluginHttpsError> { let mut addrs = Vec::new(); for addr in (host, port).to_socket_addrs().map_err(|error| { PluginHttpsError::new(format!("DNS lookup failed for {:?}: {error}", host)) })? { addrs.push(addr); } Ok(addrs) } } #[derive(Debug)] struct PluginHttpsError(String); impl PluginHttpsError { fn new(message: impl Into) -> Self { Self(redact_secret_like(&bounded_message(message.into()))) } } fn plugin_wasm_tool_definition( record: ResolvedPluginRecord, name: String, description: String, input_schema: Value, origin: ToolOrigin, ) -> ToolDefinition { Arc::new(move || { ( ToolMeta::new(name.clone()) .description(description.clone()) .input_schema(input_schema.clone()) .origin(origin.clone()), Arc::new(PluginWasmTool { record: record.clone(), name: name.clone(), origin: origin.clone(), }) as Arc, ) }) } struct PluginWasmTool { record: ResolvedPluginRecord, name: String, origin: ToolOrigin, } #[async_trait] impl Tool for PluginWasmTool { async fn execute( &self, input_json: &str, _ctx: ToolExecutionContext, ) -> Result { if input_json.len() > PLUGIN_WASM_MAX_INPUT_BYTES { return Err(ToolError::InvalidArgument(format!( "plugin tool `{}` input exceeds {} bytes", self.name, PLUGIN_WASM_MAX_INPUT_BYTES ))); } serde_json::from_str::(input_json).map_err(|error| { ToolError::InvalidArgument(format!( "plugin tool `{}` input is not valid JSON: {}", self.name, bounded_message(error.to_string()) )) })?; let record = self.record.clone(); let name = self.name.clone(); let plugin_ref = self.origin.plugin_ref.clone(); let digest = self.origin.digest.clone(); let input = input_json.as_bytes().to_vec(); let execution = tokio::task::spawn_blocking(move || run_plugin_tool(record, name, input)); match tokio::time::timeout(PLUGIN_WASM_TIMEOUT, execution).await { Ok(Ok(Ok(output))) => Ok(output), Ok(Ok(Err(error))) => Err(ToolError::ExecutionFailed(format!( "plugin tool `{}` from `{}` (digest {}) failed closed: {}", self.name, plugin_ref, digest, error.bounded_message() ))), Ok(Err(error)) => Err(ToolError::ExecutionFailed(format!( "plugin tool `{}` from `{}` (digest {}) cancelled/failed to join: {}", self.name, plugin_ref, digest, bounded_message(error.to_string()) ))), Err(_) => Err(ToolError::ExecutionFailed(format!( "plugin tool `{}` from `{}` (digest {}) timed out after {:?}", self.name, plugin_ref, digest, PLUGIN_WASM_TIMEOUT ))), } } } #[derive(Debug)] enum PluginWasmError { Package(String), Module(String), Execution(String), Output(String), } impl PluginWasmError { fn bounded_message(&self) -> String { match self { Self::Package(message) => { bounded_message(format!("package/module load error: {message}")) } Self::Module(message) => bounded_message(format!("WASM module error: {message}")), Self::Execution(message) => bounded_message(format!("WASM execution error: {message}")), Self::Output(message) => bounded_message(format!("WASM output error: {message}")), } } } struct PluginWasmHostState { record: ResolvedPluginRecord, https_client: Arc, tool_name: Vec, input: Vec, output: Vec, output_error: Option, https_response: Vec, fs_response: Vec, store_limits: wasmi::StoreLimits, } fn run_plugin_tool( record: ResolvedPluginRecord, tool_name: String, input: Vec, ) -> Result { match record .manifest .runtime .as_ref() .map(|runtime| runtime.kind.as_str()) { Some(PLUGIN_RUNTIME_WASM_KIND) => run_plugin_wasm_tool(record, tool_name, input), Some(PLUGIN_RUNTIME_COMPONENT_KIND) => run_plugin_component_tool(record, tool_name, input), Some(other) => Err(PluginWasmError::Module(format!( "unsupported plugin runtime kind `{other}`" ))), None => Err(PluginWasmError::Package( "plugin runtime is not declared".to_string(), )), } } fn run_plugin_wasm_tool( record: ResolvedPluginRecord, tool_name: String, input: Vec, ) -> Result { run_plugin_wasm_tool_with_https_client( record, tool_name, input, Arc::new(ReqwestPluginHttpsClient), ) } fn run_plugin_wasm_tool_with_https_client( record: ResolvedPluginRecord, tool_name: String, input: Vec, https_client: Arc, ) -> Result { let tool = record .manifest .tools .iter() .find(|tool| tool.name == tool_name) .ok_or_else(|| { PluginWasmError::Module("requested tool is not declared by plugin manifest".to_string()) })?; authorize_plugin_tool(&record, tool).map_err(|error| { PluginWasmError::Module(format!( "plugin permission denied: {}", error.bounded_message() )) })?; let limits = PluginDiscoveryLimits::default(); let module_bytes = read_resolved_plugin_runtime_module(&record, &limits) .map_err(|diagnostic| PluginWasmError::Package(diagnostic.message))?; if module_bytes.len() > limits.max_file_size_bytes as usize { return Err(PluginWasmError::Package(format!( "WASM runtime module exceeds {} bytes", limits.max_file_size_bytes ))); } let mut config = wasmi::Config::default(); config.consume_fuel(true); config.set_max_recursion_depth(64); config.set_max_stack_height(8 * 1024 * 1024); let engine = wasmi::Engine::new(&config); let module = wasmi::Module::new(&engine, &module_bytes[..]) .map_err(|error| PluginWasmError::Module(error.to_string()))?; validate_wasm_imports(&record, &module)?; let store_limits = wasmi::StoreLimitsBuilder::new() .memory_size(PLUGIN_WASM_MEMORY_BYTES) .table_elements(PLUGIN_WASM_TABLE_ELEMENTS) .instances(1) .tables(1) .memories(1) .trap_on_grow_failure(true) .build(); let mut store = wasmi::Store::new( &engine, PluginWasmHostState { record: record.clone(), https_client, tool_name: tool_name.into_bytes(), input, output: Vec::new(), output_error: None, https_response: Vec::new(), fs_response: Vec::new(), store_limits, }, ); store.limiter(|state| &mut state.store_limits); store .set_fuel(PLUGIN_WASM_FUEL) .map_err(|error| PluginWasmError::Execution(error.to_string()))?; let mut linker = wasmi::Linker::::new(&engine); define_plugin_wasm_host_imports(&mut linker)?; let instance = linker .instantiate_and_start(&mut store, &module) .map_err(|error| PluginWasmError::Execution(error.to_string()))?; let entry = instance .get_typed_func::<(), ()>(&store, PLUGIN_WASM_ENTRYPOINT) .map_err(|error| PluginWasmError::Module(error.to_string()))?; entry .call(&mut store, ()) .map_err(|error| PluginWasmError::Execution(error.to_string()))?; if let Some(error) = store.data().output_error.clone() { return Err(PluginWasmError::Output(error)); } decode_plugin_wasm_output(&store.data().output) } #[derive(Clone)] struct PluginComponentHostState { record: ResolvedPluginRecord, https_client: Arc, store_limits: wasmtime::StoreLimits, } fn run_plugin_component_tool( record: ResolvedPluginRecord, tool_name: String, input: Vec, ) -> Result { run_plugin_component_tool_with_https_client( record, tool_name, input, Arc::new(ReqwestPluginHttpsClient), ) } fn run_plugin_component_tool_with_https_client( record: ResolvedPluginRecord, tool_name: String, input: Vec, https_client: Arc, ) -> Result { let tool = record .manifest .tools .iter() .find(|tool| tool.name == tool_name) .ok_or_else(|| { PluginWasmError::Module("requested tool is not declared by plugin manifest".to_string()) })?; authorize_plugin_tool(&record, tool).map_err(|error| { PluginWasmError::Module(format!( "plugin permission denied: {}", error.bounded_message() )) })?; let limits = PluginDiscoveryLimits::default(); let component_bytes = read_resolved_plugin_runtime_component(&record, &limits) .map_err(|diagnostic| PluginWasmError::Package(diagnostic.message))?; if component_bytes.len() > limits.max_file_size_bytes as usize { return Err(PluginWasmError::Package(format!( "WASM component runtime artifact exceeds {} bytes", limits.max_file_size_bytes ))); } let mut config = wasmtime::Config::new(); config.wasm_component_model(true); config.consume_fuel(true); config.max_wasm_stack(8 * 1024 * 1024); let engine = wasmtime::Engine::new(&config) .map_err(|error| PluginWasmError::Module(error.to_string()))?; let component = wasmtime::component::Component::new(&engine, &component_bytes).map_err(|error| { PluginWasmError::Module(format!("component is incompatible: {error:?}")) })?; validate_component_imports(&record, &engine, &component)?; let mut linker = wasmtime::component::Linker::::new(&engine); define_plugin_component_host_imports(&mut linker)?; let mut store = wasmtime::Store::new( &engine, PluginComponentHostState { record: record.clone(), https_client, store_limits: wasm_component_store_limits(), }, ); store.limiter(|state| &mut state.store_limits); store .set_fuel(PLUGIN_WASM_FUEL) .map_err(|error| PluginWasmError::Execution(error.to_string()))?; let instance = linker .instantiate(&mut store, &component) .map_err(|error| PluginWasmError::Execution(error.to_string()))?; let call = instance .get_typed_func::<(&str, &str), (String,)>(&mut store, "call") .map_err(|error| { PluginWasmError::Module(format!( "component does not export expected `{}` call function: {error}", PLUGIN_COMPONENT_TOOL_WORLD )) })?; let input_json = std::str::from_utf8(&input).map_err(|error| { PluginWasmError::Output(format!("plugin component input is not UTF-8: {error}")) })?; // Wasmtime lifts the returned WIT `string` into a host `String` before the // ordinary ToolOutput JSON cap can be applied. Keep the component store on // the same memory/table/instance limits as the raw WASM runtime so an // untrusted component can only force host string allocation from bounded // component memory; oversized memories/tables/instances fail closed during // instantiation/growth before this lift succeeds. let (output,) = call .call(&mut store, (&tool_name, input_json)) .map_err(|error| PluginWasmError::Execution(error.to_string()))?; decode_plugin_wasm_output(output.as_bytes()) } fn validate_component_imports( record: &ResolvedPluginRecord, engine: &wasmtime::Engine, component: &wasmtime::component::Component, ) -> Result<(), PluginWasmError> { for (name, _) in component.component_type().imports(engine) { match name { "yoi:host/https@1.0.0" => { authorize_plugin_host_api(record, PluginHostApi::Https).map_err(|error| { PluginWasmError::Module(format!( "plugin host API dispatch denied: {}", error.bounded_message() )) })?; } "yoi:host/fs@1.0.0" => { authorize_plugin_host_api(record, PluginHostApi::Fs).map_err(|error| { PluginWasmError::Module(format!( "plugin host API dispatch denied: {}", error.bounded_message() )) })?; } other => { return Err(PluginWasmError::Module(format!( "unsupported component import `{other}`; no WASI filesystem, ambient network, environment, or other imports are available" ))); } } } Ok(()) } fn define_plugin_component_host_imports( linker: &mut wasmtime::component::Linker, ) -> Result<(), PluginWasmError> { linker .root() .instance("yoi:host/https@1.0.0") .map_err(|error| PluginWasmError::Module(error.to_string()))? .func_wrap( "request", |store: wasmtime::StoreContextMut<'_, PluginComponentHostState>, (request,): (String,)| -> wasmtime::Result<(String,)> { authorize_plugin_host_api(&store.data().record, PluginHostApi::Https) .map_err(|error| wasmtime::Error::msg(error.bounded_message()))?; let response = execute_plugin_https_request( &store.data().record, store.data().https_client.as_ref(), request.as_bytes(), ) .map_err(|error| wasmtime::Error::msg(error.0))?; Ok((String::from_utf8_lossy(&response).into_owned(),)) }, ) .map_err(|error| PluginWasmError::Module(error.to_string()))?; let mut root = linker.root(); let mut fs = root .instance("yoi:host/fs@1.0.0") .map_err(|error| PluginWasmError::Module(error.to_string()))?; fs.func_wrap( "read", |store: wasmtime::StoreContextMut<'_, PluginComponentHostState>, (request,): (String,)| -> wasmtime::Result<(String,)> { authorize_plugin_host_api(&store.data().record, PluginHostApi::Fs) .map_err(|error| wasmtime::Error::msg(error.bounded_message()))?; execute_plugin_fs_request( &store.data().record, PluginFsRuntimeOperation::Read, request.as_bytes(), ) .map(|bytes| (String::from_utf8_lossy(&bytes).into_owned(),)) .map_err(|error| wasmtime::Error::msg(error.message)) }, ) .map_err(|error| PluginWasmError::Module(error.to_string()))?; fs.func_wrap( "list", |store: wasmtime::StoreContextMut<'_, PluginComponentHostState>, (request,): (String,)| -> wasmtime::Result<(String,)> { authorize_plugin_host_api(&store.data().record, PluginHostApi::Fs) .map_err(|error| wasmtime::Error::msg(error.bounded_message()))?; execute_plugin_fs_request( &store.data().record, PluginFsRuntimeOperation::List, request.as_bytes(), ) .map(|bytes| (String::from_utf8_lossy(&bytes).into_owned(),)) .map_err(|error| wasmtime::Error::msg(error.message)) }, ) .map_err(|error| PluginWasmError::Module(error.to_string()))?; fs.func_wrap( "write", |store: wasmtime::StoreContextMut<'_, PluginComponentHostState>, (request,): (String,)| -> wasmtime::Result<(String,)> { authorize_plugin_host_api(&store.data().record, PluginHostApi::Fs) .map_err(|error| wasmtime::Error::msg(error.bounded_message()))?; execute_plugin_fs_request( &store.data().record, PluginFsRuntimeOperation::Write, request.as_bytes(), ) .map(|bytes| (String::from_utf8_lossy(&bytes).into_owned(),)) .map_err(|error| wasmtime::Error::msg(error.message)) }, ) .map_err(|error| PluginWasmError::Module(error.to_string()))?; Ok(()) } fn validate_wasm_imports( record: &ResolvedPluginRecord, module: &wasmi::Module, ) -> Result<(), PluginWasmError> { for import in module.imports() { match import.module() { PLUGIN_WASM_HOST_MODULE => match import.name() { "tool_name_len" | "tool_name_read" | "input_len" | "input_read" | "output_write" => {} other => { return Err(PluginWasmError::Module(format!( "unsupported host import `{}`; no filesystem, ambient network, environment, or WASI imports are available", other ))); } }, PLUGIN_WASM_HTTPS_MODULE => { authorize_plugin_host_api(record, PluginHostApi::Https).map_err(|error| { PluginWasmError::Module(format!( "plugin host API dispatch denied: {}", error.bounded_message() )) })?; match import.name() { "request" | "response_len" | "response_read" => {} other => { return Err(PluginWasmError::Module(format!( "unsupported https host import `{other}`" ))); } } } PLUGIN_WASM_FS_MODULE => { authorize_plugin_host_api(record, PluginHostApi::Fs).map_err(|error| { PluginWasmError::Module(format!( "plugin host API dispatch denied: {}", error.bounded_message() )) })?; match import.name() { "read" | "list" | "write" | "response_len" | "response_read" => {} other => { return Err(PluginWasmError::Module(format!( "unsupported fs host import `{other}`" ))); } } } other => { return Err(PluginWasmError::Module(format!( "unsupported import module `{}`; only `{}`, `{}`, and `{}` are available", other, PLUGIN_WASM_HOST_MODULE, PLUGIN_WASM_HTTPS_MODULE, PLUGIN_WASM_FS_MODULE ))); } } } Ok(()) } fn define_plugin_wasm_host_imports( linker: &mut wasmi::Linker, ) -> Result<(), PluginWasmError> { linker .func_wrap( PLUGIN_WASM_HOST_MODULE, "tool_name_len", |caller: wasmi::Caller<'_, PluginWasmHostState>| -> i32 { caller.data().tool_name.len() as i32 }, ) .map_err(|error| PluginWasmError::Module(error.to_string()))?; linker .func_wrap( PLUGIN_WASM_HOST_MODULE, "input_len", |caller: wasmi::Caller<'_, PluginWasmHostState>| -> i32 { caller.data().input.len() as i32 }, ) .map_err(|error| PluginWasmError::Module(error.to_string()))?; linker .func_wrap( PLUGIN_WASM_HOST_MODULE, "tool_name_read", |mut caller: wasmi::Caller<'_, PluginWasmHostState>, ptr: i32, len: i32| -> i32 { write_host_bytes_to_guest(&mut caller, ptr, len, HostBuffer::ToolName) }, ) .map_err(|error| PluginWasmError::Module(error.to_string()))?; linker .func_wrap( PLUGIN_WASM_HOST_MODULE, "input_read", |mut caller: wasmi::Caller<'_, PluginWasmHostState>, ptr: i32, len: i32| -> i32 { write_host_bytes_to_guest(&mut caller, ptr, len, HostBuffer::Input) }, ) .map_err(|error| PluginWasmError::Module(error.to_string()))?; linker .func_wrap( PLUGIN_WASM_HOST_MODULE, "output_write", |mut caller: wasmi::Caller<'_, PluginWasmHostState>, ptr: i32, len: i32| -> i32 { read_guest_output(&mut caller, ptr, len) }, ) .map_err(|error| PluginWasmError::Module(error.to_string()))?; linker .func_wrap( PLUGIN_WASM_HTTPS_MODULE, "request", |mut caller: wasmi::Caller<'_, PluginWasmHostState>, ptr: i32, len: i32| -> i32 { read_guest_https_request(&mut caller, ptr, len) }, ) .map_err(|error| PluginWasmError::Module(error.to_string()))?; linker .func_wrap( PLUGIN_WASM_HTTPS_MODULE, "response_len", |caller: wasmi::Caller<'_, PluginWasmHostState>| -> i32 { caller.data().https_response.len() as i32 }, ) .map_err(|error| PluginWasmError::Module(error.to_string()))?; linker .func_wrap( PLUGIN_WASM_HTTPS_MODULE, "response_read", |mut caller: wasmi::Caller<'_, PluginWasmHostState>, ptr: i32, len: i32| -> i32 { write_host_bytes_to_guest(&mut caller, ptr, len, HostBuffer::HttpsResponse) }, ) .map_err(|error| PluginWasmError::Module(error.to_string()))?; linker .func_wrap( PLUGIN_WASM_FS_MODULE, "read", |mut caller: wasmi::Caller<'_, PluginWasmHostState>, ptr: i32, len: i32| -> i32 { read_guest_fs_request(&mut caller, ptr, len, PluginFsRuntimeOperation::Read) }, ) .map_err(|error| PluginWasmError::Module(error.to_string()))?; linker .func_wrap( PLUGIN_WASM_FS_MODULE, "list", |mut caller: wasmi::Caller<'_, PluginWasmHostState>, ptr: i32, len: i32| -> i32 { read_guest_fs_request(&mut caller, ptr, len, PluginFsRuntimeOperation::List) }, ) .map_err(|error| PluginWasmError::Module(error.to_string()))?; linker .func_wrap( PLUGIN_WASM_FS_MODULE, "write", |mut caller: wasmi::Caller<'_, PluginWasmHostState>, ptr: i32, len: i32| -> i32 { read_guest_fs_request(&mut caller, ptr, len, PluginFsRuntimeOperation::Write) }, ) .map_err(|error| PluginWasmError::Module(error.to_string()))?; linker .func_wrap( PLUGIN_WASM_FS_MODULE, "response_len", |caller: wasmi::Caller<'_, PluginWasmHostState>| -> i32 { caller.data().fs_response.len() as i32 }, ) .map_err(|error| PluginWasmError::Module(error.to_string()))?; linker .func_wrap( PLUGIN_WASM_FS_MODULE, "response_read", |mut caller: wasmi::Caller<'_, PluginWasmHostState>, ptr: i32, len: i32| -> i32 { write_host_bytes_to_guest(&mut caller, ptr, len, HostBuffer::FsResponse) }, ) .map_err(|error| PluginWasmError::Module(error.to_string()))?; Ok(()) } #[derive(Clone, Copy, Debug)] enum HostBuffer { ToolName, Input, HttpsResponse, FsResponse, } fn write_host_bytes_to_guest( caller: &mut wasmi::Caller<'_, PluginWasmHostState>, ptr: i32, len: i32, buffer: HostBuffer, ) -> i32 { if ptr < 0 || len < 0 { return -1; } let bytes = match buffer { HostBuffer::ToolName => caller.data().tool_name.clone(), HostBuffer::Input => caller.data().input.clone(), HostBuffer::HttpsResponse => caller.data().https_response.clone(), HostBuffer::FsResponse => caller.data().fs_response.clone(), }; if len as usize != bytes.len() { return -1; } let Some(memory) = caller .get_export("memory") .and_then(|export| export.into_memory()) else { return -1; }; match memory.write(caller, ptr as usize, &bytes) { Ok(()) => bytes.len() as i32, Err(_) => -1, } } fn read_guest_https_request( caller: &mut wasmi::Caller<'_, PluginWasmHostState>, ptr: i32, len: i32, ) -> i32 { let bytes = match read_guest_bytes(caller, ptr, len, PLUGIN_HTTPS_MAX_REQUEST_BYTES) { Ok(bytes) => bytes, Err(error) => { caller.data_mut().output_error = Some(error); return -1; } }; let record = caller.data().record.clone(); let https_client = caller.data().https_client.clone(); match execute_plugin_https_request(&record, https_client.as_ref(), &bytes) { Ok(response) => { caller.data_mut().https_response = response; caller.data().https_response.len() as i32 } Err(error) => { caller.data_mut().output_error = Some(error.0); -1 } } } fn read_guest_fs_request( caller: &mut wasmi::Caller<'_, PluginWasmHostState>, ptr: i32, len: i32, operation: PluginFsRuntimeOperation, ) -> i32 { let bytes = match read_guest_bytes(caller, ptr, len, PLUGIN_FS_MAX_REQUEST_BYTES) { Ok(bytes) => bytes, Err(error) => { caller.data_mut().output_error = Some(error); return -1; } }; let record = caller.data().record.clone(); match execute_plugin_fs_request(&record, operation, &bytes) { Ok(response) => { caller.data_mut().fs_response = response; caller.data().fs_response.len() as i32 } Err(error) => { caller.data_mut().output_error = Some(error.message); -1 } } } fn read_guest_bytes( caller: &mut wasmi::Caller<'_, PluginWasmHostState>, ptr: i32, len: i32, max_len: usize, ) -> Result, String> { if ptr < 0 || len < 0 { return Err("guest input pointer/length is invalid".into()); } let len = len as usize; if len > max_len { return Err(format!("guest input exceeds {max_len} bytes")); } let Some(memory) = caller .get_export("memory") .and_then(|export| export.into_memory()) else { return Err("guest did not export linear memory".into()); }; let mut bytes = vec![0; len]; memory .read(&*caller, ptr as usize, &mut bytes) .map_err(|_| "guest input memory range is invalid".to_string())?; Ok(bytes) } fn read_guest_output( caller: &mut wasmi::Caller<'_, PluginWasmHostState>, ptr: i32, len: i32, ) -> i32 { if ptr < 0 || len < 0 { caller.data_mut().output_error = Some("guest output pointer/length is invalid".into()); return -1; } let len = len as usize; if len > PLUGIN_WASM_MAX_OUTPUT_BYTES { caller.data_mut().output_error = Some(format!( "guest output exceeds {} bytes", PLUGIN_WASM_MAX_OUTPUT_BYTES )); return -1; } let Some(memory) = caller .get_export("memory") .and_then(|export| export.into_memory()) else { caller.data_mut().output_error = Some("guest did not export linear memory".into()); return -1; }; let mut output = vec![0; len]; if memory.read(&*caller, ptr as usize, &mut output).is_err() { caller.data_mut().output_error = Some("guest output memory range is invalid".into()); return -1; } caller.data_mut().output = output; len as i32 } fn decode_plugin_wasm_output(bytes: &[u8]) -> Result { if bytes.is_empty() { return Err(PluginWasmError::Output( "guest did not call output_write".into(), )); } if bytes.len() > PLUGIN_WASM_MAX_OUTPUT_BYTES { return Err(PluginWasmError::Output(format!( "guest output exceeds {} bytes", PLUGIN_WASM_MAX_OUTPUT_BYTES ))); } let text = std::str::from_utf8(bytes) .map_err(|error| PluginWasmError::Output(format!("guest output is not UTF-8: {error}")))?; let value: Value = serde_json::from_str(text).map_err(|error| { PluginWasmError::Output(format!("guest output is not valid JSON: {error}")) })?; let Value::Object(map) = value else { return Err(PluginWasmError::Output( "guest output JSON must be an object".into(), )); }; for key in map.keys() { if key != "summary" && key != "content" { return Err(PluginWasmError::Output(format!( "guest output contains unsupported key `{key}`" ))); } } let summary = match map.get("summary") { Some(Value::String(summary)) if !summary.is_empty() => summary.clone(), Some(Value::String(_)) => { return Err(PluginWasmError::Output( "guest output summary must not be empty".into(), )); } Some(_) => { return Err(PluginWasmError::Output( "guest output summary must be a string".into(), )); } None => { return Err(PluginWasmError::Output( "guest output must include a summary string".into(), )); } }; if summary.len() > PLUGIN_WASM_MAX_SUMMARY_BYTES { return Err(PluginWasmError::Output(format!( "guest output summary exceeds {} bytes", PLUGIN_WASM_MAX_SUMMARY_BYTES ))); } let content = match map.get("content") { Some(Value::String(content)) => { if content.len() > PLUGIN_WASM_MAX_OUTPUT_BYTES { return Err(PluginWasmError::Output(format!( "guest output content exceeds {} bytes", PLUGIN_WASM_MAX_OUTPUT_BYTES ))); } Some(content.clone()) } Some(Value::Null) | None => None, Some(_) => { return Err(PluginWasmError::Output( "guest output content must be a string or null".into(), )); } }; Ok(ToolOutput { summary, content }) } fn bounded_message(message: impl Into) -> String { let message = message.into(); let mut sanitized = String::with_capacity(message.len().min(512)); for ch in message.chars() { if ch.is_control() && ch != '\n' && ch != '\t' { sanitized.push(' '); } else { sanitized.push(ch); } if sanitized.len() >= 512 { sanitized.truncate(512); sanitized.push('…'); break; } } sanitized } fn validate_declared_tool_names(record: &ResolvedPluginRecord) -> Result<(), FeatureInstallError> { let mut seen = HashSet::new(); for tool in &record.manifest.tools { if !seen.insert(tool.name.as_str()) { return Err(FeatureInstallError::DuplicateToolName { tool: tool.name.clone(), first_feature: format!("{} (same plugin package)", record.identity), duplicate_feature: record.identity.to_string(), }); } } Ok(()) } fn validate_tool_name(name: &str) -> Result<(), &'static str> { if name.is_empty() { return Err("name must not be empty"); } if name.len() > 128 { return Err("name is longer than 128 bytes"); } if name.chars().any(|c| c.is_control() || c.is_whitespace()) { return Err("name must not contain whitespace or control characters"); } Ok(()) } #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum SupportedSchemaType { Object, Array, String, Number, Integer, Boolean, Null, } impl SupportedSchemaType { fn parse(value: &str) -> Option { match value { "object" => Some(Self::Object), "array" => Some(Self::Array), "string" => Some(Self::String), "number" => Some(Self::Number), "integer" => Some(Self::Integer), "boolean" => Some(Self::Boolean), "null" => Some(Self::Null), _ => None, } } } fn validate_input_schema(schema: &Value) -> Result<(), String> { let ty = validate_schema_node(schema, "$", true)?; if ty != SupportedSchemaType::Object { return Err("root schema type must be `object`".into()); } Ok(()) } fn validate_schema_node( schema: &Value, path: &str, root: bool, ) -> Result { let Value::Object(map) = schema else { return Err(format!("{path}: schema node must be a JSON object")); }; for key in map.keys() { if !is_supported_schema_keyword(key) { return Err(format!("{path}: unsupported schema keyword `{key}`")); } } let ty = match map.get("type") { Some(Value::String(value)) => SupportedSchemaType::parse(value) .ok_or_else(|| format!("{path}: unsupported schema type `{value}`"))?, Some(_) => return Err(format!("{path}: type must be a string")), None if root => return Err("root schema must declare type = `object`".into()), None => return Err(format!("{path}: schema node must declare type")), }; if let Some(title) = map.get("title") { if !title.is_string() { return Err(format!("{path}: title must be a string")); } } if let Some(description) = map.get("description") { if !description.is_string() { return Err(format!("{path}: description must be a string")); } } let properties = map.get("properties"); if let Some(properties) = properties { if ty != SupportedSchemaType::Object { return Err(format!( "{path}: properties is only supported for object schemas" )); } let Some(properties) = properties.as_object() else { return Err(format!("{path}: properties must be a JSON object")); }; for (name, child_schema) in properties { validate_schema_node(child_schema, &format!("{path}.properties.{name}"), false)?; } } if let Some(required) = map.get("required") { if ty != SupportedSchemaType::Object { return Err(format!( "{path}: required is only supported for object schemas" )); } let Some(required) = required.as_array() else { return Err(format!("{path}: required must be an array")); }; let mut seen = HashSet::new(); for entry in required { let Some(name) = entry.as_str() else { return Err(format!("{path}: required entries must be strings")); }; if !seen.insert(name) { return Err(format!("{path}: required entries must be unique")); } if let Some(properties) = properties.and_then(Value::as_object) { if !properties.contains_key(name) { return Err(format!( "{path}: required entry `{name}` is not declared in properties" )); } } } } if let Some(additional) = map.get("additionalProperties") { if ty != SupportedSchemaType::Object { return Err(format!( "{path}: additionalProperties is only supported for object schemas" )); } match additional { Value::Bool(_) => {} Value::Object(_) => { validate_schema_node(additional, &format!("{path}.additionalProperties"), false)?; } _ => { return Err(format!( "{path}: additionalProperties must be boolean or schema object" )); } } } if let Some(items) = map.get("items") { if ty != SupportedSchemaType::Array { return Err(format!("{path}: items is only supported for array schemas")); } validate_schema_node(items, &format!("{path}.items"), false)?; } if let Some(enum_values) = map.get("enum") { let Some(enum_values) = enum_values.as_array() else { return Err(format!("{path}: enum must be an array")); }; if enum_values.is_empty() { return Err(format!("{path}: enum must not be empty")); } for (index, value) in enum_values.iter().enumerate() { if enum_values .iter() .skip(index + 1) .any(|other| other == value) { return Err(format!("{path}: enum entries must be unique")); } } } Ok(ty) } fn is_supported_schema_keyword(key: &str) -> bool { matches!( key, "type" | "title" | "description" | "properties" | "required" | "additionalProperties" | "items" | "enum" ) } #[cfg(test)] mod tests { use super::*; use manifest::plugin::{ PluginDiscoveryOptions, PluginEnablementConfig, PluginExactVersion, PluginGrantConfig, PluginHttpsGrant, PluginPackageManifest, PluginRuntimeManifest, SourceQualifiedPluginId, resolve_plugin_config_for_startup, }; use serde_json::json; use std::fs; use std::path::Path; use std::sync::Mutex; use tempfile::TempDir; fn tool(name: &str) -> manifest::plugin::PluginToolManifest { manifest::plugin::PluginToolManifest { name: name.into(), description: format!("{name} tool"), input_schema: json!({"type":"object","properties":{},"additionalProperties":false}), external_write: false, } } fn record(tools: Vec) -> ResolvedPluginRecord { record_with_identity("project:example", tools) } fn record_with_identity( identity: &str, tools: Vec, ) -> ResolvedPluginRecord { let parsed_identity = SourceQualifiedPluginId::parse(identity).unwrap(); let permissions = tool_permissions(&tools); ResolvedPluginRecord { identity: parsed_identity.clone(), source: parsed_identity.source, package_path: std::path::PathBuf::from("/tmp/example.zip"), package_label: "example.zip".into(), digest: "sha256:abc".into(), version: "0.1.0".into(), manifest: PluginPackageManifest { schema_version: 1, id: "example".into(), name: "Example".into(), version: "0.1.0".into(), description: None, surfaces: vec![PluginSurface::Tool], runtime: None, hooks: Vec::new(), tools, permissions: permissions.clone(), }, enabled_surfaces: vec![PluginSurface::Tool], grants: PluginGrantConfig { id: Some(parsed_identity.to_string()), version: Some(PluginExactVersion("0.1.0".to_string())), digest: Some("sha256:abc".to_string()), permissions, https: Vec::new(), fs: Vec::new(), }, config: None, } } fn tool_permissions(tools: &[manifest::plugin::PluginToolManifest]) -> Vec { let mut permissions = vec![PluginPermission::surface(PluginSurface::Tool)]; permissions.extend( tools .iter() .map(|tool| PluginPermission::tool(tool.name.clone())), ); permissions } fn skipped_count(report: &super::super::FeatureRegistryInstallReport) -> usize { report .reports .iter() .map(|feature_report| feature_report.skipped.len()) .sum() } fn has_diagnostic(report: &super::super::FeatureRegistryInstallReport, needle: &str) -> bool { report.reports.iter().any(|feature_report| { feature_report .diagnostics .iter() .any(|diagnostic| diagnostic.message.contains(needle)) }) } fn install_plugin_record( record: ResolvedPluginRecord, ) -> ( super::super::FeatureRegistryInstallReport, Vec, ) { let mut pending = Vec::new(); let mut hooks = crate::hook::HookRegistryBuilder::new(); let report = super::super::FeatureRegistryBuilder::default() .with_module(PluginToolFeature::new(record)) .install_into_pending(&mut pending, &mut hooks); (report, pending) } fn record_with_https_grant() -> ResolvedPluginRecord { let mut record = record(vec![tool("HttpsTool")]); let https_permission = PluginPermission::HostApi { api: PluginHostApi::Https, }; record.manifest.permissions.push(https_permission.clone()); record.grants.permissions.push(https_permission); record.grants.https.push(PluginHttpsGrant { host: "api.example.test".to_string(), methods: vec!["GET".to_string(), "POST".to_string()], path_prefixes: vec!["/v1".to_string()], }); record } struct MockHttpsClient { calls: Mutex, response_body: String, error: Mutex>, } impl Default for MockHttpsClient { fn default() -> Self { Self { calls: Mutex::new(0), response_body: "ok".to_string(), error: Mutex::new(None), } } } impl MockHttpsClient { fn call_count(&self) -> usize { *self.calls.lock().expect("mock call lock") } } impl PluginHttpsClient for MockHttpsClient { fn execute( &self, _request: &PluginHttpsRequest, _url: &reqwest::Url, _limits: PluginHttpsLimits, ) -> Result { *self.calls.lock().expect("mock call lock") += 1; if let Some(error) = self.error.lock().expect("mock error lock").take() { return Err(PluginHttpsError::new(error)); } Ok(PluginHttpsResponse { status: 200, headers: vec![PluginHttpsHeader { name: "content-type".to_string(), value: "text/plain".to_string(), }], body: self.response_body.clone(), truncated: false, }) } } struct FakeHttpsResolver { calls: Mutex>, addrs: Vec, } impl FakeHttpsResolver { fn new(addrs: Vec) -> Self { Self { calls: Mutex::new(Vec::new()), addrs, } } fn calls(&self) -> Vec<(String, u16)> { self.calls.lock().expect("resolver calls lock").clone() } } impl PluginHttpsResolver for FakeHttpsResolver { fn resolve(&self, host: &str, port: u16) -> Result, PluginHttpsError> { self.calls .lock() .expect("resolver calls lock") .push((host.to_string(), port)); Ok(self.addrs.clone()) } } fn https_request_json(method: &str, url: &str) -> String { json!({ "method": method, "url": url }).to_string() } fn wasm_tool_that_calls_https(request: &str) -> Vec { let output = br#"{"summary":"https ok","content":"ordinary tool result path"}"#; wat::parse_str(format!( r#" (module (import "yoi:tool" "output_write" (func $output_write (param i32 i32) (result i32))) (import "yoi:https" "request" (func $https_request (param i32 i32) (result i32))) (import "yoi:https" "response_len" (func $https_response_len (result i32))) (memory (export "memory") 1) (data (i32.const 16) "{}") (data (i32.const 4096) "{}") (func (export "yoi_tool_call") (local $n i32) (local.set $n (call $https_request (i32.const 16) (i32.const {}))) (if (i32.lt_s (local.get $n) (i32.const 0)) (then unreachable)) (drop (call $https_response_len)) (drop (call $output_write (i32.const 4096) (i32.const {}))) ) ) "#, wat_bytes(request.as_bytes()), wat_bytes(output), request.len(), output.len() )) .expect("valid wat") } fn fs_request_json(path: &str) -> String { json!({ "path": path }).to_string() } fn fs_write_request_json(path: &str, content: &str) -> String { json!({ "path": path, "content": content }).to_string() } fn record_with_fs_grant( root: &Path, operations: Vec, ) -> ResolvedPluginRecord { let mut record = record(vec![tool("FsTool")]); let fs_permission = PluginPermission::HostApi { api: PluginHostApi::Fs, }; record.manifest.permissions.push(fs_permission.clone()); record.grants.permissions.push(fs_permission); record.grants.fs.push(PluginFsGrant { root: root.to_string_lossy().into_owned(), operations, }); record } fn runtime_record_with_fs_wasm( wasm: Vec, root: &Path, operations: Vec, ) -> (TempDir, ResolvedPluginRecord) { let (dir, mut record) = resolved_record_with_wasm(wasm); let fs_permission = PluginPermission::HostApi { api: PluginHostApi::Fs, }; record.manifest.permissions.push(fs_permission.clone()); record.grants.permissions.push(fs_permission); record.grants.fs.push(PluginFsGrant { root: root.to_string_lossy().into_owned(), operations, }); (dir, record) } fn wasm_tool_that_calls_fs_read(request: &str) -> Vec { let output = br#"{"summary":"fs ok","content":"ordinary tool result path"}"#; wat::parse_str(format!( r#" (module (import "yoi:tool" "output_write" (func $output_write (param i32 i32) (result i32))) (import "yoi:fs" "read" (func $fs_read (param i32 i32) (result i32))) (import "yoi:fs" "response_len" (func $fs_response_len (result i32))) (import "yoi:fs" "response_read" (func $fs_response_read (param i32 i32) (result i32))) (memory (export "memory") 1) (data (i32.const 16) "{}") (data (i32.const 4096) "{}") (func (export "yoi_tool_call") (local $n i32) (local.set $n (call $fs_read (i32.const 16) (i32.const {}))) (if (i32.lt_s (local.get $n) (i32.const 0)) (then unreachable)) (drop (call $fs_response_len)) (drop (call $fs_response_read (i32.const 8192) (i32.const 4096))) (drop (call $output_write (i32.const 4096) (i32.const {}))) ) ) "#, wat_bytes(request.as_bytes()), wat_bytes(output), request.len(), output.len() )) .expect("valid wat") } fn empty_wasm_tool() -> Vec { let output = br#"{"summary":"no network","content":"no https import"}"#; wat::parse_str(format!( r#" (module (import "yoi:tool" "output_write" (func $output_write (param i32 i32) (result i32))) (memory (export "memory") 1) (data (i32.const 4096) "{}") (func (export "yoi_tool_call") (drop (call $output_write (i32.const 4096) (i32.const {}))) ) ) "#, wat_bytes(output), output.len() )) .expect("valid wat") } #[test] fn rejects_invalid_root_schema() { let schema = json!({"type":"string"}); assert!( validate_input_schema(&schema) .unwrap_err() .contains("type must be `object`") ); } #[test] fn rejects_unsupported_schema_keyword() { let schema = json!({"type":"object","oneOf":[]}); assert!( validate_input_schema(&schema) .unwrap_err() .contains("unsupported schema keyword") ); } #[test] fn rejects_invalid_nested_property_schema_node() { let schema = json!({ "type":"object", "properties":{"query":"not-a-schema"}, "required":["query"], "additionalProperties":false }); let error = validate_input_schema(&schema).unwrap_err(); assert!(error.contains("$.properties.query")); assert!(error.contains("schema node must be a JSON object")); } #[test] fn rejects_invalid_recursive_schema_members() { let duplicate_required = json!({ "type":"object", "properties":{"query":{"type":"string"}}, "required":["query", "query"] }); assert!( validate_input_schema(&duplicate_required) .unwrap_err() .contains("required entries must be unique") ); let invalid_items = json!({ "type":"object", "properties":{"values":{"type":"array", "items":"not-a-schema"}} }); assert!( validate_input_schema(&invalid_items) .unwrap_err() .contains("$.properties.values.items") ); let invalid_additional = json!({ "type":"object", "additionalProperties":{"type":"unsupported"} }); assert!( validate_input_schema(&invalid_additional) .unwrap_err() .contains("unsupported schema type") ); } #[test] fn accepts_object_tool_schema() { validate_input_schema(&json!({ "type":"object", "properties":{ "query":{"type":"string", "description":"Search text"}, "limit":{"type":"integer", "enum":[1, 5, 10]}, "tags":{"type":"array", "items":{"type":"string"}} }, "required":["query"], "additionalProperties":{"type":"string"} })) .unwrap(); } #[test] fn wasm_tool_can_call_granted_fs_read_host_api() { let root = TempDir::new().expect("temp root"); fs::write(root.path().join("allowed.txt"), "hello fs").expect("write fixture"); let (_dir, record) = runtime_record_with_fs_wasm( wasm_tool_that_calls_fs_read(&fs_request_json("allowed.txt")), root.path(), vec![PluginFsOperation::Read], ); let output = run_plugin_wasm_tool(record, "PluginEcho".to_string(), Vec::new()) .expect("tool output"); assert_eq!(output.summary, "fs ok"); assert_eq!(output.content.as_deref(), Some("ordinary tool result path")); } #[test] fn granted_fs_read_list_and_write_are_scoped() { let root = TempDir::new().expect("temp root"); fs::write(root.path().join("read.txt"), "hello fs").expect("write fixture"); fs::create_dir(root.path().join("dir")).expect("create dir"); fs::write(root.path().join("dir").join("entry.txt"), "entry").expect("write entry"); let record = record_with_fs_grant( root.path(), vec![ PluginFsOperation::Read, PluginFsOperation::List, PluginFsOperation::Write, ], ); let read = execute_plugin_fs_request( &record, PluginFsRuntimeOperation::Read, fs_request_json("read.txt").as_bytes(), ) .expect("read allowed"); let read: serde_json::Value = serde_json::from_slice(&read).expect("read response json"); assert_eq!(read["content"], "hello fs"); assert_eq!(read["truncated"], false); let list = execute_plugin_fs_request( &record, PluginFsRuntimeOperation::List, fs_request_json("dir").as_bytes(), ) .expect("list allowed"); let list: serde_json::Value = serde_json::from_slice(&list).expect("list response json"); assert_eq!(list["entries"][0]["name"], "entry.txt"); assert_eq!(list["entries"][0]["kind"], "file"); let write = execute_plugin_fs_request( &record, PluginFsRuntimeOperation::Write, fs_write_request_json("written.txt", "new content").as_bytes(), ) .expect("write allowed"); let write: serde_json::Value = serde_json::from_slice(&write).expect("write response json"); assert_eq!(write["bytes_written"], "new content".len()); assert_eq!( fs::read_to_string(root.path().join("written.txt")).expect("read written"), "new content" ); } #[test] fn missing_fs_grant_denies_even_when_manifest_requests_api() { let root = TempDir::new().expect("temp root"); fs::write(root.path().join("secret.txt"), "must not leak").expect("write fixture"); let mut record = record(vec![tool("FsTool")]); record.manifest.permissions.push(PluginPermission::HostApi { api: PluginHostApi::Fs, }); let error = execute_plugin_fs_request( &record, PluginFsRuntimeOperation::Read, fs_request_json("secret.txt").as_bytes(), ) .expect_err("grant denied"); assert!(error.message.contains("host_api.fs")); assert!(!error.message.contains("must not leak")); } #[test] fn workspace_scope_is_not_inherited_without_plugin_fs_grant() { let root = TempDir::new().expect("temp root"); fs::write(root.path().join("workspace.txt"), "workspace authority").expect("write fixture"); let record = record(vec![tool("FsTool")]); let error = execute_plugin_fs_request( &record, PluginFsRuntimeOperation::Read, fs_request_json("workspace.txt").as_bytes(), ) .expect_err("plugin grant required"); assert!(error.message.contains("host_api.fs")); assert!(!error.message.contains("workspace authority")); } #[test] fn fs_traversal_and_absolute_paths_are_rejected() { let root = TempDir::new().expect("temp root"); let record = record_with_fs_grant(root.path(), vec![PluginFsOperation::Read]); for path in ["../outside.txt", "/etc/passwd"] { let error = execute_plugin_fs_request( &record, PluginFsRuntimeOperation::Read, fs_request_json(path).as_bytes(), ) .expect_err("path denied"); assert!( error.message.contains("relative") || error.message.contains("traversal"), "unexpected error: {}", error.message ); } } #[cfg(unix)] #[test] fn fs_symlink_escape_is_rejected() { let root = TempDir::new().expect("temp root"); let outside = TempDir::new().expect("outside root"); fs::write(outside.path().join("outside.txt"), "outside secret").expect("write outside"); std::os::unix::fs::symlink(outside.path(), root.path().join("link")) .expect("create symlink"); let record = record_with_fs_grant(root.path(), vec![PluginFsOperation::Read]); let error = execute_plugin_fs_request( &record, PluginFsRuntimeOperation::Read, fs_request_json("link/outside.txt").as_bytes(), ) .expect_err("symlink denied"); assert!(error.message.contains("symlink")); assert!(!error.message.contains("outside secret")); } #[test] fn fs_read_write_and_list_bounds_are_enforced() { let root = TempDir::new().expect("temp root"); fs::write( root.path().join("big.txt"), vec![b'a'; PLUGIN_FS_MAX_READ_BYTES + 1], ) .expect("write big file"); let list_dir = root.path().join("many"); fs::create_dir(&list_dir).expect("create list dir"); for index in 0..=PLUGIN_FS_MAX_LIST_ENTRIES { fs::write(list_dir.join(format!("entry-{index:03}.txt")), "x") .expect("write list entry"); } let record = record_with_fs_grant( root.path(), vec![ PluginFsOperation::Read, PluginFsOperation::List, PluginFsOperation::Write, ], ); let read = execute_plugin_fs_request( &record, PluginFsRuntimeOperation::Read, fs_request_json("big.txt").as_bytes(), ) .expect("bounded read allowed"); let read: serde_json::Value = serde_json::from_slice(&read).expect("read response json"); assert_eq!(read["truncated"], true); assert_eq!( read["content"].as_str().expect("content").len(), PLUGIN_FS_MAX_READ_BYTES ); let list = execute_plugin_fs_request( &record, PluginFsRuntimeOperation::List, fs_request_json("many").as_bytes(), ) .expect("bounded list allowed"); let list: serde_json::Value = serde_json::from_slice(&list).expect("list response json"); assert_eq!(list["truncated"], true); assert_eq!( list["entries"].as_array().expect("entries").len(), PLUGIN_FS_MAX_LIST_ENTRIES ); let too_large = "x".repeat(PLUGIN_FS_MAX_WRITE_BYTES + 1); let error = execute_plugin_fs_request( &record, PluginFsRuntimeOperation::Write, fs_write_request_json("too-large.txt", &too_large).as_bytes(), ) .expect_err("oversize write denied"); assert!(error.message.contains("exceeds")); assert!(!root.path().join("too-large.txt").exists()); } #[test] fn fs_diagnostics_redact_secret_like_path_segments() { let root = TempDir::new().expect("temp root"); let record = record_with_fs_grant(root.path(), vec![PluginFsOperation::Read]); let error = execute_plugin_fs_request( &record, PluginFsRuntimeOperation::Read, fs_request_json("secret=my-token-value.txt").as_bytes(), ) .expect_err("missing file"); assert!(error.message.contains("secret")); assert!(error.message.contains("")); assert!(!error.message.contains("my-token-value")); } #[test] fn fs_writes_serialize_to_normalized_target() { let root = TempDir::new().expect("temp root"); let record = record_with_fs_grant(root.path(), vec![PluginFsOperation::Write]); execute_plugin_fs_request( &record, PluginFsRuntimeOperation::Write, fs_write_request_json("target.txt", "first").as_bytes(), ) .expect("first write"); execute_plugin_fs_request( &record, PluginFsRuntimeOperation::Write, fs_write_request_json("./target.txt", "second").as_bytes(), ) .expect("second write"); assert_eq!( fs::read_to_string(root.path().join("target.txt")).expect("read target"), "second" ); } #[test] fn origin_retains_plugin_metadata() { let feature = PluginToolFeature::new(record(Vec::new())); let origin = feature.origin(); assert_eq!(origin.kind, "plugin"); assert_eq!(origin.plugin_id, "example"); assert_eq!(origin.plugin_ref, "project:example"); assert_eq!(origin.source, "project"); assert_eq!(origin.digest, "sha256:abc"); assert_eq!(origin.package_version, "0.1.0"); assert_eq!(origin.package_api_version, 1); assert_eq!(origin.surface, "tool"); } #[test] fn wasm_tool_can_call_granted_https_host_api() { let (_dir, record) = runtime_record_with_https_wasm(wasm_tool_that_calls_https( &https_request_json("GET", "https://api.example.test/v1/data"), )); let client = Arc::new(MockHttpsClient::default()); let output = run_plugin_wasm_tool_with_https_client( record, "PluginEcho".to_string(), Vec::new(), client.clone(), ) .expect("tool output"); assert_eq!(client.call_count(), 1); assert_eq!(output.summary, "https ok"); assert_eq!(output.content.as_deref(), Some("ordinary tool result path")); } #[test] fn missing_https_grant_denies_before_network() { let (_dir, mut record) = resolved_record_with_wasm(wasm_tool_that_calls_https( &https_request_json("GET", "https://api.example.test/v1/data"), )); record.manifest.permissions.push(PluginPermission::HostApi { api: PluginHostApi::Https, }); let client = Arc::new(MockHttpsClient::default()); let error = run_plugin_wasm_tool_with_https_client( record, "PluginEcho".to_string(), Vec::new(), client.clone(), ) .expect_err("grant denied"); assert_eq!(client.call_count(), 0); assert!(error.bounded_message().contains("host_api.https")); } #[test] fn disallowed_https_request_targets_deny_before_network() { let record = record_with_https_grant(); let client = MockHttpsClient::default(); for (method, url, needle) in [ ("GET", "http://api.example.test/v1/data", "scheme"), ("TRACE", "https://api.example.test/v1/data", "method"), ("GET", "https://other.example.test/v1/data", "grants"), ("GET", "https://localhost/v1/data", "local/private"), ("GET", "https://127.0.0.1/v1/data", "local/private"), ("GET", "https://10.0.0.1/v1/data", "local/private"), ("GET", "https://169.254.169.254/v1/data", "local/private"), ("GET", "file:///tmp/secret", "scheme"), ] { let error = execute_plugin_https_request( &record, &client, https_request_json(method, url).as_bytes(), ) .expect_err("request denied"); assert!( error.0.contains(needle), "{method} {url} produced {:?}, expected {needle}", error.0 ); } assert_eq!(client.call_count(), 0); } #[test] fn ipv4_mapped_ipv6_targets_deny_before_network() { let record = record_with_https_grant(); let client = MockHttpsClient::default(); for url in [ "https://[::ffff:127.0.0.1]/v1/data", "https://[::ffff:10.0.0.1]/v1/data", "https://[::ffff:169.254.169.254]/v1/data", "https://[::10.0.0.1]/v1/data", ] { let error = execute_plugin_https_request( &record, &client, https_request_json("GET", url).as_bytes(), ) .expect_err("mapped address denied"); assert!( error.0.contains("local/private"), "{url} produced {:?}", error.0 ); } assert_eq!(client.call_count(), 0); } #[test] fn dns_resolution_is_pinned_to_validated_public_socket_addresses() { let url = reqwest::Url::parse("https://api.example.test:8443/v1/data").unwrap(); let resolver = FakeHttpsResolver::new(vec!["93.184.216.34:8443".parse().unwrap()]); let pinned = resolve_https_target_for_client(&url, &resolver) .expect("resolution") .expect("hostname resolution is pinned"); assert_eq!( resolver.calls(), vec![("api.example.test".to_string(), 8443)] ); assert_eq!(pinned.domains, vec!["api.example.test".to_string()]); assert_eq!(pinned.addrs, vec!["93.184.216.34:8443".parse().unwrap()]); let mut builder = reqwest::blocking::Client::builder().no_proxy(); for domain in &pinned.domains { builder = builder.resolve_to_addrs(domain, &pinned.addrs); } builder.build().expect("client accepts pinned resolver"); } #[test] fn dns_resolution_rejects_private_addresses_before_client_build() { let url = reqwest::Url::parse("https://api.example.test/v1/data").unwrap(); let resolver = FakeHttpsResolver::new(vec!["127.0.0.1:443".parse().unwrap()]); let error = resolve_https_target_for_client(&url, &resolver).expect_err("private DNS answer"); assert!(error.0.contains("local/private")); } #[test] fn timeout_and_secret_diagnostics_are_bounded_and_redacted() { let record = record_with_https_grant(); let client = MockHttpsClient::default(); *client.error.lock().expect("mock error lock") = Some("timeout while using Authorization: Bearer SUPER_SECRET_TOKEN".to_string()); let error = execute_plugin_https_request( &record, &client, https_request_json("GET", "https://api.example.test/v1/data").as_bytes(), ) .expect_err("timeout error"); assert!(error.0.contains("timeout")); assert!(error.0.contains(PLUGIN_HTTPS_REDACTION)); assert!(!error.0.contains("SUPER_SECRET_TOKEN")); assert!(error.0.len() <= 513); assert_eq!(client.call_count(), 1); } #[test] fn response_size_bound_truncates() { let record = record_with_https_grant(); let client = MockHttpsClient { calls: Mutex::new(0), response_body: "x".repeat(PLUGIN_HTTPS_MAX_RESPONSE_BYTES + 8), error: Mutex::new(None), }; let response = execute_plugin_https_request( &record, &client, https_request_json("GET", "https://api.example.test/v1/data").as_bytes(), ) .expect("response"); let value: Value = serde_json::from_slice(&response).expect("response json"); assert_eq!(value["truncated"], true); assert_eq!( value["body"].as_str().expect("body").len(), PLUGIN_HTTPS_MAX_RESPONSE_BYTES ); } #[test] fn no_network_without_https_import() { let (_dir, record) = runtime_record_with_https_wasm(empty_wasm_tool()); let client = Arc::new(MockHttpsClient::default()); let output = run_plugin_wasm_tool_with_https_client( record, "PluginEcho".to_string(), Vec::new(), client.clone(), ) .expect("tool output"); assert_eq!(client.call_count(), 0); assert_eq!(output.summary, "no network"); } #[test] fn enabled_plugin_tool_registers_model_visible_schema_and_origin() { let mut pending = Vec::new(); let mut hooks = crate::hook::HookRegistryBuilder::new(); let report = super::super::FeatureRegistryBuilder::default() .with_module(PluginToolFeature::new(record(vec![tool("PluginSearch")]))) .install_into_pending(&mut pending, &mut hooks); assert!( report .reports .iter() .all(|feature_report| feature_report.diagnostics.is_empty()), "{:#?}", report.reports ); assert_eq!(report.installed_tool_names(), vec!["PluginSearch"]); assert_eq!(pending.len(), 1); let (meta, _) = pending[0](); assert_eq!(meta.name, "PluginSearch"); assert_eq!(meta.input_schema["type"], "object"); let origin = meta.origin.expect("plugin origin metadata"); assert_eq!(origin.plugin_ref, "project:example"); assert_eq!(origin.digest, "sha256:abc"); assert_eq!(origin.source, "project"); assert_eq!(origin.surface, "tool"); } #[test] fn no_grant_denies_plugin_tool_registration_and_runtime_execution() { let mut record = record(vec![tool("PluginSearch")]); record.grants = PluginGrantConfig::default(); let (report, pending) = install_plugin_record(record.clone()); assert!(pending.is_empty()); assert!(has_diagnostic(&report, "registration denied")); assert!(has_diagnostic( &report, "granted surfaces.tool permission is missing" )); let error = run_plugin_wasm_tool(record, "PluginSearch".into(), br#"{}"#.to_vec()) .unwrap_err() .bounded_message(); assert!(error.contains("plugin permission denied"), "{error}"); assert!( error.contains("granted surfaces.tool permission is missing"), "{error}" ); assert!(error.len() < 700, "{error}"); } #[test] fn specific_tool_grant_registers_only_intended_plugin_tool() { let mut record = record(vec![tool("PluginAllowed"), tool("PluginDenied")]); record.grants.permissions = vec![ PluginPermission::surface(PluginSurface::Tool), PluginPermission::tool("PluginAllowed"), ]; let (report, pending) = install_plugin_record(record); assert_eq!(pending.len(), 1); let (meta, _) = pending[0](); assert_eq!(meta.name, "PluginAllowed"); assert_eq!(report.installed_tool_names(), vec!["PluginAllowed"]); assert!(has_diagnostic( &report, "granted tool permission for `PluginDenied` is missing" )); } #[test] fn grant_binding_mismatches_do_not_authorize_plugin_tool() { let mut unrelated = record(vec![tool("PluginSearch")]); unrelated.grants.id = Some("project:other".to_string()); let error = authorize_plugin_tool(&unrelated, &unrelated.manifest.tools[0]) .unwrap_err() .bounded_message(); assert!( error.contains("package id binding does not match"), "{error}" ); let mut bad_digest = record(vec![tool("PluginSearch")]); bad_digest.grants.digest = Some("sha256:not-the-package".to_string()); let error = authorize_plugin_tool(&bad_digest, &bad_digest.manifest.tools[0]) .unwrap_err() .bounded_message(); assert!(error.contains("digest binding does not match"), "{error}"); let mut bad_version = record(vec![tool("PluginSearch")]); bad_version.grants.version = Some(PluginExactVersion("9.9.9".to_string())); let error = authorize_plugin_tool(&bad_version, &bad_version.manifest.tools[0]) .unwrap_err() .bounded_message(); assert!(error.contains("version binding does not match"), "{error}"); } #[test] fn requested_surface_tool_and_external_write_permissions_are_required() { let mut missing_surface = record(vec![tool("PluginSearch")]); missing_surface.manifest.permissions = vec![PluginPermission::tool("PluginSearch")]; let (report, pending) = install_plugin_record(missing_surface); assert!(pending.is_empty()); assert!(has_diagnostic( &report, "requested surfaces.tool permission is missing" )); let mut missing_tool = record(vec![tool("PluginSearch")]); missing_tool.manifest.permissions = vec![PluginPermission::surface(PluginSurface::Tool)]; let (report, pending) = install_plugin_record(missing_tool); assert!(pending.is_empty()); assert!(has_diagnostic( &report, "requested tool permission for `PluginSearch` is missing" )); let mut external_tool = tool("PluginWrite"); external_tool.external_write = true; let mut missing_external_request = record(vec![external_tool]); let (report, pending) = install_plugin_record(missing_external_request.clone()); assert!(pending.is_empty()); assert!(has_diagnostic( &report, "requested external_write permission is missing" )); missing_external_request .manifest .permissions .push(PluginPermission::ExternalWrite); let (report, pending) = install_plugin_record(missing_external_request); assert!(pending.is_empty()); assert!(has_diagnostic( &report, "granted external_write permission is missing" )); } #[test] fn future_host_api_imports_are_permission_checked_before_unimplemented_boundary() { let (_dir, mut record) = resolved_record_with_wasm(https_import_module()); let error = run_plugin_wasm_tool(record.clone(), "PluginEcho".into(), br#"{}"#.to_vec()) .unwrap_err() .bounded_message(); assert!( error.contains("requested host_api.https permission is missing"), "{error}" ); record .manifest .permissions .push(PluginPermission::host_api(PluginHostApi::Https)); record .grants .permissions .push(PluginPermission::host_api(PluginHostApi::Https)); let error = run_plugin_wasm_tool(record, "PluginEcho".into(), br#"{}"#.to_vec()) .unwrap_err() .bounded_message(); assert!( error.contains("granted host_api.https allowlist is missing"), "{error}" ); } #[test] fn package_without_enabled_tool_surface_registers_no_schema() { let mut config = PluginConfig::default(); let mut disabled = record(vec![tool("PluginSearch")]); disabled.enabled_surfaces.clear(); config.resolved.push(disabled); assert!(plugin_tool_features(&config).is_empty()); } #[test] fn disabled_profile_feature_registers_no_schema() { let mut config = PluginConfig::default(); config.resolved.push(record(vec![tool("PluginSearch")])); assert!(plugin_tool_features_if_enabled(false, &config).is_empty()); assert_eq!(plugin_tool_features_if_enabled(true, &config).len(), 1); } #[test] fn duplicate_plugin_tool_names_are_rejected_with_diagnostic() { let mut pending = Vec::new(); let mut hooks = crate::hook::HookRegistryBuilder::new(); let report = super::super::FeatureRegistryBuilder::default() .with_module(PluginToolFeature::new(record(vec![tool("PluginSearch")]))) .with_module(PluginToolFeature::new(record_with_identity( "project:other", vec![tool("PluginSearch")], ))) .install_into_pending(&mut pending, &mut hooks); assert_eq!(pending.len(), 1); assert_eq!(skipped_count(&report), 1); assert!(has_diagnostic(&report, "duplicate tool contribution")); } #[test] fn builtin_tool_name_collision_is_rejected_with_diagnostic() { let mut pending = Vec::new(); let mut hooks = crate::hook::HookRegistryBuilder::new(); let mut registered = std::collections::HashMap::new(); registered.insert("Read".to_string(), FeatureId::builtin("preexisting-tool")); let report = super::super::FeatureRegistryBuilder::default() .with_module(PluginToolFeature::new(record(vec![tool("Read")]))) .install_into_pending_with_registered(&mut pending, &mut hooks, registered); assert!(pending.is_empty()); assert_eq!(skipped_count(&report), 1); assert!(has_diagnostic(&report, "duplicate tool contribution")); } #[test] fn invalid_input_schema_is_rejected_with_diagnostic() { let mut invalid = tool("BadSchema"); invalid.input_schema = json!({"type":"object","$ref":"#/defs/input"}); let mut pending = Vec::new(); let mut hooks = crate::hook::HookRegistryBuilder::new(); let report = super::super::FeatureRegistryBuilder::default() .with_module(PluginToolFeature::new(record(vec![invalid]))) .install_into_pending(&mut pending, &mut hooks); assert!(pending.is_empty()); assert!(has_diagnostic(&report, "invalid input_schema")); } #[test] fn nested_invalid_input_schema_does_not_register_plugin_tool() { let mut invalid = tool("BadNestedSchema"); invalid.input_schema = json!({ "type":"object", "properties":{"query":"not-a-schema"}, "required":["query"], "additionalProperties":false }); let mut pending = Vec::new(); let mut hooks = crate::hook::HookRegistryBuilder::new(); let report = super::super::FeatureRegistryBuilder::default() .with_module(PluginToolFeature::new(record(vec![invalid]))) .install_into_pending(&mut pending, &mut hooks); assert!(pending.is_empty()); assert!(has_diagnostic(&report, "invalid input_schema")); assert!(has_diagnostic(&report, "$.properties.query")); } #[tokio::test] async fn registered_plugin_tool_executes_wasm_and_returns_normal_tool_result() { let (_dir, record) = resolved_record_with_wasm(input_reaches_guest_module()); let origin = PluginToolFeature::new(record.clone()).origin(); let tool = PluginWasmTool { record, name: "PluginEcho".into(), origin, }; let output = tool .execute(r#"{"x":1}"#, ToolExecutionContext::default()) .await .unwrap(); assert_eq!(output.summary, "input reached"); assert_eq!(output.content.as_deref(), Some("ordinary tool result path")); let result = llm_worker::tool::ToolResult::from_output("call-1", output); assert_eq!(result.summary, "input reached"); assert!( result .content .unwrap() .contains("ordinary tool result path") ); } #[test] fn pdk_tool_output_shape_is_accepted_by_wasm_decoder() { let pdk_output = yoi_plugin_pdk::ToolOutput::json("pdk ok", serde_json::json!({"answer": 42})) .unwrap() .to_json_string(); let output = decode_plugin_wasm_output(pdk_output.as_bytes()).unwrap(); assert_eq!(output.summary, "pdk ok"); assert_eq!(output.content.as_deref(), Some(r#"{"answer":42}"#)); } #[tokio::test] async fn malformed_input_json_fails_before_wasm_execution() { let (_dir, record) = resolved_record_with_wasm(input_reaches_guest_module()); let origin = PluginToolFeature::new(record.clone()).origin(); let tool = PluginWasmTool { record, name: "PluginEcho".into(), origin, }; let error = tool .execute("not json", ToolExecutionContext::default()) .await .unwrap_err(); assert!(error.to_string().contains("input is not valid JSON")); } #[tokio::test] async fn malformed_output_fails_closed() { let (_dir, record) = resolved_record_with_wasm(output_module(b"not json")); let error = run_plugin_wasm_tool(record, "PluginEcho".into(), br#"{}"#.to_vec()).unwrap_err(); assert!(error.bounded_message().contains("not valid JSON")); } #[tokio::test] async fn schema_mismatch_output_fails_closed() { let (_dir, record) = resolved_record_with_wasm(output_module(br#"{"summary":1}"#)); let error = run_plugin_wasm_tool(record, "PluginEcho".into(), br#"{}"#.to_vec()).unwrap_err(); assert!(error.bounded_message().contains("summary must be a string")); } #[tokio::test] async fn oversize_output_fails_closed() { let (_dir, record) = resolved_record_with_wasm(oversize_output_module()); let error = run_plugin_wasm_tool(record, "PluginEcho".into(), br#"{}"#.to_vec()).unwrap_err(); assert!(error.bounded_message().contains("exceeds")); } #[tokio::test] async fn nonterminating_execution_fails_closed_with_fuel_boundary() { let (_dir, record) = resolved_record_with_wasm(nonterminating_module()); let error = run_plugin_wasm_tool(record, "PluginEcho".into(), br#"{}"#.to_vec()).unwrap_err(); let message = error.bounded_message(); assert!( message.contains("Execution") || message.contains("fuel") || message.contains("execution"), "{message}" ); } #[tokio::test] async fn missing_runtime_module_returns_safe_bounded_tool_error() { let record = record_with_missing_package_runtime(); let origin = PluginToolFeature::new(record.clone()).origin(); let tool = PluginWasmTool { record, name: "PluginSearch".into(), origin, }; let error = tool .execute("{}", ToolExecutionContext::default()) .await .unwrap_err(); let message = error.to_string(); assert!(message.contains("failed closed")); assert!(message.contains("metadata could not be read")); assert!(message.len() < 900); assert!(message.contains("project:example")); } #[tokio::test] async fn ambient_wasi_fs_network_env_imports_are_unavailable() { let (_dir, record) = resolved_record_with_wasm(wasi_import_module()); let error = run_plugin_wasm_tool(record, "PluginEcho".into(), br#"{}"#.to_vec()).unwrap_err(); let message = error.bounded_message(); assert!(message.contains("unsupported import module"), "{message}"); assert!(message.contains("wasi_snapshot_preview1"), "{message}"); } fn record_with_missing_package_runtime() -> ResolvedPluginRecord { let mut record = record(vec![tool("PluginSearch")]); record.manifest.runtime = Some(PluginRuntimeManifest { kind: PLUGIN_RUNTIME_WASM_KIND.into(), entry: Some("plugin.wasm".into()), abi: Some(PLUGIN_RUNTIME_WASM_ABI.into()), component: None, world: None, }); record } fn runtime_record_with_https_wasm(wasm: Vec) -> (TempDir, ResolvedPluginRecord) { let (dir, mut record) = resolved_record_with_wasm(wasm); let https_permission = PluginPermission::HostApi { api: PluginHostApi::Https, }; record.manifest.permissions.push(https_permission.clone()); record.grants.permissions.push(https_permission); record.grants.https.push(PluginHttpsGrant { host: "api.example.test".to_string(), methods: vec!["GET".to_string(), "POST".to_string()], path_prefixes: vec!["/v1".to_string()], }); (dir, record) } fn resolved_record_with_wasm(wasm: Vec) -> (TempDir, ResolvedPluginRecord) { let dir = TempDir::new().unwrap(); let package_dir = dir.path().join(".yoi/plugins"); fs::create_dir_all(&package_dir).unwrap(); let package_path = package_dir.join("example.yoi-plugin"); write_plugin_package(&package_path, &wasm); let config = PluginConfig { enabled: vec![PluginEnablementConfig { id: "project:example".parse().unwrap(), surfaces: vec![PluginSurface::Tool], ..PluginEnablementConfig::default() }], resolved: Vec::new(), diagnostics: Vec::new(), }; let options = PluginDiscoveryOptions::new(dir.path()); let resolved = resolve_plugin_config_for_startup(&config, &options); assert!( resolved.diagnostics.is_empty(), "{:#?}", resolved.diagnostics ); assert_eq!(resolved.resolved.len(), 1); let mut record = resolved.resolved[0].clone(); record.grants = PluginGrantConfig { id: Some(record.identity.to_string()), version: Some(PluginExactVersion(record.version.clone())), digest: Some(record.digest.clone()), permissions: tool_permissions(&record.manifest.tools), https: Vec::new(), fs: Vec::new(), }; (dir, record) } fn write_component_plugin_package(path: &Path, component: &[u8], world: &str) { let manifest = format!( r#"schema_version = 1 id = "example" name = "Example" version = "1.0.0" description = "Example component plugin" surfaces = ["tool"] [runtime] kind = "wasm-component" component = "plugin.component.wasm" world = "{}" [[permissions]] kind = "surface" surface = "tool" [[permissions]] kind = "tool" name = "PluginEcho" [[tools]] name = "PluginEcho" description = "Echo plugin tool" input_schema = {{ type = "object", additionalProperties = true }} "#, world ); write_stored_zip( path, &[ ("plugin.toml", manifest.as_bytes()), ("plugin.component.wasm", component), ], ); } fn resolved_record_with_component(component: Vec) -> (TempDir, ResolvedPluginRecord) { let dir = TempDir::new().unwrap(); let package_dir = dir.path().join(".yoi/plugins"); fs::create_dir_all(&package_dir).unwrap(); let package_path = package_dir.join("component.yoi-plugin"); write_component_plugin_package(&package_path, &component, PLUGIN_COMPONENT_TOOL_WORLD); let config = PluginConfig { enabled: vec![PluginEnablementConfig { id: "project:example".parse().unwrap(), surfaces: vec![PluginSurface::Tool], ..PluginEnablementConfig::default() }], resolved: Vec::new(), diagnostics: Vec::new(), }; let options = PluginDiscoveryOptions::new(dir.path()); let resolved = resolve_plugin_config_for_startup(&config, &options); assert!( resolved.diagnostics.is_empty(), "{:#?}", resolved.diagnostics ); assert_eq!(resolved.resolved.len(), 1); let mut record = resolved.resolved[0].clone(); record.grants = PluginGrantConfig { id: Some(record.identity.to_string()), version: Some(PluginExactVersion(record.version.clone())), digest: Some(record.digest.clone()), permissions: tool_permissions(&record.manifest.tools), https: Vec::new(), fs: Vec::new(), }; (dir, record) } fn component_tool_that_returns(output: &[u8]) -> Vec { component_tool_with_memory_pages(output, 1) } fn component_tool_with_memory_pages(output: &[u8], memory_pages: usize) -> Vec { wat::parse_str(format!( r#"(component (core module $m (memory (export "memory") {}) (func (export "realloc") (param i32 i32 i32 i32) (result i32) (if (result i32) (i32.eqz (local.get 0)) (then (i32.const 8192)) (else (local.get 0)))) (data (i32.const 1024) "{}") (func (export "call") (param i32 i32 i32 i32) (result i32) (i32.store (i32.const 2048) (i32.const 1024)) (i32.store (i32.const 2052) (i32.const {})) (i32.const 2048)) ) (core instance $i (instantiate $m)) (alias core export $i "memory" (core memory $mem)) (alias core export $i "realloc" (core func $realloc)) (alias core export $i "call" (core func $call_core)) (type $call_ty (func (param "tool-name" string) (param "input-json" string) (result string))) (func $call (type $call_ty) (canon lift (core func $call_core) (memory $mem) (realloc $realloc) string-encoding=utf8)) (export "call" (func $call)) )"#, memory_pages, wat_bytes(output), output.len() )) .expect("valid component wat") } fn component_tool_with_table_elements(output: &[u8], table_elements: usize) -> Vec { wat::parse_str(format!( r#"(component (core module $m (memory (export "memory") 1) (table {} funcref) (func (export "realloc") (param i32 i32 i32 i32) (result i32) (if (result i32) (i32.eqz (local.get 0)) (then (i32.const 8192)) (else (local.get 0)))) (data (i32.const 1024) "{}") (func (export "call") (param i32 i32 i32 i32) (result i32) (i32.store (i32.const 2048) (i32.const 1024)) (i32.store (i32.const 2052) (i32.const {})) (i32.const 2048)) ) (core instance $i (instantiate $m)) (alias core export $i "memory" (core memory $mem)) (alias core export $i "realloc" (core func $realloc)) (alias core export $i "call" (core func $call_core)) (type $call_ty (func (param "tool-name" string) (param "input-json" string) (result string))) (func $call (type $call_ty) (canon lift (core func $call_core) (memory $mem) (realloc $realloc) string-encoding=utf8)) (export "call" (func $call)) )"#, table_elements, wat_bytes(output), output.len() )) .expect("valid component wat") } fn component_tool_importing_https(output: &[u8]) -> Vec { wat::parse_str(format!( r#"(component (import "yoi:host/https@1.0.0" (instance $https (export "request" (func $request (param "request-json" string) (result string))))) (core module $m (memory (export "memory") 1) (func (export "realloc") (param i32 i32 i32 i32) (result i32) (if (result i32) (i32.eqz (local.get 0)) (then (i32.const 8192)) (else (local.get 0)))) (data (i32.const 1024) "{}") (func (export "call") (param i32 i32 i32 i32) (result i32) (i32.store (i32.const 2048) (i32.const 1024)) (i32.store (i32.const 2052) (i32.const {})) (i32.const 2048)) ) (core instance $i (instantiate $m)) (alias core export $i "memory" (core memory $mem)) (alias core export $i "realloc" (core func $realloc)) (alias core export $i "call" (core func $call_core)) (type $call_ty (func (param "tool-name" string) (param "input-json" string) (result string))) (func $call (type $call_ty) (canon lift (core func $call_core) (memory $mem) (realloc $realloc) string-encoding=utf8)) (export "call" (func $call)) )"#, wat_bytes(output), output.len() )) .expect("valid component wat") } fn component_without_call_export() -> Vec { wat::parse_str(r#"(component (core module $m) (core instance $i (instantiate $m)))"#) .unwrap() } fn raw_module_bytes() -> Vec { wat::parse_str(r#"(module (func (export "call")))"#).unwrap() } #[test] fn component_tool_executes_through_ordinary_tool_result_path() { let (_dir, record) = resolved_record_with_component(component_tool_that_returns( br#"{"summary":"component ok","content":"ordinary tool result path"}"#, )); let output = run_plugin_component_tool(record, "PluginEcho".to_string(), b"{}".to_vec()) .expect("component tool output"); assert_eq!(output.summary, "component ok"); assert_eq!(output.content.as_deref(), Some("ordinary tool result path")); } #[test] fn component_memory_limit_fails_closed_before_string_lift() { let oversized_memory_pages = (PLUGIN_WASM_MEMORY_BYTES / 65_536) + 1; let (_dir, record) = resolved_record_with_component(component_tool_with_memory_pages( br#"{"summary":"should not lift"}"#, oversized_memory_pages, )); let error = run_plugin_component_tool(record, "PluginEcho".to_string(), b"{}".to_vec()) .expect_err("component memory limit is enforced"); assert!(format!("{error:?}").contains("growing memory"), "{error:?}"); } #[test] fn component_table_limit_fails_closed() { let (_dir, record) = resolved_record_with_component(component_tool_with_table_elements( br#"{"summary":"should not run"}"#, PLUGIN_WASM_TABLE_ELEMENTS + 1, )); let error = run_plugin_component_tool(record, "PluginEcho".to_string(), b"{}".to_vec()) .expect_err("component table limit is enforced"); assert!(format!("{error:?}").contains("growing table"), "{error:?}"); } #[test] fn component_output_cap_still_fails_closed_after_bounded_lift() { let output = format!( r#"{{"summary":"too big","content":"{}"}}"#, "x".repeat(PLUGIN_WASM_MAX_OUTPUT_BYTES) ); let (_dir, record) = resolved_record_with_component(component_tool_with_memory_pages(output.as_bytes(), 2)); let error = run_plugin_component_tool(record, "PluginEcho".to_string(), b"{}".to_vec()) .expect_err("component output cap is enforced"); assert!(format!("{error:?}").contains("output exceeds"), "{error:?}"); } #[test] fn component_tool_denies_host_import_without_matching_grant() { let (_dir, record) = resolved_record_with_component(component_tool_importing_https( br#"{"summary":"component ok"}"#, )); let error = run_plugin_component_tool(record, "PluginEcho".to_string(), b"{}".to_vec()) .expect_err("host import without grant is denied"); assert!( format!("{error:?}").contains("plugin host API dispatch denied"), "{error:?}" ); } #[test] fn component_tool_missing_export_fails_closed() { let (_dir, record) = resolved_record_with_component(component_without_call_export()); let error = run_plugin_component_tool(record, "PluginEcho".to_string(), b"{}".to_vec()) .expect_err("missing export fails closed"); assert!( format!("{error:?}").contains("does not export expected"), "{error:?}" ); } #[test] fn core_wasm_is_not_silently_reinterpreted_as_component() { let (_dir, record) = resolved_record_with_component(raw_module_bytes()); let error = run_plugin_component_tool(record, "PluginEcho".to_string(), b"{}".to_vec()) .expect_err("core module is incompatible with component runtime"); assert!( format!("{error:?}").contains("component is incompatible"), "{error:?}" ); } #[test] fn component_wrong_world_fails_closed_during_discovery() { let dir = TempDir::new().unwrap(); let package_dir = dir.path().join(".yoi/plugins"); fs::create_dir_all(&package_dir).unwrap(); let package_path = package_dir.join("component.yoi-plugin"); write_component_plugin_package( &package_path, &component_tool_that_returns(br#"{"summary":"component ok"}"#), "example:other/world@1.0.0", ); let config = PluginConfig { enabled: vec![PluginEnablementConfig { id: "project:example".parse().unwrap(), surfaces: vec![PluginSurface::Tool], ..PluginEnablementConfig::default() }], resolved: Vec::new(), diagnostics: Vec::new(), }; let options = PluginDiscoveryOptions::new(dir.path()); let resolved = resolve_plugin_config_for_startup(&config, &options); assert!(resolved.resolved.is_empty()); assert!( resolved.diagnostics.iter().any(|diagnostic| diagnostic .message .contains("component world is unsupported")), "{:#?}", resolved.diagnostics ); } #[test] fn component_tool_registration_uses_existing_tool_registry_path() { let (_dir, record) = resolved_record_with_component(component_tool_that_returns( br#"{"summary":"component ok"}"#, )); let (report, pending) = install_plugin_record(record); assert_eq!(skipped_count(&report), 0, "{report:#?}"); assert_eq!(pending.len(), 1); let (meta, _) = pending[0](); assert_eq!(meta.name, "PluginEcho"); } #[test] fn component_static_inspection_reports_component_runtime_without_execution() { let mut record = record(vec![tool("Echo")]); record.package_path = std::path::PathBuf::from("/no/such/component.wasm"); record.manifest.runtime = Some(PluginRuntimeManifest { kind: PLUGIN_RUNTIME_COMPONENT_KIND.to_string(), entry: None, abi: None, component: Some("plugin.component.wasm".to_string()), world: Some(PLUGIN_COMPONENT_TOOL_WORLD.to_string()), }); let inspection = inspect_resolved_plugin_static(&record); assert!(inspection.runtime.eligible); assert_eq!( inspection.runtime.status, format!("{PLUGIN_RUNTIME_COMPONENT_KIND}/{PLUGIN_COMPONENT_TOOL_WORLD}") ); assert!(inspection.runtime.diagnostic.is_none()); } fn write_plugin_package(path: &Path, wasm: &[u8]) { let manifest = br#"schema_version = 1 id = "example" name = "Example" version = "1.0.0" description = "Example plugin" surfaces = ["tool"] [runtime] kind = "wasm" entry = "plugin.wasm" abi = "yoi-plugin-wasm-1" [[permissions]] kind = "surface" surface = "tool" [[permissions]] kind = "tool" name = "PluginEcho" [[tools]] name = "PluginEcho" description = "Echo plugin tool" input_schema = { type = "object", additionalProperties = true } "#; write_stored_zip( path, &[("plugin.toml", manifest.as_slice()), ("plugin.wasm", wasm)], ); } fn input_reaches_guest_module() -> Vec { let ok = br#"{"summary":"input reached","content":"ordinary tool result path"}"#; let bad = br#"{"summary":"input missing"}"#; let wat = format!( r#"(module (import "yoi:tool" "input_len" (func $input_len (result i32))) (import "yoi:tool" "input_read" (func $input_read (param i32 i32) (result i32))) (import "yoi:tool" "output_write" (func $output_write (param i32 i32) (result i32))) (memory (export "memory") 1) (data (i32.const 0) "{}") (data (i32.const 128) "{}") (func (export "yoi_tool_call") (local $len i32) (local.set $len (call $input_len)) (if (i32.eq (local.get $len) (i32.const 7)) (then (drop (call $input_read (i32.const 512) (local.get $len))) (if (i32.eq (i32.load8_u (i32.const 517)) (i32.const 49)) (then (drop (call $output_write (i32.const 0) (i32.const {})))) (else (drop (call $output_write (i32.const 128) (i32.const {})))) ) ) (else (drop (call $output_write (i32.const 128) (i32.const {})))) ) ) )"#, wat_bytes(ok), wat_bytes(bad), ok.len(), bad.len(), bad.len() ); wat::parse_str(wat).unwrap() } fn output_module(output: &[u8]) -> Vec { let wat = format!( r#"(module (import "yoi:tool" "output_write" (func $output_write (param i32 i32) (result i32))) (memory (export "memory") 1) (data (i32.const 0) "{}") (func (export "yoi_tool_call") (drop (call $output_write (i32.const 0) (i32.const {}))) ) )"#, wat_bytes(output), output.len() ); wat::parse_str(wat).unwrap() } fn oversize_output_module() -> Vec { let wat = format!( r#"(module (import "yoi:tool" "output_write" (func $output_write (param i32 i32) (result i32))) (memory (export "memory") 2) (func (export "yoi_tool_call") (drop (call $output_write (i32.const 0) (i32.const {}))) ) )"#, PLUGIN_WASM_MAX_OUTPUT_BYTES + 1 ); wat::parse_str(wat).unwrap() } fn nonterminating_module() -> Vec { wat::parse_str( r#"(module (memory (export "memory") 1) (func (export "yoi_tool_call") (local $remaining i32) (local.set $remaining (i32.const 100000000)) (loop $again (local.set $remaining (i32.sub (local.get $remaining) (i32.const 1))) (br_if $again (local.get $remaining)) ) ) )"#, ) .unwrap() } fn wasi_import_module() -> Vec { wat::parse_str( r#"(module (import "wasi_snapshot_preview1" "fd_write" (func $fd_write)) (memory (export "memory") 1) (func (export "yoi_tool_call")) )"#, ) .unwrap() } fn https_import_module() -> Vec { wat::parse_str( r#"(module (import "yoi:https" "request" (func $request)) (memory (export "memory") 1) (func (export "yoi_tool_call")) )"#, ) .unwrap() } fn wat_bytes(bytes: &[u8]) -> String { bytes .iter() .map(|byte| format!(r#"\{:02x}"#, byte)) .collect() } #[test] fn static_inspection_does_not_read_or_execute_package() { let mut record = record(vec![tool("Echo")]); record.package_path = std::path::PathBuf::from("/no/such/plugin.wasm"); record.manifest.runtime = Some(PluginRuntimeManifest { kind: PLUGIN_RUNTIME_WASM_KIND.to_string(), entry: Some("plugin.wasm".to_string()), abi: Some(PLUGIN_RUNTIME_WASM_ABI.to_string()), component: None, world: None, }); let inspection = inspect_resolved_plugin_static(&record); assert!(inspection.runtime.eligible); assert_eq!(inspection.tools.len(), 1); assert!(inspection.tools[0].eligible); assert!(inspection.statically_eligible()); } #[test] fn static_inspection_reports_missing_tool_grant() { let mut record = record(vec![tool("Echo")]); record.manifest.runtime = Some(PluginRuntimeManifest { kind: PLUGIN_RUNTIME_WASM_KIND.to_string(), entry: Some("plugin.wasm".to_string()), abi: Some(PLUGIN_RUNTIME_WASM_ABI.to_string()), component: None, world: None, }); record.grants.permissions = vec![PluginPermission::surface(PluginSurface::Tool)]; let inspection = inspect_resolved_plugin_static(&record); assert!(!inspection.statically_eligible()); assert!(!inspection.tools[0].eligible); assert!( inspection.tools[0] .diagnostic .as_deref() .unwrap_or_default() .contains("grant") ); } #[test] fn static_inspection_reports_invalid_tool_definition() { let mut bad_schema = tool("Echo"); bad_schema.input_schema = json!({"type":"string"}); let mut record = record(vec![bad_schema]); record.manifest.runtime = Some(PluginRuntimeManifest { kind: PLUGIN_RUNTIME_WASM_KIND.to_string(), entry: Some("plugin.wasm".to_string()), abi: Some(PLUGIN_RUNTIME_WASM_ABI.to_string()), component: None, world: None, }); let inspection = inspect_resolved_plugin_static(&record); assert!(!inspection.statically_eligible()); assert!(!inspection.tools[0].eligible); let diagnostic = inspection.tools[0] .diagnostic .as_deref() .unwrap_or_default(); assert!(diagnostic.contains("invalid input_schema")); assert!(diagnostic.contains("root schema type must be `object`")); } #[test] fn static_inspection_reports_invalid_and_duplicate_tool_names() { let mut invalid = tool("Bad Tool"); invalid.input_schema = json!({"type":"object"}); let mut first_duplicate = tool("Echo"); let mut second_duplicate = tool("Echo"); first_duplicate.input_schema = json!({"type":"object"}); second_duplicate.input_schema = json!({"type":"object"}); let mut record = record(vec![invalid, first_duplicate, second_duplicate]); record.manifest.runtime = Some(PluginRuntimeManifest { kind: PLUGIN_RUNTIME_WASM_KIND.to_string(), entry: Some("plugin.wasm".to_string()), abi: Some(PLUGIN_RUNTIME_WASM_ABI.to_string()), component: None, world: None, }); let inspection = inspect_resolved_plugin_static(&record); assert!(!inspection.statically_eligible()); assert!( inspection.tools[0] .diagnostic .as_deref() .unwrap_or_default() .contains("invalid name") ); assert!( inspection.tools[1] .diagnostic .as_deref() .unwrap_or_default() .contains("duplicate name") ); assert!( inspection.tools[2] .diagnostic .as_deref() .unwrap_or_default() .contains("duplicate name") ); } fn write_stored_zip(path: &Path, files: &[(&str, &[u8])]) { let mut out = Vec::new(); let mut central = Vec::new(); for (name, data) in files { let offset = out.len() as u32; let name_bytes = name.as_bytes(); let crc = crc32(data); write_u32(&mut out, 0x0403_4b50); write_u16(&mut out, 20); write_u16(&mut out, 0); write_u16(&mut out, 0); write_u16(&mut out, 0); write_u16(&mut out, 0); write_u32(&mut out, crc); write_u32(&mut out, data.len() as u32); write_u32(&mut out, data.len() as u32); write_u16(&mut out, name_bytes.len() as u16); write_u16(&mut out, 0); out.extend_from_slice(name_bytes); out.extend_from_slice(data); write_u32(&mut central, 0x0201_4b50); write_u16(&mut central, 20); write_u16(&mut central, 20); write_u16(&mut central, 0); write_u16(&mut central, 0); write_u16(&mut central, 0); write_u16(&mut central, 0); write_u32(&mut central, crc); write_u32(&mut central, data.len() as u32); write_u32(&mut central, data.len() as u32); write_u16(&mut central, name_bytes.len() as u16); write_u16(&mut central, 0); write_u16(&mut central, 0); write_u16(&mut central, 0); write_u16(&mut central, 0); write_u32(&mut central, 0); write_u32(&mut central, offset); central.extend_from_slice(name_bytes); } let central_offset = out.len() as u32; let central_size = central.len() as u32; out.extend_from_slice(¢ral); write_u32(&mut out, 0x0605_4b50); write_u16(&mut out, 0); write_u16(&mut out, 0); write_u16(&mut out, files.len() as u16); write_u16(&mut out, files.len() as u16); write_u32(&mut out, central_size); write_u32(&mut out, central_offset); write_u16(&mut out, 0); fs::write(path, out).unwrap(); } fn write_u16(out: &mut Vec, value: u16) { out.extend_from_slice(&value.to_le_bytes()); } fn write_u32(out: &mut Vec, value: u32) { out.extend_from_slice(&value.to_le_bytes()); } fn crc32(data: &[u8]) -> u32 { let mut crc = 0xffff_ffffu32; for &byte in data { crc ^= byte as u32; for _ in 0..8 { let mask = if crc & 1 == 1 { 0xedb8_8320 } else { 0 }; crc = (crc >> 1) ^ mask; } } !crc } }