use std::{env, path::PathBuf};
use anyhow::{Result, bail};
#[derive(Debug, Clone, Default)]
pub struct SshResolvedBinPaths {
pub ssh: Option<PathBuf>,
pub sshfs: Option<PathBuf>,
pub umount: Option<PathBuf>,
pub diskutil: Option<PathBuf>,
}
#[derive(Debug, Clone)]
pub struct SshConfig {
pub ssh_bin_path: Option<PathBuf>,
pub sshfs_bin_path: Option<PathBuf>,
pub umount_bin_path: Option<PathBuf>,
pub diskutil_bin_path: Option<PathBuf>,
pub macos_block_apple_metadata: bool,
pub managed_mount_root: Option<PathBuf>,
pub allowed_hosts: Vec<String>,
pub denied_hosts: Vec<String>,
pub allowed_users: Vec<String>,
pub allowed_auth_kinds: Vec<String>,
pub allowed_tunnel_bind_hosts: Vec<String>,
pub allow_explicit_mount_paths: bool,
pub allowed_mount_roots: Vec<PathBuf>,
pub port_min: u16,
pub port_max: u16,
}
impl Default for SshConfig {
fn default() -> Self {
Self {
ssh_bin_path: resolve_bin_path(None, "ssh", ssh_default_paths()),
sshfs_bin_path: resolve_bin_path(None, "sshfs", sshfs_default_paths()),
umount_bin_path: resolve_bin_path(None, "umount", umount_default_paths()),
diskutil_bin_path: resolve_bin_path(None, "diskutil", diskutil_default_paths()),
macos_block_apple_metadata: default_macos_block_apple_metadata(),
managed_mount_root: None,
allowed_hosts: Vec::new(),
denied_hosts: Vec::new(),
allowed_users: Vec::new(),
allowed_auth_kinds: Vec::new(),
allowed_tunnel_bind_hosts: vec![
"127.0.0.1".to_string(),
"::1".to_string(),
"localhost".to_string(),
],
allow_explicit_mount_paths: true,
allowed_mount_roots: Vec::new(),
port_min: 1,
port_max: u16::MAX,
}
}
}
impl SshConfig {
pub fn resolved_ssh_bin_path(&self) -> Option<PathBuf> {
resolve_bin_path(self.ssh_bin_path.clone(), "ssh", ssh_default_paths())
}
pub fn resolved_sshfs_bin_path(&self) -> Option<PathBuf> {
resolve_bin_path(self.sshfs_bin_path.clone(), "sshfs", sshfs_default_paths())
}
pub fn resolved_umount_bin_path(&self) -> Option<PathBuf> {
resolve_bin_path(
self.umount_bin_path.clone(),
"umount",
umount_default_paths(),
)
}
pub fn resolved_diskutil_bin_path(&self) -> Option<PathBuf> {
resolve_bin_path(
self.diskutil_bin_path.clone(),
"diskutil",
diskutil_default_paths(),
)
}
pub fn resolved_bin_paths(&self) -> SshResolvedBinPaths {
SshResolvedBinPaths {
ssh: self.resolved_ssh_bin_path(),
sshfs: self.resolved_sshfs_bin_path(),
umount: self.resolved_umount_bin_path(),
diskutil: self.resolved_diskutil_bin_path(),
}
}
}
#[derive(Debug, Clone)]
pub struct Config {
pub session_limit: usize,
pub default_read_limit: usize,
pub max_buffer_lines: usize,
pub allowed_cwd_roots: Vec<PathBuf>,
pub allowed_commands: Vec<String>,
pub denied_commands: Vec<String>,
pub allowed_env_vars: Vec<String>,
pub denied_env_vars: Vec<String>,
pub ssh: SshConfig,
}
impl Default for Config {
fn default() -> Self {
Self {
session_limit: 32,
default_read_limit: 200,
max_buffer_lines: 50_000,
allowed_cwd_roots: vec![env::current_dir().unwrap_or_else(|_| PathBuf::from("."))],
allowed_commands: Vec::new(),
denied_commands: Vec::new(),
allowed_env_vars: Vec::new(),
denied_env_vars: vec![
"LD_PRELOAD".to_string(),
"LD_LIBRARY_PATH".to_string(),
"DYLD_INSERT_LIBRARIES".to_string(),
"DYLD_LIBRARY_PATH".to_string(),
],
ssh: SshConfig::default(),
}
}
}
impl Config {
pub fn from_env() -> Result<Self> {
let mut config = Self::default();
if let Ok(value) = env::var("PTY_MCP_SESSION_LIMIT") {
config.session_limit = parse_usize("PTY_MCP_SESSION_LIMIT", &value)?;
}
if let Ok(value) = env::var("PTY_MCP_DEFAULT_READ_LIMIT") {
config.default_read_limit = parse_usize("PTY_MCP_DEFAULT_READ_LIMIT", &value)?;
}
if let Ok(value) = env::var("PTY_MCP_MAX_BUFFER_LINES") {
config.max_buffer_lines = parse_usize("PTY_MCP_MAX_BUFFER_LINES", &value)?;
}
if let Ok(value) = env::var("PTY_MCP_ALLOWED_CWD_ROOTS") {
config.allowed_cwd_roots = value
.split(':')
.filter(|segment| !segment.trim().is_empty())
.map(PathBuf::from)
.collect();
}
if let Ok(value) = env::var("PTY_MCP_ALLOWED_COMMANDS") {
config.allowed_commands = parse_csv(&value);
}
if let Ok(value) = env::var("PTY_MCP_DENIED_COMMANDS") {
config.denied_commands = parse_csv(&value);
}
if let Ok(value) = env::var("PTY_MCP_ALLOWED_ENV_VARS") {
config.allowed_env_vars = parse_csv(&value);
}
if let Ok(value) = env::var("PTY_MCP_DENIED_ENV_VARS") {
config.denied_env_vars = parse_csv(&value);
}
if let Ok(value) = env::var("PTY_MCP_SSH_BIN_PATH") {
config.ssh.ssh_bin_path =
resolve_bin_path(Some(PathBuf::from(value)), "ssh", ssh_default_paths());
}
if let Ok(value) = env::var("PTY_MCP_SSHFS_BIN_PATH") {
config.ssh.sshfs_bin_path =
resolve_bin_path(Some(PathBuf::from(value)), "sshfs", sshfs_default_paths());
}
if let Ok(value) = env::var("PTY_MCP_UMOUNT_BIN_PATH") {
config.ssh.umount_bin_path =
resolve_bin_path(Some(PathBuf::from(value)), "umount", umount_default_paths());
}
if let Ok(value) = env::var("PTY_MCP_DISKUTIL_BIN_PATH") {
config.ssh.diskutil_bin_path = resolve_bin_path(
Some(PathBuf::from(value)),
"diskutil",
diskutil_default_paths(),
);
}
if let Ok(value) = env::var("PTY_MCP_SSH_MACOS_BLOCK_APPLE_METADATA") {
config.ssh.macos_block_apple_metadata =
parse_bool("PTY_MCP_SSH_MACOS_BLOCK_APPLE_METADATA", &value)?;
}
if let Ok(value) = env::var("PTY_MCP_SSH_MANAGED_MOUNT_ROOT") {
let trimmed = value.trim();
if !trimmed.is_empty() {
let managed_mount_root = PathBuf::from(trimmed);
if !config.allowed_cwd_roots.contains(&managed_mount_root) {
config.allowed_cwd_roots.push(managed_mount_root.clone());
}
config.ssh.managed_mount_root = Some(managed_mount_root);
}
}
if let Ok(value) = env::var("PTY_MCP_SSH_ALLOWED_HOSTS") {
config.ssh.allowed_hosts = parse_csv(&value);
}
if let Ok(value) = env::var("PTY_MCP_SSH_DENIED_HOSTS") {
config.ssh.denied_hosts = parse_csv(&value);
}
if let Ok(value) = env::var("PTY_MCP_SSH_ALLOWED_USERS") {
config.ssh.allowed_users = parse_csv(&value);
}
if let Ok(value) = env::var("PTY_MCP_SSH_ALLOWED_AUTH_KINDS") {
config.ssh.allowed_auth_kinds =
parse_auth_kinds("PTY_MCP_SSH_ALLOWED_AUTH_KINDS", &value)?;
}
if let Ok(value) = env::var("PTY_MCP_SSH_ALLOWED_TUNNEL_BIND_HOSTS") {
config.ssh.allowed_tunnel_bind_hosts = parse_csv(&value);
}
if let Ok(value) = env::var("PTY_MCP_SSH_ALLOW_EXPLICIT_MOUNT_PATHS") {
config.ssh.allow_explicit_mount_paths =
parse_bool("PTY_MCP_SSH_ALLOW_EXPLICIT_MOUNT_PATHS", &value)?;
}
if let Ok(value) = env::var("PTY_MCP_SSH_ALLOWED_MOUNT_ROOTS") {
config.ssh.allowed_mount_roots = parse_path_list(&value);
}
if let Ok(value) = env::var("PTY_MCP_SSH_PORT_MIN") {
config.ssh.port_min = parse_u16("PTY_MCP_SSH_PORT_MIN", &value)?;
}
if let Ok(value) = env::var("PTY_MCP_SSH_PORT_MAX") {
config.ssh.port_max = parse_u16("PTY_MCP_SSH_PORT_MAX", &value)?;
}
if config.ssh.port_min > config.ssh.port_max {
bail!(
"invalid SSH port range: PTY_MCP_SSH_PORT_MIN={} is greater than PTY_MCP_SSH_PORT_MAX={}",
config.ssh.port_min,
config.ssh.port_max
);
}
if config.ssh.allowed_mount_roots.is_empty() {
config.ssh.allowed_mount_roots = config.allowed_cwd_roots.clone();
}
Ok(config)
}
}
fn parse_csv(value: &str) -> Vec<String> {
value
.split(',')
.map(str::trim)
.filter(|segment| !segment.is_empty())
.map(ToString::to_string)
.collect()
}
fn parse_path_list(value: &str) -> Vec<PathBuf> {
value
.split(':')
.map(str::trim)
.filter(|segment| !segment.is_empty())
.map(PathBuf::from)
.collect()
}
fn parse_auth_kinds(key: &'static str, value: &str) -> Result<Vec<String>> {
let mut parsed = Vec::new();
for segment in value
.split(',')
.map(str::trim)
.filter(|segment| !segment.is_empty())
{
let normalized = normalize_auth_kind(segment)
.ok_or_else(|| anyhow::anyhow!("invalid ssh auth kind for {key}: value={segment}"))?;
if !parsed.contains(&normalized) {
parsed.push(normalized);
}
}
Ok(parsed)
}
fn normalize_auth_kind(value: &str) -> Option<String> {
match value.trim().to_ascii_lowercase().as_str() {
"config_alias" => Some("config_alias".to_string()),
"ssh_agent" => Some("ssh_agent".to_string()),
"identity_file" => Some("identity_file".to_string()),
_ => None,
}
}
fn parse_usize(key: &'static str, value: &str) -> Result<usize> {
value
.parse::<usize>()
.map_err(|source| anyhow::anyhow!("invalid usize for {key}: value={value}: {source}"))
}
fn parse_u16(key: &'static str, value: &str) -> Result<u16> {
value
.parse::<u16>()
.map_err(|source| anyhow::anyhow!("invalid u16 for {key}: value={value}: {source}"))
}
fn parse_bool(key: &'static str, value: &str) -> Result<bool> {
match value.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Ok(true),
"0" | "false" | "no" | "off" => Ok(false),
_ => bail!("invalid bool for {key}: value={value}"),
}
}
#[cfg(target_os = "macos")]
const fn default_macos_block_apple_metadata() -> bool {
true
}
#[cfg(not(target_os = "macos"))]
const fn default_macos_block_apple_metadata() -> bool {
false
}
fn resolve_bin_path(
explicit: Option<PathBuf>,
command_name: &str,
default_candidates: &'static [&'static str],
) -> Option<PathBuf> {
if let Some(path) = explicit {
let trimmed = path.to_string_lossy().trim().to_string();
if !trimmed.is_empty() {
return Some(PathBuf::from(trimmed));
}
}
for candidate in default_candidates {
let path = PathBuf::from(candidate);
if path.is_file() {
return Some(path);
}
}
find_in_path(command_name)
}
fn find_in_path(command_name: &str) -> Option<PathBuf> {
let path_var = env::var_os("PATH")?;
for entry in env::split_paths(&path_var) {
let candidate = entry.join(command_name);
if candidate.is_file() {
return Some(candidate);
}
}
None
}
#[cfg(target_os = "macos")]
fn ssh_default_paths() -> &'static [&'static str] {
&[
"/usr/bin/ssh",
"/opt/homebrew/bin/ssh",
"/usr/local/bin/ssh",
]
}
#[cfg(not(target_os = "macos"))]
fn ssh_default_paths() -> &'static [&'static str] {
&["/usr/bin/ssh", "/usr/local/bin/ssh"]
}
#[cfg(target_os = "macos")]
fn sshfs_default_paths() -> &'static [&'static str] {
&[
"/opt/homebrew/bin/sshfs",
"/usr/local/bin/sshfs",
"/usr/bin/sshfs",
]
}
#[cfg(not(target_os = "macos"))]
fn sshfs_default_paths() -> &'static [&'static str] {
&["/usr/bin/sshfs", "/usr/local/bin/sshfs"]
}
#[cfg(target_os = "macos")]
fn umount_default_paths() -> &'static [&'static str] {
&["/sbin/umount", "/usr/sbin/umount", "/usr/bin/umount"]
}
#[cfg(not(target_os = "macos"))]
fn umount_default_paths() -> &'static [&'static str] {
&["/usr/bin/umount", "/bin/umount", "/usr/local/bin/umount"]
}
#[cfg(target_os = "macos")]
fn diskutil_default_paths() -> &'static [&'static str] {
&["/usr/sbin/diskutil", "/usr/bin/diskutil"]
}
#[cfg(not(target_os = "macos"))]
fn diskutil_default_paths() -> &'static [&'static str] {
&[]
}
#[cfg(test)]
mod tests {
use std::sync::{Mutex, OnceLock};
use super::{
Config, default_macos_block_apple_metadata, parse_auth_kinds, parse_bool, parse_usize,
};
fn env_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
struct EnvGuard {
key: &'static str,
original: Option<String>,
}
impl EnvGuard {
fn set(key: &'static str, value: Option<&str>) -> Self {
let original = std::env::var(key).ok();
match value {
Some(value) => unsafe { std::env::set_var(key, value) },
None => unsafe { std::env::remove_var(key) },
}
Self { key, original }
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
match self.original.as_deref() {
Some(value) => unsafe { std::env::set_var(self.key, value) },
None => unsafe { std::env::remove_var(self.key) },
}
}
}
#[test]
fn parse_usize_error_contains_key_and_value() {
let error = parse_usize("PTY_MCP_SESSION_LIMIT", "abc").expect_err("parse should fail");
let text = format!("{error:#}");
assert!(text.contains("PTY_MCP_SESSION_LIMIT"));
assert!(text.contains("abc"));
}
#[test]
fn parse_bool_error_contains_key_and_value() {
let error = parse_bool("PTY_MCP_SSH_ALLOW_EXPLICIT_MOUNT_PATHS", "maybe")
.expect_err("parse should fail");
let text = format!("{error:#}");
assert!(text.contains("PTY_MCP_SSH_ALLOW_EXPLICIT_MOUNT_PATHS"));
assert!(text.contains("maybe"));
}
#[test]
fn parse_auth_kind_error_contains_key_and_value() {
let error = parse_auth_kinds("PTY_MCP_SSH_ALLOWED_AUTH_KINDS", "magic")
.expect_err("parse should fail");
let text = format!("{error:#}");
assert!(text.contains("PTY_MCP_SSH_ALLOWED_AUTH_KINDS"));
assert!(text.contains("magic"));
}
#[test]
fn config_from_env_uses_platform_default_for_macos_metadata_blocking() {
let _lock = env_lock().lock().expect("env lock poisoned");
let _guard = EnvGuard::set("PTY_MCP_SSH_MACOS_BLOCK_APPLE_METADATA", None);
let config = Config::from_env().expect("config should load");
assert_eq!(
config.ssh.macos_block_apple_metadata,
default_macos_block_apple_metadata()
);
}
#[test]
fn config_from_env_allows_overriding_macos_metadata_blocking() {
let _lock = env_lock().lock().expect("env lock poisoned");
let _guard = EnvGuard::set("PTY_MCP_SSH_MACOS_BLOCK_APPLE_METADATA", Some("false"));
let config = Config::from_env().expect("config should load");
assert!(!config.ssh.macos_block_apple_metadata);
}
}