synaps 0.1.0

Terminal-native AI agent runtime — parallel orchestration, reactive subagents, MCP, autonomous supervision
//! Plugin permission/trust inspection helpers.

use crate::skills::Plugin;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginPermissionSummary {
    pub has_executable_extension: bool,
    pub has_setup_script: bool,
    pub permissions: Vec<String>,
    pub hooks: Vec<String>,
    pub config_keys: Vec<String>,
    pub command: Option<String>,
}

impl PluginPermissionSummary {
    pub fn is_empty(&self) -> bool {
        !self.has_executable_extension
            && !self.has_setup_script
            && self.permissions.is_empty()
            && self.hooks.is_empty()
            && self.config_keys.is_empty()
            && self.command.is_none()
    }

    pub fn lines(&self) -> Vec<String> {
        if self.is_empty() {
            return vec!["no executable extension or extension permissions declared".to_string()];
        }

        let mut lines = Vec::new();
        if self.has_executable_extension {
            lines.push("executable extension: yes".to_string());
        }
        if self.has_setup_script {
            lines.push("⚠ runs setup script on install".to_string());
        }
        if let Some(command) = &self.command {
            lines.push(format!("command: {}", command));
        }
        if self.permissions.is_empty() {
            lines.push("permissions: <none>".to_string());
        } else {
            lines.push(format!("permissions: {}", self.permissions.join(", ")));
        }
        if !self.hooks.is_empty() {
            lines.push(format!("hooks: {}", self.hooks.join(", ")));
        }
        if !self.config_keys.is_empty() {
            lines.push(format!("config: {}", self.config_keys.join(", ")));
        }
        lines
    }
}

pub fn summarize_plugin_permissions(plugin: &Plugin) -> PluginPermissionSummary {
    let has_setup_script = plugin.manifest.as_ref()
        .and_then(|m| m.provides.as_ref())
        .and_then(|p| p.sidecar.as_ref())
        .and_then(|s| s.setup.as_ref())
        .is_some();

    let Some(extension) = &plugin.extension else {
        return PluginPermissionSummary {
            has_executable_extension: false,
            has_setup_script,
            permissions: Vec::new(),
            hooks: Vec::new(),
            config_keys: Vec::new(),
            command: None,
        };
    };

    let mut permissions = extension.permissions.clone();
    permissions.sort();
    permissions.dedup();

    let hooks = extension
        .hooks
        .iter()
        .map(|hook| match &hook.tool {
            Some(tool) => format!("{}({})", hook.hook, tool),
            None => hook.hook.clone(),
        })
        .collect();

    let config_keys = extension
        .config
        .iter()
        .map(|entry| {
            if entry.required {
                format!("{} [required]", entry.key)
            } else {
                entry.key.clone()
            }
        })
        .collect();

    PluginPermissionSummary {
        has_executable_extension: true,
        has_setup_script,
        permissions,
        hooks,
        config_keys,
        command: Some(format!("{} {}", extension.command, extension.args.join(" ")).trim().to_string()),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::extensions::manifest::{ExtensionConfigEntry, ExtensionManifest, ExtensionRuntime, HookSubscription};
    use std::path::PathBuf;

    fn plugin(extension: Option<ExtensionManifest>) -> Plugin {
        Plugin {
            name: "policy".to_string(),
            root: PathBuf::from("/tmp/policy"),
            marketplace: None,
            version: None,
            description: None,
            extension,
            manifest: None,
        }
    }

    #[test]
    fn summary_is_empty_without_extension() {
        let summary = summarize_plugin_permissions(&plugin(None));
        assert!(summary.is_empty());
        assert_eq!(summary.lines(), vec!["no executable extension or extension permissions declared"]);
    }

    #[test]
    fn summary_lists_permissions_hooks_and_required_config() {
        let summary = summarize_plugin_permissions(&plugin(Some(ExtensionManifest {
            protocol_version: 1,
            runtime: ExtensionRuntime::Process,
            command: "python3".to_string(),
            setup: None,
            prebuilt: ::std::collections::HashMap::new(),
            args: vec!["ext.py".to_string()],
            permissions: vec!["tools.intercept".to_string(), "privacy.llm_content".to_string()],
            hooks: vec![HookSubscription {
                hook: "before_tool_call".to_string(),
                tool: Some("bash".to_string()),
                matcher: None,
            }],
            config: vec![ExtensionConfigEntry {
                key: "api_key".to_string(),
                value_type: None,
                description: None,
                required: true,
                default: None,
                secret_env: Some("API_KEY".to_string()),
            }],
        })));

        assert!(summary.has_executable_extension);
        assert_eq!(summary.permissions, vec!["privacy.llm_content", "tools.intercept"]);
        assert_eq!(summary.hooks, vec!["before_tool_call(bash)"]);
        assert_eq!(summary.config_keys, vec!["api_key [required]"]);
        assert!(summary.lines().iter().any(|line| line.contains("permissions: privacy.llm_content, tools.intercept")));
    }
}