Skip to main content

lean_ctx/core/plugins/
manifest.rs

1use serde::Deserialize;
2use std::collections::HashMap;
3use std::path::Path;
4
5use super::sandbox::TrustSpec;
6
7#[derive(Debug, Clone, Deserialize)]
8pub struct PluginManifest {
9    pub plugin: PluginMeta,
10    #[serde(default)]
11    pub hooks: HashMap<String, HookEntry>,
12    /// Native MCP tools contributed by this plugin (`[[tools]]`). Lets a plugin
13    /// add tools without forking `build_registry()` (EPIC 12.11).
14    #[serde(default)]
15    pub tools: Vec<ToolEntry>,
16    /// Declared trust/sandbox capabilities (`[trust]`, EPIC 12.3). Absent ⇒
17    /// least privilege (scrubbed env, no declared network/fs access).
18    #[serde(default)]
19    pub trust: TrustSpec,
20}
21
22#[derive(Debug, Clone, Deserialize)]
23pub struct PluginMeta {
24    pub name: String,
25    pub version: String,
26    #[serde(default)]
27    pub description: String,
28    #[serde(default)]
29    pub author: String,
30}
31
32#[derive(Debug, Clone, Deserialize)]
33pub struct HookEntry {
34    pub command: String,
35    #[serde(default = "default_timeout_ms")]
36    pub timeout_ms: u64,
37}
38
39/// A manifest-declared MCP tool, backed by a sandboxed subprocess. The `command`
40/// receives the tool's JSON arguments on stdin and returns text on stdout.
41#[derive(Debug, Clone, Deserialize)]
42pub struct ToolEntry {
43    pub name: String,
44    #[serde(default)]
45    pub description: String,
46    pub command: String,
47    #[serde(default = "default_timeout_ms")]
48    pub timeout_ms: u64,
49    /// JSON Schema for the tool's arguments. Defaults to a permissive object.
50    #[serde(default)]
51    pub input_schema: serde_json::Value,
52}
53
54fn default_timeout_ms() -> u64 {
55    5000
56}
57
58impl PluginManifest {
59    pub fn from_file(path: &Path) -> Result<Self, ManifestError> {
60        let content = std::fs::read_to_string(path).map_err(|e| ManifestError::Io {
61            path: path.to_path_buf(),
62            source: e,
63        })?;
64        Self::from_str(&content, path)
65    }
66
67    pub fn from_str(content: &str, path: &Path) -> Result<Self, ManifestError> {
68        let manifest: Self = toml::from_str(content).map_err(|e| ManifestError::Parse {
69            path: path.to_path_buf(),
70            source: e,
71        })?;
72        manifest.validate(path)?;
73        Ok(manifest)
74    }
75
76    fn validate(&self, path: &Path) -> Result<(), ManifestError> {
77        if self.plugin.name.is_empty() {
78            return Err(ManifestError::Validation {
79                path: path.to_path_buf(),
80                field: "plugin.name".to_string(),
81                reason: "must not be empty".to_string(),
82            });
83        }
84        if self.plugin.version.is_empty() {
85            return Err(ManifestError::Validation {
86                path: path.to_path_buf(),
87                field: "plugin.version".to_string(),
88                reason: "must not be empty".to_string(),
89            });
90        }
91        for (hook_name, entry) in &self.hooks {
92            if entry.command.is_empty() {
93                return Err(ManifestError::Validation {
94                    path: path.to_path_buf(),
95                    field: format!("hooks.{hook_name}.command"),
96                    reason: "must not be empty".to_string(),
97                });
98            }
99        }
100        for tool in &self.tools {
101            if tool.name.is_empty() {
102                return Err(ManifestError::Validation {
103                    path: path.to_path_buf(),
104                    field: "tools.name".to_string(),
105                    reason: "must not be empty".to_string(),
106                });
107            }
108            if tool.command.is_empty() {
109                return Err(ManifestError::Validation {
110                    path: path.to_path_buf(),
111                    field: format!("tools.{}.command", tool.name),
112                    reason: "must not be empty".to_string(),
113                });
114            }
115        }
116        if let Err(reason) = self.trust.validate() {
117            return Err(ManifestError::Validation {
118                path: path.to_path_buf(),
119                field: "trust.permissions".to_string(),
120                reason,
121            });
122        }
123        Ok(())
124    }
125}
126
127#[derive(Debug, thiserror::Error)]
128pub enum ManifestError {
129    #[error("failed to read plugin manifest at {path}: {source}")]
130    Io {
131        path: std::path::PathBuf,
132        source: std::io::Error,
133    },
134    #[error("failed to parse plugin manifest at {path}: {source}")]
135    Parse {
136        path: std::path::PathBuf,
137        source: toml::de::Error,
138    },
139    #[error("invalid plugin manifest at {path}: {field} {reason}")]
140    Validation {
141        path: std::path::PathBuf,
142        field: String,
143        reason: String,
144    },
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use std::path::PathBuf;
151
152    #[test]
153    fn parse_valid_manifest() {
154        let toml = r#"
155[plugin]
156name = "test-plugin"
157version = "0.1.0"
158description = "A test plugin"
159author = "Test Author"
160
161[hooks.on_session_start]
162command = "test-binary start"
163timeout_ms = 3000
164
165[hooks.pre_read]
166command = "test-binary pre-read"
167"#;
168        let manifest = PluginManifest::from_str(toml, &PathBuf::from("test.toml")).unwrap();
169        assert_eq!(manifest.plugin.name, "test-plugin");
170        assert_eq!(manifest.plugin.version, "0.1.0");
171        assert_eq!(manifest.hooks.len(), 2);
172        assert_eq!(manifest.hooks["on_session_start"].timeout_ms, 3000);
173        assert_eq!(manifest.hooks["pre_read"].timeout_ms, 5000);
174    }
175
176    #[test]
177    fn reject_empty_name() {
178        let toml = r#"
179[plugin]
180name = ""
181version = "0.1.0"
182"#;
183        let err = PluginManifest::from_str(toml, &PathBuf::from("bad.toml")).unwrap_err();
184        assert!(err.to_string().contains("plugin.name"));
185    }
186
187    #[test]
188    fn reject_empty_version() {
189        let toml = r#"
190[plugin]
191name = "test"
192version = ""
193"#;
194        let err = PluginManifest::from_str(toml, &PathBuf::from("bad.toml")).unwrap_err();
195        assert!(err.to_string().contains("plugin.version"));
196    }
197
198    #[test]
199    fn reject_empty_command() {
200        let toml = r#"
201[plugin]
202name = "test"
203version = "0.1.0"
204
205[hooks.pre_read]
206command = ""
207"#;
208        let err = PluginManifest::from_str(toml, &PathBuf::from("bad.toml")).unwrap_err();
209        assert!(err.to_string().contains("hooks.pre_read.command"));
210    }
211
212    #[test]
213    fn minimal_manifest_no_hooks() {
214        let toml = r#"
215[plugin]
216name = "minimal"
217version = "1.0.0"
218"#;
219        let manifest = PluginManifest::from_str(toml, &PathBuf::from("minimal.toml")).unwrap();
220        assert_eq!(manifest.plugin.name, "minimal");
221        assert!(manifest.hooks.is_empty());
222        assert!(manifest.tools.is_empty());
223    }
224
225    #[test]
226    fn parses_tool_entries_with_schema() {
227        let toml = r#"
228[plugin]
229name = "weather"
230version = "1.0.0"
231
232[[tools]]
233name = "weather_lookup"
234description = "Look up the weather for a city"
235command = "weather-bin"
236timeout_ms = 8000
237input_schema = { type = "object", properties = { city = { type = "string" } }, required = ["city"] }
238"#;
239        let manifest = PluginManifest::from_str(toml, &PathBuf::from("weather.toml")).unwrap();
240        assert_eq!(manifest.tools.len(), 1);
241        let t = &manifest.tools[0];
242        assert_eq!(t.name, "weather_lookup");
243        assert_eq!(t.command, "weather-bin");
244        assert_eq!(t.timeout_ms, 8000);
245        assert_eq!(t.input_schema["type"], serde_json::json!("object"));
246    }
247
248    #[test]
249    fn rejects_tool_without_command() {
250        let toml = r#"
251[plugin]
252name = "bad"
253version = "1.0.0"
254
255[[tools]]
256name = "broken"
257command = ""
258"#;
259        let err = PluginManifest::from_str(toml, &PathBuf::from("bad.toml")).unwrap_err();
260        assert!(err.to_string().contains("tools.broken.command"));
261    }
262
263    #[test]
264    fn default_timeout_applied() {
265        let toml = r#"
266[plugin]
267name = "defaults"
268version = "0.1.0"
269
270[hooks.on_session_end]
271command = "plugin-bin stop"
272"#;
273        let manifest = PluginManifest::from_str(toml, &PathBuf::from("test.toml")).unwrap();
274        assert_eq!(manifest.hooks["on_session_end"].timeout_ms, 5000);
275    }
276}