Skip to main content

synaps_cli/skills/
trust.rs

1//! Plugin permission/trust inspection helpers.
2
3use crate::skills::Plugin;
4
5#[derive(Debug, Clone, PartialEq, Eq)]
6pub struct PluginPermissionSummary {
7    pub has_executable_extension: bool,
8    pub has_setup_script: bool,
9    pub permissions: Vec<String>,
10    pub hooks: Vec<String>,
11    pub config_keys: Vec<String>,
12    pub command: Option<String>,
13}
14
15impl PluginPermissionSummary {
16    pub fn is_empty(&self) -> bool {
17        !self.has_executable_extension
18            && !self.has_setup_script
19            && self.permissions.is_empty()
20            && self.hooks.is_empty()
21            && self.config_keys.is_empty()
22            && self.command.is_none()
23    }
24
25    pub fn lines(&self) -> Vec<String> {
26        if self.is_empty() {
27            return vec!["no executable extension or extension permissions declared".to_string()];
28        }
29
30        let mut lines = Vec::new();
31        if self.has_executable_extension {
32            lines.push("executable extension: yes".to_string());
33        }
34        if self.has_setup_script {
35            lines.push("⚠ runs setup script on install".to_string());
36        }
37        if let Some(command) = &self.command {
38            lines.push(format!("command: {}", command));
39        }
40        if self.permissions.is_empty() {
41            lines.push("permissions: <none>".to_string());
42        } else {
43            lines.push(format!("permissions: {}", self.permissions.join(", ")));
44        }
45        if !self.hooks.is_empty() {
46            lines.push(format!("hooks: {}", self.hooks.join(", ")));
47        }
48        if !self.config_keys.is_empty() {
49            lines.push(format!("config: {}", self.config_keys.join(", ")));
50        }
51        lines
52    }
53}
54
55pub fn summarize_plugin_permissions(plugin: &Plugin) -> PluginPermissionSummary {
56    let has_setup_script = plugin.manifest.as_ref()
57        .and_then(|m| m.provides.as_ref())
58        .and_then(|p| p.sidecar.as_ref())
59        .and_then(|s| s.setup.as_ref())
60        .is_some();
61
62    let Some(extension) = &plugin.extension else {
63        return PluginPermissionSummary {
64            has_executable_extension: false,
65            has_setup_script,
66            permissions: Vec::new(),
67            hooks: Vec::new(),
68            config_keys: Vec::new(),
69            command: None,
70        };
71    };
72
73    let mut permissions = extension.permissions.clone();
74    permissions.sort();
75    permissions.dedup();
76
77    let hooks = extension
78        .hooks
79        .iter()
80        .map(|hook| match &hook.tool {
81            Some(tool) => format!("{}({})", hook.hook, tool),
82            None => hook.hook.clone(),
83        })
84        .collect();
85
86    let config_keys = extension
87        .config
88        .iter()
89        .map(|entry| {
90            if entry.required {
91                format!("{} [required]", entry.key)
92            } else {
93                entry.key.clone()
94            }
95        })
96        .collect();
97
98    PluginPermissionSummary {
99        has_executable_extension: true,
100        has_setup_script,
101        permissions,
102        hooks,
103        config_keys,
104        command: Some(format!("{} {}", extension.command, extension.args.join(" ")).trim().to_string()),
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111    use crate::extensions::manifest::{ExtensionConfigEntry, ExtensionManifest, ExtensionRuntime, HookSubscription};
112    use std::path::PathBuf;
113
114    fn plugin(extension: Option<ExtensionManifest>) -> Plugin {
115        Plugin {
116            name: "policy".to_string(),
117            root: PathBuf::from("/tmp/policy"),
118            marketplace: None,
119            version: None,
120            description: None,
121            extension,
122            manifest: None,
123        }
124    }
125
126    #[test]
127    fn summary_is_empty_without_extension() {
128        let summary = summarize_plugin_permissions(&plugin(None));
129        assert!(summary.is_empty());
130        assert_eq!(summary.lines(), vec!["no executable extension or extension permissions declared"]);
131    }
132
133    #[test]
134    fn summary_lists_permissions_hooks_and_required_config() {
135        let summary = summarize_plugin_permissions(&plugin(Some(ExtensionManifest {
136            protocol_version: 1,
137            runtime: ExtensionRuntime::Process,
138            command: "python3".to_string(),
139            setup: None,
140            prebuilt: ::std::collections::HashMap::new(),
141            args: vec!["ext.py".to_string()],
142            permissions: vec!["tools.intercept".to_string(), "privacy.llm_content".to_string()],
143            hooks: vec![HookSubscription {
144                hook: "before_tool_call".to_string(),
145                tool: Some("bash".to_string()),
146                matcher: None,
147            }],
148            config: vec![ExtensionConfigEntry {
149                key: "api_key".to_string(),
150                value_type: None,
151                description: None,
152                required: true,
153                default: None,
154                secret_env: Some("API_KEY".to_string()),
155            }],
156        })));
157
158        assert!(summary.has_executable_extension);
159        assert_eq!(summary.permissions, vec!["privacy.llm_content", "tools.intercept"]);
160        assert_eq!(summary.hooks, vec!["before_tool_call(bash)"]);
161        assert_eq!(summary.config_keys, vec!["api_key [required]"]);
162        assert!(summary.lines().iter().any(|line| line.contains("permissions: privacy.llm_content, tools.intercept")));
163    }
164}