Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions src/tools/wasm/capabilities_schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,18 @@ use crate::tools::wasm::{
/// Root schema for a capabilities JSON file.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CapabilitiesFile {
/// Human-readable description of what the tool does.
/// Used as the `Tool::description()` return value.
/// If omitted, a generic fallback is used (with a warning).
#[serde(default)]
pub description: Option<String>,

/// JSON Schema for the tool's input parameters.
/// Used as the `Tool::parameters_schema()` return value.
/// If omitted, a permissive fallback is used (with a warning).
#[serde(default)]
pub parameters: Option<serde_json::Value>,

/// Extension version (semver).
#[serde(default)]
pub version: Option<String>,
Expand Down Expand Up @@ -103,6 +115,8 @@ impl CapabilitiesFile {
fn resolve_nested(mut self) -> Self {
if let Some(inner) = self.capabilities.take() {
let inner = inner.resolve_nested();
self.description = self.description.or(inner.description);
self.parameters = self.parameters.or(inner.parameters);
self.http = self.http.or(inner.http);
self.secrets = self.secrets.or(inner.secrets);
self.tool_invoke = self.tool_invoke.or(inner.tool_invoke);
Expand Down Expand Up @@ -1188,4 +1202,114 @@ mod tests {
"Empty inner capabilities should not clobber outer http"
);
}

// ── Tool description and parameters schema ──────────────────────────

#[test]
fn test_parse_description_and_parameters() {
let json = r#"{
"description": "Search the web using Brave Search API",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search query"
},
"count": {
"type": "integer",
"description": "Number of results"
}
},
"required": ["query"]
}
}"#;

let caps = CapabilitiesFile::from_json(json).unwrap();
assert_eq!(
caps.description.as_deref(),
Some("Search the web using Brave Search API")
);
let params = caps.parameters.unwrap();
assert_eq!(params["type"], "object");
assert!(params["properties"]["query"].is_object());
assert_eq!(params["required"][0], "query");
}

#[test]
fn test_parse_description_only() {
let json = r#"{
"description": "A tool without explicit parameters schema"
}"#;

let caps = CapabilitiesFile::from_json(json).unwrap();
assert_eq!(
caps.description.as_deref(),
Some("A tool without explicit parameters schema")
);
assert!(caps.parameters.is_none());
}

#[test]
fn test_parse_without_description_or_parameters() {
let json = r#"{
"http": {
"allowlist": [{ "host": "api.example.com" }]
}
}"#;

let caps = CapabilitiesFile::from_json(json).unwrap();
assert!(
caps.description.is_none(),
"description should be None when not provided"
);
assert!(
caps.parameters.is_none(),
"parameters should be None when not provided"
);
}

#[test]
fn test_resolve_nested_description_promoted() {
let json = r#"{
"capabilities": {
"description": "Inner tool description",
"parameters": {
"type": "object",
"properties": {
"input": { "type": "string" }
},
"required": ["input"]
}
}
}"#;

let caps = CapabilitiesFile::from_json(json).unwrap();
assert_eq!(
caps.description.as_deref(),
Some("Inner tool description"),
"description should be promoted from inner capabilities"
);
assert!(
caps.parameters.is_some(),
"parameters should be promoted from inner capabilities"
);
}

#[test]
fn test_resolve_nested_outer_description_takes_precedence() {
let json = r#"{
"description": "Outer description wins",
"capabilities": {
"description": "Inner description loses"
}
}"#;

let caps = CapabilitiesFile::from_json(json).unwrap();
assert_eq!(
caps.description.as_deref(),
Some("Outer description wins"),
"Outer description should take precedence over inner"
);
}
}
93 changes: 66 additions & 27 deletions src/tools/wasm/loader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,34 +123,73 @@ impl WasmToolLoader {
}
let wasm_bytes = fs::read(wasm_path).await?;

// Read capabilities (optional) and extract OAuth refresh config
let (capabilities, oauth_refresh) = if let Some(cap_path) = capabilities_path {
if cap_path.exists() {
let cap_bytes = fs::read(cap_path).await?;
let cap_file = CapabilitiesFile::from_bytes(&cap_bytes)
.map_err(|e| WasmLoadError::InvalidCapabilities(e.to_string()))?;
cap_file.validate(name);

// Check WIT version compatibility
check_wit_version_compat(
name,
cap_file.wit_version.as_deref(),
crate::tools::wasm::WIT_TOOL_VERSION,
)?;

let caps = cap_file.to_capabilities();
let oauth = resolve_oauth_refresh_config(&cap_file);
(caps, oauth)
// Read capabilities (optional) and extract OAuth refresh config,
// tool description, and parameter schema.
let (capabilities, oauth_refresh, description, schema) =
if let Some(cap_path) = capabilities_path {
if cap_path.exists() {
let cap_bytes = fs::read(cap_path).await?;
let cap_file = CapabilitiesFile::from_bytes(&cap_bytes)
.map_err(|e| WasmLoadError::InvalidCapabilities(e.to_string()))?;
cap_file.validate(name);

// Check WIT version compatibility
check_wit_version_compat(
name,
cap_file.wit_version.as_deref(),
crate::tools::wasm::WIT_TOOL_VERSION,
)?;

let caps = cap_file.to_capabilities();
let oauth = resolve_oauth_refresh_config(&cap_file);
let desc = cap_file.description.clone();
// Validate parameters schema before accepting it.
let params = cap_file.parameters.clone().and_then(|p| {
let errors = crate::tools::validate_tool_schema(&p, name);
if errors.is_empty() {
Some(p)
} else {
tracing::warn!(
tool = name,
?errors,
"Invalid parameters schema in capabilities.json, \
using permissive fallback"
);
None
}
});
if desc.is_none() {
tracing::warn!(
tool = name,
path = %cap_path.display(),
"Capabilities file missing \"description\" field; \
tool will use generic fallback description"
);
}
if params.is_none() && cap_file.parameters.is_none() {
tracing::warn!(
tool = name,
path = %cap_path.display(),
"Capabilities file missing \"parameters\" field; \
tool will accept any JSON object (permissive fallback)"
);
Comment on lines +173 to +175

Copilot AI Mar 4, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The warning message says the tool will accept "any JSON input", but the runtime fallback schema is an object schema (type "object"); non-object JSON values would still be rejected by schema-based callers. Update the warning text to accurately reflect the fallback behavior (e.g., "any JSON object"), so operators understand the real risk/behavior.

Copilot uses AI. Check for mistakes.
}
(caps, oauth, desc, params)
} else {
tracing::warn!(
path = %cap_path.display(),
"Capabilities file not found, using default (no permissions)"
);
(Capabilities::default(), None, None, None)
}
} else {
tracing::warn!(
path = %cap_path.display(),
"Capabilities file not found, using default (no permissions)"
tool = name,
"No capabilities file for WASM tool; \
tool will use generic fallback description and accept any JSON object"
);
(Capabilities::default(), None)
}
} else {
(Capabilities::default(), None)
};
(Capabilities::default(), None, None, None)
};

// Register the tool
self.registry
Expand All @@ -160,8 +199,8 @@ impl WasmToolLoader {
runtime: &self.runtime,
capabilities,
limits: None,
description: None,
schema: None,
description: description.as_deref(),
schema,
Comment on lines 199 to +203

Copilot AI Mar 4, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parameters is accepted as an arbitrary serde_json::Value and forwarded directly as the tool's parameter schema override. If the capabilities file contains a malformed/unsupported schema (or a non-object value), this can break downstream function-calling/tool schema consumers. Consider validating params with the existing schema validators (e.g., crate::tools::validate_tool_schema for a lenient check) and, on errors, log the issues and fall back to the runtime/default schema instead of registering an invalid one.

Copilot uses AI. Check for mistakes.
secrets_store: self.secrets_store.clone(),
oauth_refresh,
})
Expand Down
23 changes: 14 additions & 9 deletions src/tools/wasm/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -323,27 +323,32 @@ impl WasmToolRuntime {

/// Extract tool description from a compiled component.
///
/// In a full implementation, this would use WIT bindgen to call the description() export.
/// For now, we return a placeholder since we can't easily introspect without more setup.
/// Returns a generic fallback. Callers should prefer loading the description
/// from the sidecar `*.capabilities.json` file and overriding via
/// `WasmToolWrapper::with_description()` or the `WasmToolRegistration::description` field.
fn extract_tool_description(
_engine: &Engine,
_component: &wasmtime::component::Component,
) -> Result<String, WasmError> {
// TODO: Use WIT bindgen to properly extract description
// This requires instantiating with a linker, which needs host functions.
// For now, tools should have their description set externally.
// WIT bindgen extraction is not yet implemented (see TODO #4 in CLAUDE.md).
// Real descriptions come from the capabilities.json sidecar file, which is
// loaded by the WasmToolLoader and passed as an override at registration time.
Ok("WASM sandboxed tool".to_string())
}

/// Extract tool schema from a compiled component.
/// Extract tool parameter schema from a compiled component.
///
/// In a full implementation, this would use WIT bindgen to call the schema() export.
/// Returns a permissive fallback that accepts any JSON object. Callers should
/// prefer loading the schema from the sidecar `*.capabilities.json` file and
/// overriding via `WasmToolWrapper::with_schema()` or the
/// `WasmToolRegistration::schema` field.
fn extract_tool_schema(
_engine: &Engine,
_component: &wasmtime::component::Component,
) -> Result<serde_json::Value, WasmError> {
// TODO: Use WIT bindgen to properly extract schema
// For now, return a minimal schema that accepts any object.
// WIT bindgen extraction is not yet implemented (see TODO #4 in CLAUDE.md).
// Real schemas come from the capabilities.json sidecar file, which is
// loaded by the WasmToolLoader and passed as an override at registration time.
Ok(serde_json::json!({
"type": "object",
"properties": {},
Expand Down
Loading