diff --git a/src/openhuman/tool_registry/mod.rs b/src/openhuman/tool_registry/mod.rs index 2116419f67..208c603898 100644 --- a/src/openhuman/tool_registry/mod.rs +++ b/src/openhuman/tool_registry/mod.rs @@ -9,4 +9,7 @@ pub use schemas::{ all_controller_schemas as all_tool_registry_controller_schemas, all_registered_controllers as all_tool_registry_registered_controllers, }; -pub use types::{ToolRegistryEntry, ToolRegistryHealth, ToolRegistryList, ToolRegistryTransport}; +pub use types::{ + ToolPolicyDiagnostics, ToolRegistryEntry, ToolRegistryHealth, ToolRegistryList, + ToolRegistryTransport, +}; diff --git a/src/openhuman/tool_registry/ops.rs b/src/openhuman/tool_registry/ops.rs index a91284adc2..a94b030e9d 100644 --- a/src/openhuman/tool_registry/ops.rs +++ b/src/openhuman/tool_registry/ops.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use serde_json::{json, Map, Value}; @@ -8,10 +8,20 @@ use crate::openhuman::mcp_server::McpToolSpec; use crate::rpc::RpcOutcome; use super::types::{ - ToolRegistryEntry, ToolRegistryHealth, ToolRegistryList, ToolRegistryTransport, + ToolPolicyDiagnostics, ToolRegistryEntry, ToolRegistryHealth, ToolRegistryList, + ToolRegistryTransport, }; const REGISTRY_ENTRY_VERSION: &str = env!("CARGO_PKG_VERSION"); +const POLICY_SURFACES: &[&str] = &[ + "security.policy_info", + "approval.list_pending", + "approval.list_recent_decisions", + "approval.decide", + "tool_registry.list", + "tool_registry.get", + "tool_registry.diagnostics", +]; /// Return the current read-only tool registry snapshot. pub fn list_tools() -> RpcOutcome { @@ -23,6 +33,37 @@ pub fn list_tools() -> RpcOutcome { RpcOutcome::new(ToolRegistryList { tools }, vec![]) } +/// Return redacted diagnostics for policy/tool visibility reviews. +pub fn diagnostics() -> RpcOutcome { + let tools = registry_entries(); + let total_tools = tools.len(); + let enabled_tools = tools.iter().filter(|entry| entry.enabled).count(); + let mcp_stdio_tools = tools + .iter() + .filter(|entry| entry.transport == ToolRegistryTransport::McpStdio) + .count(); + let json_rpc_tools = tools + .iter() + .filter(|entry| entry.transport == ToolRegistryTransport::JsonRpc) + .count(); + let possible_write_surfaces = tools + .iter() + .filter(|entry| looks_write_capable(&entry.tool_id)) + .map(|entry| entry.tool_id.clone()) + .collect::>(); + let policy_surfaces = policy_surface_ids(); + + let diagnostics = ToolPolicyDiagnostics { + total_tools, + enabled_tools, + mcp_stdio_tools, + json_rpc_tools, + possible_write_surfaces, + policy_surfaces, + }; + RpcOutcome::new(diagnostics, vec![]) +} + /// Look up one registry entry by stable `tool_id`. pub fn get_tool(tool_id: &str) -> Result, String> { let normalized = tool_id.trim(); @@ -287,6 +328,44 @@ fn push_unique(tags: &mut Vec, tag: &str) { } } +fn looks_write_capable(tool_id: &str) -> bool { + const MARKERS: &[&str] = &[ + "add", "apply", "create", "decide", "delete", "email", "execute", "forget", "ingest", + "post", "put", "remove", "run", "send", "store", "update", "write", + ]; + let lower = tool_id.to_ascii_lowercase(); + MARKERS.iter().any(|marker| { + lower == *marker + || lower.contains(&format!(".{marker}")) + || lower.contains(&format!("_{marker}")) + || lower.contains(&format!("{marker}.")) + || lower.contains(&format!("{marker}_")) + }) +} + +fn policy_surface_ids() -> Vec { + let mut ids = POLICY_SURFACES + .iter() + .copied() + .map(String::from) + .collect::>(); + + ids.extend( + all::all_controller_schemas() + .into_iter() + .map(|schema| schema.method_name()) + .filter(|tool_id| is_policy_surface(tool_id)), + ); + + ids.into_iter().collect() +} + +fn is_policy_surface(tool_id: &str) -> bool { + POLICY_SURFACES.contains(&tool_id) + || tool_id.starts_with("security.") + || tool_id.starts_with("approval.") +} + fn title_from_function(function: &str) -> String { function .split('_') @@ -345,6 +424,42 @@ mod tests { assert_eq!(ids, sorted); } + #[test] + fn diagnostics_reports_inventory_and_policy_surfaces() { + let outcome = diagnostics(); + + assert!(outcome.value.total_tools > 0); + assert_eq!(outcome.value.total_tools, outcome.value.enabled_tools); + assert!(outcome.value.mcp_stdio_tools > 0); + assert!(outcome.value.json_rpc_tools > 0); + assert!(outcome + .value + .policy_surfaces + .iter() + .any(|tool_id| tool_id == "security.policy_info")); + assert!(outcome + .value + .possible_write_surfaces + .iter() + .any(|tool_id| tool_id == "tools.composio_execute")); + } + + #[test] + fn looks_write_capable_detects_action_prefixes_and_suffixes() { + assert!(looks_write_capable("user.create")); + assert!(looks_write_capable("create.user")); + assert!(looks_write_capable("tools.composio_execute")); + assert!(!looks_write_capable("tools.search")); + } + + #[test] + fn is_policy_surface_includes_policy_namespaces() { + assert!(is_policy_surface("security.audit_status")); + assert!(is_policy_surface("approval.request")); + assert!(is_policy_surface("tool_registry.diagnostics")); + assert!(!is_policy_surface("tools.web_search")); + } + #[test] fn insert_registry_entry_skips_duplicate_tool_id() { let mut entries = BTreeMap::new(); diff --git a/src/openhuman/tool_registry/schemas.rs b/src/openhuman/tool_registry/schemas.rs index 4094b651c4..f3b1750a8f 100644 --- a/src/openhuman/tool_registry/schemas.rs +++ b/src/openhuman/tool_registry/schemas.rs @@ -6,7 +6,7 @@ use crate::rpc::RpcOutcome; /// Declared controller schemas for the `tool_registry` namespace. pub fn all_controller_schemas() -> Vec { - vec![schemas("list"), schemas("get")] + vec![schemas("list"), schemas("get"), schemas("diagnostics")] } /// Registered controller handlers for the `tool_registry` namespace. @@ -20,6 +20,10 @@ pub fn all_registered_controllers() -> Vec { schema: schemas("get"), handler: handle_get, }, + RegisteredController { + schema: schemas("diagnostics"), + handler: handle_diagnostics, + }, ] } @@ -55,6 +59,18 @@ pub fn schemas(function: &str) -> ControllerSchema { required: true, }], }, + "diagnostics" => ControllerSchema { + namespace: "tool_registry", + function: "diagnostics", + description: "Return redacted tool inventory and policy visibility diagnostics.", + inputs: vec![], + outputs: vec![FieldSchema { + name: "diagnostics", + ty: TypeSchema::Json, + comment: "Counts and redacted tool ids useful for policy/conformance checks.", + required: true, + }], + }, _ => ControllerSchema { namespace: "tool_registry", function: "unknown", @@ -88,6 +104,21 @@ fn handle_get(params: Map) -> ControllerFuture { }) } +fn handle_diagnostics(params: Map) -> ControllerFuture { + Box::pin(async move { + log::debug!( + "[tool_registry] rpc diagnostics requested param_count={}", + params.len() + ); + let result = to_json(crate::openhuman::tool_registry::ops::diagnostics()); + log::debug!( + "[tool_registry] rpc diagnostics completed success={}", + result.is_ok() + ); + result + }) +} + fn required_tool_id(params: &Map) -> Result<&str, String> { params .get("tool_id") @@ -111,10 +142,11 @@ mod tests { let schemas = all_controller_schemas(); let controllers = all_registered_controllers(); - assert_eq!(schemas.len(), 2); - assert_eq!(controllers.len(), 2); + assert_eq!(schemas.len(), 3); + assert_eq!(controllers.len(), 3); assert_eq!(schemas[0].function, controllers[0].schema.function); assert_eq!(schemas[1].function, controllers[1].schema.function); + assert_eq!(schemas[2].function, controllers[2].schema.function); } #[test] @@ -133,6 +165,15 @@ mod tests { assert!(schema.inputs[0].required); } + #[test] + fn diagnostics_schema_has_no_inputs() { + let schema = schemas("diagnostics"); + assert_eq!(schema.namespace, "tool_registry"); + assert_eq!(schema.function, "diagnostics"); + assert!(schema.inputs.is_empty()); + assert_eq!(schema.outputs[0].name, "diagnostics"); + } + #[test] fn required_tool_id_rejects_wrong_type() { let mut params = Map::new(); @@ -165,4 +206,20 @@ mod tests { Some("tools.web_search") ); } + + #[tokio::test] + async fn handle_diagnostics_returns_counts() { + let value = handle_diagnostics(Map::new()) + .await + .expect("diagnostics json"); + let diagnostics = value.get("diagnostics").unwrap_or(&value); + assert!(diagnostics + .get("total_tools") + .and_then(Value::as_u64) + .is_some_and(|count| count > 0)); + assert!(diagnostics + .get("policy_surfaces") + .and_then(Value::as_array) + .is_some()); + } } diff --git a/src/openhuman/tool_registry/types.rs b/src/openhuman/tool_registry/types.rs index 37a4a25096..200e68ac62 100644 --- a/src/openhuman/tool_registry/types.rs +++ b/src/openhuman/tool_registry/types.rs @@ -58,3 +58,14 @@ pub struct ToolRegistryList { /// Sorted registry entries. pub tools: Vec, } + +/// Redacted diagnostics for policy/tool visibility reviews. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct ToolPolicyDiagnostics { + pub total_tools: usize, + pub enabled_tools: usize, + pub mcp_stdio_tools: usize, + pub json_rpc_tools: usize, + pub possible_write_surfaces: Vec, + pub policy_surfaces: Vec, +}