pty-mcp 0.2.0

An MCP server for PTY management with SSH connections, remote sessions, file access, and mounts
Documentation
use std::{
    collections::BTreeSet,
    path::{Path, PathBuf},
};

use crate::Config;

#[derive(Debug, Clone)]
pub struct PermissionPolicy {
    allowed_commands: BTreeSet<String>,
    denied_commands: BTreeSet<String>,
    allowed_env_vars: BTreeSet<String>,
    denied_env_vars: BTreeSet<String>,
    allowed_cwd_roots: Vec<PathBuf>,
}

impl PermissionPolicy {
    pub fn from_config(config: &Config) -> Self {
        Self {
            allowed_commands: normalize_string_set(&config.allowed_commands),
            denied_commands: normalize_string_set(&config.denied_commands),
            allowed_env_vars: normalize_env_keys(&config.allowed_env_vars),
            denied_env_vars: normalize_env_keys(&config.denied_env_vars),
            allowed_cwd_roots: config.allowed_cwd_roots.clone(),
        }
    }

    pub fn is_command_allowed(&self, command: &str) -> bool {
        let Some(normalized) = normalize_command_name(command) else {
            return false;
        };

        if self.denied_commands.contains(&normalized) {
            return false;
        }

        if self.allowed_commands.is_empty() {
            return true;
        }

        self.allowed_commands.contains(&normalized)
    }

    pub fn is_env_key_allowed(&self, key: &str) -> bool {
        let normalized = normalize_env_key(key);
        if self.denied_env_vars.contains(&normalized) {
            return false;
        }

        if self.allowed_env_vars.is_empty() {
            return true;
        }

        self.allowed_env_vars.contains(&normalized)
    }

    pub fn allowed_cwd_roots(&self) -> &[PathBuf] {
        &self.allowed_cwd_roots
    }
}

pub(crate) fn normalize_command_name(command: &str) -> Option<String> {
    let trimmed = command.trim();
    if trimmed.is_empty() {
        return None;
    }

    let normalized = Path::new(trimmed)
        .file_name()
        .map(|name| name.to_string_lossy().trim().to_ascii_lowercase())
        .unwrap_or_else(|| trimmed.to_ascii_lowercase());

    if normalized.is_empty() {
        return None;
    }

    Some(normalized)
}

pub(crate) fn normalize_env_key(key: &str) -> String {
    key.trim().to_ascii_uppercase()
}

fn normalize_env_keys(items: &[String]) -> BTreeSet<String> {
    items
        .iter()
        .map(|entry| normalize_env_key(entry))
        .filter(|entry| !entry.is_empty())
        .collect()
}

fn normalize_string_set(items: &[String]) -> BTreeSet<String> {
    items
        .iter()
        .filter_map(|entry| normalize_command_name(entry))
        .collect()
}