pty-mcp 0.2.3

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

use anyhow::{Result, bail, ensure};
use serde_json::{Map, Value};

use super::policy::{PermissionPolicy, normalize_command_name, normalize_env_key};

#[derive(Debug, Clone)]
pub struct SpawnValidationInput<'a> {
    pub command: &'a str,
    pub args: &'a [String],
    pub cwd: Option<&'a str>,
    pub env: Option<&'a Map<String, Value>>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SpawnValidationResult {
    pub command: String,
    pub args: Vec<String>,
    pub cwd: Option<PathBuf>,
    pub env: BTreeMap<String, String>,
}

#[derive(Debug, Clone)]
pub struct PermissionGuard {
    policy: PermissionPolicy,
}

impl PermissionGuard {
    pub fn new(policy: PermissionPolicy) -> Self {
        Self { policy }
    }

    pub fn policy(&self) -> &PermissionPolicy {
        &self.policy
    }

    pub fn validate_spawn(&self, input: SpawnValidationInput<'_>) -> Result<SpawnValidationResult> {
        let command = self.validate_command(input.command)?;
        let cwd = self.validate_cwd(input.cwd)?;
        let env = self.validate_env(input.env)?;

        Ok(SpawnValidationResult {
            command,
            args: input.args.to_vec(),
            cwd,
            env,
        })
    }

    fn validate_command(&self, command: &str) -> Result<String> {
        let normalized = normalize_command_name(command)
            .ok_or_else(|| anyhow::anyhow!("command cannot be empty"))?;
        ensure!(
            self.policy.is_command_allowed(&normalized),
            "command is blocked by permission policy: command={command}"
        );

        Ok(command.to_string())
    }

    fn validate_cwd(&self, cwd: Option<&str>) -> Result<Option<PathBuf>> {
        let Some(cwd) = cwd else {
            return Ok(None);
        };

        let trimmed = cwd.trim();
        ensure!(!trimmed.is_empty(), "cwd cannot be empty when provided");

        let absolute = if PathBuf::from(trimmed).is_absolute() {
            PathBuf::from(trimmed)
        } else {
            env::current_dir()
                .map_err(|err| anyhow::anyhow!("unable to resolve cwd={trimmed}: {err}"))?
                .join(trimmed)
        };

        ensure!(absolute.exists(), "cwd path does not exist: cwd={trimmed}");

        let canonical_cwd = fs::canonicalize(&absolute)
            .map_err(|err| anyhow::anyhow!("unable to canonicalize cwd={trimmed}: {err}"))?;

        let allowed = self
            .policy
            .allowed_cwd_roots()
            .iter()
            .filter_map(|root| fs::canonicalize(root).ok())
            .any(|root| canonical_cwd.starts_with(root));

        ensure!(
            allowed,
            "cwd is not within allowed roots: cwd={} allowed_cwd_roots={:?}",
            canonical_cwd.display(),
            self.policy.allowed_cwd_roots()
        );

        Ok(Some(canonical_cwd))
    }

    fn validate_env(&self, env: Option<&Map<String, Value>>) -> Result<BTreeMap<String, String>> {
        let Some(env) = env else {
            return Ok(BTreeMap::new());
        };

        let mut sanitized = BTreeMap::new();
        for (key, value) in env {
            let normalized_key = normalize_env_key(key);
            ensure!(
                !normalized_key.is_empty(),
                "environment variable key cannot be empty: env key={key}"
            );

            ensure!(
                self.policy.is_env_key_allowed(&normalized_key),
                "environment variable is blocked by permission policy: env key={key}"
            );

            sanitized.insert(normalized_key, normalize_env_value(key, value)?);
        }

        Ok(sanitized)
    }
}

fn normalize_env_value(key: &str, value: &Value) -> Result<String> {
    match value {
        Value::String(value) => Ok(value.clone()),
        Value::Number(value) => Ok(value.to_string()),
        Value::Bool(value) => Ok(value.to_string()),
        Value::Null => bail!(
            "environment variable value cannot be null: env key={key} expected=string|number|bool"
        ),
        Value::Array(_) | Value::Object(_) => bail!(
            "environment variable value must be scalar: env key={key} expected=string|number|bool"
        ),
    }
}