Skip to main content

agent_engine/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
57        .manifest
58        .as_ref()
59        .and_then(|m| m.provides.as_ref())
60        .and_then(|p| p.sidecar.as_ref())
61        .and_then(|s| s.setup.as_ref())
62        .is_some();
63
64    let Some(extension) = &plugin.extension else {
65        return PluginPermissionSummary {
66            has_executable_extension: false,
67            has_setup_script,
68            permissions: Vec::new(),
69            hooks: Vec::new(),
70            config_keys: Vec::new(),
71            command: None,
72        };
73    };
74
75    let mut permissions = extension.permissions.clone();
76    permissions.sort();
77    permissions.dedup();
78
79    let hooks = extension
80        .hooks
81        .iter()
82        .map(|hook| match &hook.tool {
83            Some(tool) => format!("{}({})", hook.hook, tool),
84            None => hook.hook.clone(),
85        })
86        .collect();
87
88    let config_keys = extension
89        .config
90        .iter()
91        .map(|entry| {
92            if entry.required {
93                format!("{} [required]", entry.key)
94            } else {
95                entry.key.clone()
96            }
97        })
98        .collect();
99
100    PluginPermissionSummary {
101        has_executable_extension: true,
102        has_setup_script,
103        permissions,
104        hooks,
105        config_keys,
106        command: Some(
107            format!("{} {}", extension.command, extension.args.join(" "))
108                .trim()
109                .to_string(),
110        ),
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117    use crate::extensions::manifest::{
118        ExtensionConfigEntry, ExtensionManifest, ExtensionRuntime, HookSubscription,
119    };
120    use std::path::PathBuf;
121
122    fn plugin(extension: Option<ExtensionManifest>) -> Plugin {
123        Plugin {
124            name: "policy".to_string(),
125            root: PathBuf::from("/tmp/policy"),
126            marketplace: None,
127            version: None,
128            description: None,
129            extension,
130            manifest: None,
131        }
132    }
133
134    #[test]
135    fn summary_is_empty_without_extension() {
136        let summary = summarize_plugin_permissions(&plugin(None));
137        assert!(summary.is_empty());
138        assert_eq!(
139            summary.lines(),
140            vec!["no executable extension or extension permissions declared"]
141        );
142    }
143
144    #[test]
145    fn summary_lists_permissions_hooks_and_required_config() {
146        let summary = summarize_plugin_permissions(&plugin(Some(ExtensionManifest {
147            theme_tokens: Default::default(),
148            deferred: None,
149            protocol_version: 1,
150            runtime: ExtensionRuntime::Process,
151            command: "python3".to_string(),
152            setup: None,
153            prebuilt: ::std::collections::HashMap::new(),
154            args: vec!["ext.py".to_string()],
155            permissions: vec![
156                "tools.intercept".to_string(),
157                "privacy.llm_content".to_string(),
158            ],
159            hooks: vec![HookSubscription {
160                hook: "before_tool_call".to_string(),
161                tool: Some("bash".to_string()),
162                matcher: None,
163            }],
164            config: vec![ExtensionConfigEntry {
165                key: "api_key".to_string(),
166                value_type: None,
167                description: None,
168                required: true,
169                default: None,
170                secret_env: Some("API_KEY".to_string()),
171                host_context: None,
172            }],
173        })));
174
175        assert!(summary.has_executable_extension);
176        assert_eq!(
177            summary.permissions,
178            vec!["privacy.llm_content", "tools.intercept"]
179        );
180        assert_eq!(summary.hooks, vec!["before_tool_call(bash)"]);
181        assert_eq!(summary.config_keys, vec!["api_key [required]"]);
182        assert!(summary
183            .lines()
184            .iter()
185            .any(|line| line.contains("permissions: privacy.llm_content, tools.intercept")));
186    }
187}