use std::collections::HashMap; use crate::config; use crate::config::extensions::name_to_key; use crate::config::permission::PermissionLevel; use crate::config::Config; use rmcp::model::Tool; use rmcp::service::ClientInitializeError; use rmcp::ServiceError as ClientError; use serde::Deserializer; use serde::{Deserialize, Serialize}; use thiserror::Error; use tracing::warn; use utoipa::ToSchema; pub use crate::agents::platform_extensions::{ PlatformExtensionContext, PlatformExtensionDef, PLATFORM_EXTENSIONS, }; #[derive(Error, Debug)] #[error("process quit before initialization: stderr = {stderr}")] pub struct ProcessExit { stderr: String, #[source] source: ClientInitializeError, } impl ProcessExit { pub fn new(stderr: T, source: ClientInitializeError) -> Self where T: Into, { ProcessExit { stderr: stderr.into(), source, } } } /// Errors from Extension operation #[derive(Error, Debug)] pub enum ExtensionError { #[error("failed a client call to an MCP server: {0}")] Client(#[from] ClientError), #[error("invalid config: {0}")] ConfigError(String), #[error("error during extension setup: {0}")] SetupError(String), #[error("join error occurred during task execution: {0}")] TaskJoinError(#[from] tokio::task::JoinError), #[error("IO error: {0}")] IoError(#[from] std::io::Error), #[error("failed to initialize MCP client: {0}")] InitializeError(#[from] ClientInitializeError), #[error("{0}")] ProcessExit(#[from] ProcessExit), } pub type ExtensionResult = Result; #[derive(Debug, Clone, Deserialize, Serialize, Default, ToSchema, PartialEq)] pub struct Envs { /// A map of environment variables to set, e.g. API_KEY -> some_secret, HOST -> host #[serde(default)] #[serde(flatten)] map: HashMap, } impl Envs { /// List of sensitive env vars that should not be overridden const DISALLOWED_KEYS: [&'static str; 31] = [ // 🔧 Binary path manipulation "PATH", // Controls executable lookup paths — critical for command hijacking "PATHEXT", // Windows: Determines recognized executable extensions (e.g., .exe, .bat) "SystemRoot", // Windows: Can affect system DLL resolution (e.g., `kernel32.dll`) "windir", // Windows: Alternative to SystemRoot (used in legacy apps) // 🧬 Dynamic linker hijacking (Linux/macOS) "LD_LIBRARY_PATH", // Alters shared library resolution "LD_PRELOAD", // Forces preloading of shared libraries — common attack vector "LD_AUDIT", // Loads a monitoring library that can intercept execution "LD_DEBUG", // Enables verbose linker logging (information disclosure risk) "LD_BIND_NOW", // Forces immediate symbol resolution, affecting ASLR "LD_ASSUME_KERNEL", // Tricks linker into thinking it's running on an older kernel // 🍎 macOS dynamic linker variables "DYLD_LIBRARY_PATH", // Same as LD_LIBRARY_PATH but for macOS "DYLD_INSERT_LIBRARIES", // macOS equivalent of LD_PRELOAD "DYLD_FRAMEWORK_PATH", // Overrides framework lookup paths // 🐍 Python / Node / Ruby / Java / Golang hijacking "PYTHONPATH", // Overrides Python module resolution "PYTHONHOME", // Overrides Python root directory "NODE_OPTIONS", // Injects options/scripts into every Node.js process "RUBYOPT", // Injects Ruby execution flags "GEM_PATH", // Alters where RubyGems looks for installed packages "GEM_HOME", // Changes RubyGems default install location "CLASSPATH", // Java: Controls where classes are loaded from — critical for RCE attacks "GO111MODULE", // Go: Forces use of module proxy or disables it "GOROOT", // Go: Changes root installation directory (could lead to execution hijacking) // 🖥️ Windows-specific process & DLL hijacking "APPINIT_DLLS", // Forces Windows to load a DLL into every process "SESSIONNAME", // Affects Windows session configuration "ComSpec", // Determines default command interpreter (can replace `cmd.exe`) "TEMP", "TMP", // Redirects temporary file storage (useful for injection attacks) "LOCALAPPDATA", // Controls application data paths (can be abused for persistence) "USERPROFILE", // Windows user directory (can affect profile-based execution paths) "HOMEDRIVE", "HOMEPATH", // Changes where the user's home directory is located ]; /// Constructs a new Envs, skipping disallowed env vars with a warning pub fn new(map: HashMap) -> Self { let mut validated = HashMap::new(); for (key, value) in map { if Self::is_disallowed(&key) { warn!("Skipping disallowed env var: {}", key); continue; } validated.insert(key, value); } Self { map: validated } } /// Returns a copy of the validated env vars pub fn get_env(&self) -> HashMap { self.map.clone() } /// Returns an error if any disallowed env var is present pub fn validate(&self) -> Result<(), Box> { for key in self.map.keys() { if Self::is_disallowed(key) { return Err(Box::new(ExtensionError::ConfigError(format!( "environment variable {} not allowed to be overwritten", key )))); } } Ok(()) } fn is_disallowed(key: &str) -> bool { Self::DISALLOWED_KEYS .iter() .any(|disallowed| disallowed.eq_ignore_ascii_case(key)) } } /// Represents the different types of MCP extensions that can be added to the manager #[derive(Debug, Clone, Deserialize, Serialize, ToSchema, PartialEq)] #[serde(tag = "type")] pub enum ExtensionConfig { /// SSE transport is no longer supported - kept only for config file compatibility #[serde(rename = "sse")] Sse { #[serde(default)] #[schema(required)] name: String, #[serde(default)] #[serde(deserialize_with = "deserialize_null_with_default")] #[schema(required)] description: String, #[serde(default)] uri: Option, }, /// Standard I/O client with command and arguments #[serde(rename = "stdio")] Stdio { /// The name used to identify this extension name: String, #[serde(default)] #[serde(deserialize_with = "deserialize_null_with_default")] #[schema(required)] description: String, cmd: String, args: Vec, #[serde(default)] envs: Envs, #[serde(default)] env_keys: Vec, timeout: Option, #[serde(default)] bundled: Option, #[serde(default)] available_tools: Vec, }, /// Built-in extension that is part of the bundled goose MCP server #[serde(rename = "builtin")] Builtin { /// The name used to identify this extension name: String, #[serde(default)] #[serde(deserialize_with = "deserialize_null_with_default")] #[schema(required)] description: String, display_name: Option, // needed for the UI timeout: Option, #[serde(default)] bundled: Option, #[serde(default)] available_tools: Vec, }, /// Platform extensions that have direct access to the agent etc and run in the agent process #[serde(rename = "platform")] Platform { /// The name used to identify this extension name: String, #[serde(default)] #[serde(deserialize_with = "deserialize_null_with_default")] #[schema(required)] description: String, display_name: Option, #[serde(default)] bundled: Option, #[serde(default)] available_tools: Vec, }, /// Streamable HTTP client with a URI endpoint using MCP Streamable HTTP specification #[serde(rename = "streamable_http")] StreamableHttp { /// The name used to identify this extension name: String, #[serde(default)] #[serde(deserialize_with = "deserialize_null_with_default")] #[schema(required)] description: String, uri: String, #[serde(default)] envs: Envs, #[serde(default)] env_keys: Vec, #[serde(default)] headers: HashMap, // NOTE: set timeout to be optional for compatibility. // However, new configurations should include this field. timeout: Option, #[serde(default)] bundled: Option, #[serde(default)] available_tools: Vec, }, /// Frontend-provided tools that will be called through the frontend #[serde(rename = "frontend")] Frontend { /// The name used to identify this extension name: String, #[serde(default)] #[serde(deserialize_with = "deserialize_null_with_default")] #[schema(required)] description: String, /// The tools provided by the frontend tools: Vec, /// Instructions for how to use these tools instructions: Option, #[serde(default)] bundled: Option, #[serde(default)] available_tools: Vec, }, /// Inline Python code that will be executed using uvx #[serde(rename = "inline_python")] InlinePython { /// The name used to identify this extension name: String, #[serde(default)] #[serde(deserialize_with = "deserialize_null_with_default")] #[schema(required)] description: String, /// The Python code to execute code: String, /// Timeout in seconds timeout: Option, /// Python package dependencies required by this extension #[serde(default)] dependencies: Option>, #[serde(default)] available_tools: Vec, }, } impl Default for ExtensionConfig { fn default() -> Self { Self::Builtin { name: config::DEFAULT_EXTENSION.to_string(), display_name: Some(config::DEFAULT_DISPLAY_NAME.to_string()), description: "default".to_string(), timeout: Some(config::DEFAULT_EXTENSION_TIMEOUT), bundled: Some(true), available_tools: Vec::new(), } } } impl ExtensionConfig { pub fn streamable_http, T: Into>( name: S, uri: S, description: S, timeout: T, ) -> Self { Self::StreamableHttp { name: name.into(), uri: uri.into(), envs: Envs::default(), env_keys: Vec::new(), headers: HashMap::new(), description: description.into(), timeout: Some(timeout.into()), bundled: None, available_tools: Vec::new(), } } pub fn stdio, T: Into>( name: S, cmd: S, description: S, timeout: T, ) -> Self { Self::Stdio { name: name.into(), cmd: cmd.into(), args: vec![], envs: Envs::default(), env_keys: Vec::new(), description: description.into(), timeout: Some(timeout.into()), bundled: None, available_tools: Vec::new(), } } pub fn inline_python, T: Into>( name: S, code: S, description: S, timeout: T, ) -> Self { Self::InlinePython { name: name.into(), code: code.into(), description: description.into(), timeout: Some(timeout.into()), dependencies: None, available_tools: Vec::new(), } } pub fn with_args(self, args: I) -> Self where I: IntoIterator, S: Into, { match self { Self::Stdio { name, cmd, envs, env_keys, timeout, description, bundled, available_tools, .. } => Self::Stdio { name, cmd, envs, env_keys, args: args.into_iter().map(Into::into).collect(), description, timeout, bundled, available_tools, }, other => other, } } pub fn key(&self) -> String { name_to_key(&self.name()) } pub fn name(&self) -> String { match self { Self::Sse { name, .. } => name, Self::StreamableHttp { name, .. } => name, Self::Stdio { name, .. } => name, Self::Builtin { name, .. } => name, Self::Platform { name, .. } => name, Self::Frontend { name, .. } => name, Self::InlinePython { name, .. } => name, } .to_string() } /// Check if a tool should be available to the LLM pub fn is_tool_available(&self, tool_name: &str) -> bool { let available_tools = match self { Self::Sse { .. } => return false, // SSE is unsupported Self::StreamableHttp { available_tools, .. } | Self::Stdio { available_tools, .. } | Self::Builtin { available_tools, .. } | Self::Platform { available_tools, .. } | Self::InlinePython { available_tools, .. } | Self::Frontend { available_tools, .. } => available_tools, }; // If no tools are specified, all tools are available // If tools are specified, only those tools are available available_tools.is_empty() || available_tools.contains(&tool_name.to_string()) } pub async fn resolve(self, config: &Config) -> ExtensionResult { use crate::agents::extension_manager::{merge_environments, substitute_env_vars}; match self { Self::Stdio { name, description, cmd, args, envs, env_keys, timeout, bundled, available_tools, } => { let merged = merge_environments(&envs, &env_keys, &name, config).await?; Ok(Self::Stdio { name, description, cmd, args, envs: Envs::new(merged), env_keys: vec![], timeout, bundled, available_tools, }) } Self::StreamableHttp { name, description, uri, envs, env_keys, headers, timeout, bundled, available_tools, } => { let merged = merge_environments(&envs, &env_keys, &name, config).await?; let headers = headers .into_iter() .map(|(k, v)| { let v = substitute_env_vars(&v, &merged); (k, v) }) .collect(); Ok(Self::StreamableHttp { name, description, uri: substitute_env_vars(&uri, &merged), envs: Envs::new(merged), env_keys: vec![], headers, timeout, bundled, available_tools, }) } other => Ok(other), } } } impl std::fmt::Display for ExtensionConfig { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { ExtensionConfig::Sse { name, .. } => { write!(f, "SSE({}: unsupported)", name) } ExtensionConfig::StreamableHttp { name, uri, .. } => { write!(f, "StreamableHttp({}: {})", name, uri) } ExtensionConfig::Stdio { name, cmd, args, .. } => { write!(f, "Stdio({}: {} {})", name, cmd, args.join(" ")) } ExtensionConfig::Builtin { name, .. } => write!(f, "Builtin({})", name), ExtensionConfig::Platform { name, .. } => write!(f, "Platform({})", name), ExtensionConfig::Frontend { name, tools, .. } => { write!(f, "Frontend({}: {} tools)", name, tools.len()) } ExtensionConfig::InlinePython { name, code, .. } => { write!(f, "InlinePython({}: {} chars)", name, code.len()) } } } } /// Information about the extension used for building prompts #[derive(Clone, Debug, Serialize)] pub struct ExtensionInfo { pub name: String, pub instructions: String, pub has_resources: bool, } impl ExtensionInfo { pub fn new(name: &str, instructions: &str, has_resources: bool) -> Self { Self { name: name.to_string(), instructions: instructions.to_string(), has_resources, } } } fn deserialize_null_with_default<'de, D, T>(deserializer: D) -> Result where T: Default + Deserialize<'de>, D: Deserializer<'de>, { let opt = Option::deserialize(deserializer)?; Ok(opt.unwrap_or_default()) } /// Information about the tool used for building prompts #[derive(Clone, Debug, Serialize, ToSchema)] pub struct ToolInfo { pub name: String, pub description: String, pub parameters: Vec, pub permission: Option, } impl ToolInfo { pub fn new( name: &str, description: &str, parameters: Vec, permission: Option, ) -> Self { Self { name: name.to_string(), description: description.to_string(), parameters, permission, } } } #[cfg(test)] mod tests { use crate::agents::*; use crate::config; use test_case::test_case; #[test] fn test_deserialize_missing_description() { let config: ExtensionConfig = serde_yaml::from_str( "enabled: true type: builtin name: developer display_name: Developer timeout: 300 bundled: true available_tools: []", ) .unwrap(); if let ExtensionConfig::Builtin { description, .. } = config { assert_eq!(description, "") } else { panic!("unexpected result of deserialization: {}", config) } } #[test] fn test_deserialize_null_description() { let config: ExtensionConfig = serde_yaml::from_str( "enabled: true type: builtin name: developer display_name: Developer description: null timeout: 300 bundled: true available_tools: [] ", ) .unwrap(); if let ExtensionConfig::Builtin { description, .. } = config { assert_eq!(description, "") } else { panic!("unexpected result of deserialization: {}", config) } } #[test] fn test_deserialize_normal_description() { let config: ExtensionConfig = serde_yaml::from_str( "enabled: true type: builtin name: developer display_name: Developer description: description goes here timeout: 300 bundled: true available_tools: [] ", ) .unwrap(); if let ExtensionConfig::Builtin { description, .. } = config { assert_eq!(description, "description goes here") } else { panic!("unexpected result of deserialization: {}", config) } } #[test_case( ExtensionConfig::Builtin { name: "developer".into(), description: "dev".into(), display_name: None, timeout: None, bundled: None, available_tools: vec![], }, ExtensionConfig::Builtin { name: "developer".into(), description: "dev".into(), display_name: None, timeout: None, bundled: None, available_tools: vec![], } ; "builtin_unchanged" )] #[test_case( ExtensionConfig::StreamableHttp { name: "test".into(), description: String::new(), uri: "https://example.com".into(), envs: extension::Envs::new({ let mut m = std::collections::HashMap::new(); m.insert("AUTH_TOKEN".to_string(), "secret".to_string()); m }), env_keys: vec![], headers: [( "Authorization".to_string(), "Bearer $AUTH_TOKEN".to_string(), )] .into_iter() .collect(), timeout: None, bundled: None, available_tools: vec![], }, ExtensionConfig::StreamableHttp { name: "test".into(), description: String::new(), uri: "https://example.com".into(), envs: extension::Envs::new({ let mut m = std::collections::HashMap::new(); m.insert("AUTH_TOKEN".to_string(), "secret".to_string()); m }), env_keys: vec![], headers: [( "Authorization".to_string(), "Bearer secret".to_string(), )] .into_iter() .collect(), timeout: None, bundled: None, available_tools: vec![], } ; "header_substitution" )] #[test_case( ExtensionConfig::Stdio { name: "test".into(), description: String::new(), cmd: "echo".into(), args: vec![], envs: extension::Envs::default(), env_keys: vec![], timeout: None, bundled: None, available_tools: vec![], }, ExtensionConfig::Stdio { name: "test".into(), description: String::new(), cmd: "echo".into(), args: vec![], envs: extension::Envs::default(), env_keys: vec![], timeout: None, bundled: None, available_tools: vec![], } ; "env_keys_cleared" )] #[test_case( ExtensionConfig::Stdio { name: "test".into(), description: String::new(), cmd: "echo".into(), args: vec![], envs: extension::Envs::default(), env_keys: vec!["MY_SECRET".into()], timeout: None, bundled: None, available_tools: vec![], }, ExtensionConfig::Stdio { name: "test".into(), description: String::new(), cmd: "echo".into(), args: vec![], envs: extension::Envs::new({ let mut m = std::collections::HashMap::new(); m.insert("MY_SECRET".to_string(), "secret_value".to_string()); m }), env_keys: vec![], timeout: None, bundled: None, available_tools: vec![], } ; "env_key_resolved" )] #[test_case( ExtensionConfig::StreamableHttp { name: "test".into(), description: String::new(), uri: "https://example.com".into(), envs: extension::Envs::default(), env_keys: vec!["MY_SECRET".into()], headers: [( "Authorization".to_string(), "Bearer $MY_SECRET".to_string(), )] .into_iter() .collect(), timeout: None, bundled: None, available_tools: vec![], }, ExtensionConfig::StreamableHttp { name: "test".into(), description: String::new(), uri: "https://example.com".into(), envs: extension::Envs::new({ let mut m = std::collections::HashMap::new(); m.insert("MY_SECRET".to_string(), "secret_value".to_string()); m }), env_keys: vec![], headers: [("Authorization".to_string(), "Bearer secret_value".to_string())] .into_iter() .collect(), timeout: None, bundled: None, available_tools: vec![], } ; "http_env_key_and_header_substitution" )] #[test_case( ExtensionConfig::StreamableHttp { name: "test".into(), description: String::new(), uri: "https://example.com/mcp?api_key=$MY_SECRET".into(), envs: extension::Envs::default(), env_keys: vec!["MY_SECRET".into()], headers: std::collections::HashMap::new(), timeout: None, bundled: None, available_tools: vec![], }, ExtensionConfig::StreamableHttp { name: "test".into(), description: String::new(), uri: "https://example.com/mcp?api_key=secret_value".into(), envs: extension::Envs::new({ let mut m = std::collections::HashMap::new(); m.insert("MY_SECRET".to_string(), "secret_value".to_string()); m }), env_keys: vec![], headers: std::collections::HashMap::new(), timeout: None, bundled: None, available_tools: vec![], } ; "http_env_key_uri_substitution" )] #[test_case( ExtensionConfig::Stdio { name: "test".into(), description: String::new(), cmd: "echo".into(), args: vec![], envs: extension::Envs::new({ let mut m = std::collections::HashMap::new(); m.insert("MY_SECRET".to_string(), "original".to_string()); m }), env_keys: vec!["MY_SECRET".into()], timeout: None, bundled: None, available_tools: vec![], }, ExtensionConfig::Stdio { name: "test".into(), description: String::new(), cmd: "echo".into(), args: vec![], envs: extension::Envs::new({ let mut m = std::collections::HashMap::new(); m.insert("MY_SECRET".to_string(), "original".to_string()); m }), env_keys: vec![], timeout: None, bundled: None, available_tools: vec![], } ; "env_key_skipped_when_already_in_envs" )] #[tokio::test] async fn test_resolve(config: ExtensionConfig, expected: ExtensionConfig) { let dir = tempfile::tempdir().unwrap(); let cfg = config::Config::new_with_file_secrets( dir.path().join("config.yaml"), dir.path().join("secrets.yaml"), ) .unwrap(); cfg.set("MY_SECRET", &"secret_value", true).unwrap(); assert_eq!(config.resolve(&cfg).await.unwrap(), expected); } }