4702 lines
164 KiB
Rust
4702 lines
164 KiB
Rust
//! 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<PluginToolFeature> {
|
|
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<PluginToolFeature> {
|
|
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<PluginPermissionEligibility>,
|
|
pub tools: Vec<PluginToolEligibility>,
|
|
}
|
|
|
|
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<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
|
pub struct PluginPermissionEligibility {
|
|
pub permission: String,
|
|
pub requested: bool,
|
|
pub granted: bool,
|
|
pub eligible: bool,
|
|
pub diagnostic: Option<String>,
|
|
}
|
|
|
|
#[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<String>,
|
|
}
|
|
|
|
/// 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}/<missing-abi>"));
|
|
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}/<missing-world>"));
|
|
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<String> {
|
|
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<String>,
|
|
) -> Vec<String> {
|
|
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<String>) -> Option<String> {
|
|
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<PluginHttpsResponse, PluginHttpsError> {
|
|
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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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<PluginFsResolvedPath, PluginFsError> {
|
|
let relative = parse_plugin_fs_relative_path(path)?;
|
|
let mut saw_operation_grant = false;
|
|
let mut last_error: Option<PluginFsError> = 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<PluginFsWritePath, PluginFsError> {
|
|
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<PluginFsError> = 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<PathBuf, PluginFsError> {
|
|
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<PathBuf, PluginFsError> {
|
|
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<PathBuf, PluginFsError> {
|
|
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<PluginFsWritePath, PluginFsError> {
|
|
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::<Vec<_>>();
|
|
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<Mutex<()>> {
|
|
static LOCKS: OnceLock<Mutex<HashMap<PathBuf, Arc<Mutex<()>>>>> = 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<reqwest::Url, PluginHttpsError> {
|
|
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<String> {
|
|
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<String, PluginHttpsError> {
|
|
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::<IpAddr>() {
|
|
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<Option<PinnedHttpsResolution>, PluginHttpsError> {
|
|
let host = canonical_host(url)?;
|
|
if host.parse::<IpAddr>().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<Ipv4Addr> {
|
|
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<PluginHttpsHeader> {
|
|
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("<missing-host>");
|
|
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 = "<redacted>";
|
|
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<PluginFsDirEntry>,
|
|
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<String>) -> 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<PluginHttpsHeader>,
|
|
#[serde(default)]
|
|
body: Option<String>,
|
|
}
|
|
|
|
#[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<PluginHttpsHeader>,
|
|
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<PluginHttpsResponse, PluginHttpsError>;
|
|
}
|
|
|
|
struct ReqwestPluginHttpsClient;
|
|
struct SystemPluginHttpsResolver;
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct PinnedHttpsResolution {
|
|
domains: Vec<String>,
|
|
addrs: Vec<SocketAddr>,
|
|
}
|
|
|
|
trait PluginHttpsResolver {
|
|
fn resolve(&self, host: &str, port: u16) -> Result<Vec<SocketAddr>, PluginHttpsError>;
|
|
}
|
|
|
|
impl PluginHttpsResolver for SystemPluginHttpsResolver {
|
|
fn resolve(&self, host: &str, port: u16) -> Result<Vec<SocketAddr>, 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<String>) -> 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<dyn Tool>,
|
|
)
|
|
})
|
|
}
|
|
|
|
struct PluginWasmTool {
|
|
record: ResolvedPluginRecord,
|
|
name: String,
|
|
origin: ToolOrigin,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Tool for PluginWasmTool {
|
|
async fn execute(
|
|
&self,
|
|
input_json: &str,
|
|
_ctx: ToolExecutionContext,
|
|
) -> Result<ToolOutput, ToolError> {
|
|
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::<Value>(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<dyn PluginHttpsClient>,
|
|
tool_name: Vec<u8>,
|
|
input: Vec<u8>,
|
|
output: Vec<u8>,
|
|
output_error: Option<String>,
|
|
https_response: Vec<u8>,
|
|
fs_response: Vec<u8>,
|
|
store_limits: wasmi::StoreLimits,
|
|
}
|
|
|
|
fn run_plugin_tool(
|
|
record: ResolvedPluginRecord,
|
|
tool_name: String,
|
|
input: Vec<u8>,
|
|
) -> Result<ToolOutput, PluginWasmError> {
|
|
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<u8>,
|
|
) -> Result<ToolOutput, PluginWasmError> {
|
|
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<u8>,
|
|
https_client: Arc<dyn PluginHttpsClient>,
|
|
) -> Result<ToolOutput, PluginWasmError> {
|
|
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::<PluginWasmHostState>::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<dyn PluginHttpsClient>,
|
|
store_limits: wasmtime::StoreLimits,
|
|
}
|
|
|
|
fn run_plugin_component_tool(
|
|
record: ResolvedPluginRecord,
|
|
tool_name: String,
|
|
input: Vec<u8>,
|
|
) -> Result<ToolOutput, PluginWasmError> {
|
|
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<u8>,
|
|
https_client: Arc<dyn PluginHttpsClient>,
|
|
) -> Result<ToolOutput, PluginWasmError> {
|
|
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::<PluginComponentHostState>::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<PluginComponentHostState>,
|
|
) -> 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<PluginWasmHostState>,
|
|
) -> 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<Vec<u8>, 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<ToolOutput, PluginWasmError> {
|
|
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>) -> 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<Self> {
|
|
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<SupportedSchemaType, String> {
|
|
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<manifest::plugin::PluginToolManifest>) -> ResolvedPluginRecord {
|
|
record_with_identity("project:example", tools)
|
|
}
|
|
|
|
fn record_with_identity(
|
|
identity: &str,
|
|
tools: Vec<manifest::plugin::PluginToolManifest>,
|
|
) -> 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<PluginPermission> {
|
|
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<ToolDefinition>,
|
|
) {
|
|
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<usize>,
|
|
response_body: String,
|
|
error: Mutex<Option<String>>,
|
|
}
|
|
|
|
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<PluginHttpsResponse, PluginHttpsError> {
|
|
*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<Vec<(String, u16)>>,
|
|
addrs: Vec<SocketAddr>,
|
|
}
|
|
|
|
impl FakeHttpsResolver {
|
|
fn new(addrs: Vec<SocketAddr>) -> 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<Vec<SocketAddr>, 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<u8> {
|
|
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<PluginFsOperation>,
|
|
) -> 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<u8>,
|
|
root: &Path,
|
|
operations: Vec<PluginFsOperation>,
|
|
) -> (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<u8> {
|
|
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<u8> {
|
|
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("<redacted>"));
|
|
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<u8>) -> (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<u8>) -> (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<u8>) -> (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<u8> {
|
|
component_tool_with_memory_pages(output, 1)
|
|
}
|
|
|
|
fn component_tool_with_memory_pages(output: &[u8], memory_pages: usize) -> Vec<u8> {
|
|
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<u8> {
|
|
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<u8> {
|
|
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<u8> {
|
|
wat::parse_str(r#"(component (core module $m) (core instance $i (instantiate $m)))"#)
|
|
.unwrap()
|
|
}
|
|
|
|
fn raw_module_bytes() -> Vec<u8> {
|
|
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<u8> {
|
|
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<u8> {
|
|
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<u8> {
|
|
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<u8> {
|
|
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<u8> {
|
|
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<u8> {
|
|
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<u8>, value: u16) {
|
|
out.extend_from_slice(&value.to_le_bytes());
|
|
}
|
|
|
|
fn write_u32(out: &mut Vec<u8>, 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
|
|
}
|
|
}
|