Skip to main content

clawft_plugin/
manifest.rs

1//! Plugin manifest types.
2//!
3//! Defines [`PluginManifest`], [`PluginCapability`], [`PluginPermissions`],
4//! and [`PluginResourceConfig`] -- the schema for plugin metadata parsed
5//! from `clawft.plugin.json` or `.yaml` files.
6
7use serde::{Deserialize, Serialize};
8
9use crate::PluginError;
10
11/// Plugin manifest parsed from `clawft.plugin.json` or `.yaml`.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct PluginManifest {
14    /// Unique plugin identifier (reverse-domain, e.g., `"com.example.my-plugin"`).
15    pub id: String,
16
17    /// Human-readable plugin name.
18    pub name: String,
19
20    /// Semantic version string (must be valid semver).
21    pub version: String,
22
23    /// Capabilities this plugin provides.
24    pub capabilities: Vec<PluginCapability>,
25
26    /// Permissions the plugin requests.
27    #[serde(default)]
28    pub permissions: PluginPermissions,
29
30    /// Resource limits configuration.
31    #[serde(default)]
32    pub resources: PluginResourceConfig,
33
34    /// Path to the WASM module (relative to plugin directory).
35    #[serde(default)]
36    pub wasm_module: Option<String>,
37
38    /// Skills provided by this plugin.
39    #[serde(default)]
40    pub skills: Vec<String>,
41
42    /// Tools provided by this plugin.
43    #[serde(default)]
44    pub tools: Vec<String>,
45}
46
47/// Plugin capability types.
48#[non_exhaustive]
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(rename_all = "snake_case")]
51pub enum PluginCapability {
52    /// Tool execution capability.
53    Tool,
54    /// Channel adapter capability.
55    Channel,
56    /// Pipeline stage capability.
57    PipelineStage,
58    /// Skill definition capability.
59    Skill,
60    /// Memory backend capability.
61    MemoryBackend,
62    /// Reserved for Workstream G (voice/audio).
63    Voice,
64}
65
66/// Permissions requested by a plugin.
67#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
68pub struct PluginPermissions {
69    /// Allowed network hosts. Empty = no network. `["*"]` = all hosts.
70    #[serde(default)]
71    pub network: Vec<String>,
72
73    /// Allowed filesystem paths.
74    #[serde(default)]
75    pub filesystem: Vec<String>,
76
77    /// Allowed environment variable names.
78    #[serde(default)]
79    pub env_vars: Vec<String>,
80
81    /// Whether the plugin can execute shell commands.
82    #[serde(default)]
83    pub shell: bool,
84}
85
86/// Resource limits for plugin execution.
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct PluginResourceConfig {
89    /// Maximum WASM fuel per invocation (default: 1,000,000,000).
90    #[serde(default = "default_max_fuel")]
91    pub max_fuel: u64,
92
93    /// Maximum WASM memory in MB (default: 16).
94    #[serde(default = "default_max_memory_mb")]
95    pub max_memory_mb: usize,
96
97    /// Maximum HTTP requests per minute (default: 10).
98    #[serde(default = "default_max_http_rpm")]
99    pub max_http_requests_per_minute: u64,
100
101    /// Maximum log messages per minute (default: 100).
102    #[serde(default = "default_max_log_rpm")]
103    pub max_log_messages_per_minute: u64,
104
105    /// Maximum execution wall-clock seconds (default: 30).
106    #[serde(default = "default_max_exec_seconds")]
107    pub max_execution_seconds: u64,
108
109    /// Maximum WASM table elements (default: 10,000).
110    #[serde(default = "default_max_table_elements")]
111    pub max_table_elements: u32,
112}
113
114fn default_max_fuel() -> u64 {
115    1_000_000_000
116}
117fn default_max_memory_mb() -> usize {
118    16
119}
120fn default_max_http_rpm() -> u64 {
121    10
122}
123fn default_max_log_rpm() -> u64 {
124    100
125}
126fn default_max_exec_seconds() -> u64 {
127    30
128}
129fn default_max_table_elements() -> u32 {
130    10_000
131}
132
133impl Default for PluginResourceConfig {
134    fn default() -> Self {
135        Self {
136            max_fuel: default_max_fuel(),
137            max_memory_mb: default_max_memory_mb(),
138            max_http_requests_per_minute: default_max_http_rpm(),
139            max_log_messages_per_minute: default_max_log_rpm(),
140            max_execution_seconds: default_max_exec_seconds(),
141            max_table_elements: default_max_table_elements(),
142        }
143    }
144}
145
146/// Represents new permissions requested by a plugin version upgrade
147/// that were not present in the previously approved permission set.
148///
149/// Used to determine which permissions need user re-approval when a
150/// plugin updates its manifest.
151#[derive(Debug, Clone, Default, PartialEq)]
152pub struct PermissionDiff {
153    /// Network hosts requested that were not previously approved.
154    pub new_network: Vec<String>,
155    /// Filesystem paths requested that were not previously approved.
156    pub new_filesystem: Vec<String>,
157    /// Environment variables requested that were not previously approved.
158    pub new_env_vars: Vec<String>,
159    /// Whether shell access is being escalated from `false` to `true`.
160    pub shell_escalation: bool,
161}
162
163impl PermissionDiff {
164    /// Returns `true` if no new permissions are being requested.
165    pub fn is_empty(&self) -> bool {
166        self.new_network.is_empty()
167            && self.new_filesystem.is_empty()
168            && self.new_env_vars.is_empty()
169            && !self.shell_escalation
170    }
171}
172
173impl PluginPermissions {
174    /// Compute the diff between previously approved permissions and newly
175    /// requested permissions.
176    ///
177    /// Returns a [`PermissionDiff`] containing only the items in `requested`
178    /// that are not present in `approved`. For the `shell` field, only an
179    /// escalation from `false` to `true` counts as a new permission.
180    pub fn diff(approved: &PluginPermissions, requested: &PluginPermissions) -> PermissionDiff {
181        let new_network = requested
182            .network
183            .iter()
184            .filter(|item| !approved.network.contains(item))
185            .cloned()
186            .collect();
187
188        let new_filesystem = requested
189            .filesystem
190            .iter()
191            .filter(|item| !approved.filesystem.contains(item))
192            .cloned()
193            .collect();
194
195        let new_env_vars = requested
196            .env_vars
197            .iter()
198            .filter(|item| !approved.env_vars.contains(item))
199            .cloned()
200            .collect();
201
202        let shell_escalation = !approved.shell && requested.shell;
203
204        PermissionDiff {
205            new_network,
206            new_filesystem,
207            new_env_vars,
208            shell_escalation,
209        }
210    }
211}
212
213impl PluginManifest {
214    /// Validate the manifest. Returns an error describing the first
215    /// validation failure, or `Ok(())` if the manifest is valid.
216    pub fn validate(&self) -> Result<(), PluginError> {
217        if self.id.is_empty() {
218            return Err(PluginError::LoadFailed(
219                "manifest: id is required".into(),
220            ));
221        }
222        if self.id.len() > 128 {
223            return Err(PluginError::LoadFailed(
224                "manifest: id must be 128 characters or fewer".into(),
225            ));
226        }
227        if !self
228            .id
229            .chars()
230            .all(|c| c.is_alphanumeric() || c == '.' || c == '-' || c == '_')
231        {
232            return Err(PluginError::LoadFailed(
233                "manifest: id must contain only alphanumeric characters, dots, hyphens, and underscores".into(),
234            ));
235        }
236        if self.name.is_empty() {
237            return Err(PluginError::LoadFailed(
238                "manifest: name is required".into(),
239            ));
240        }
241        // Validate semver
242        if semver::Version::parse(&self.version).is_err() {
243            return Err(PluginError::LoadFailed(format!(
244                "manifest: invalid semver version '{}'",
245                self.version
246            )));
247        }
248        if self.capabilities.is_empty() {
249            return Err(PluginError::LoadFailed(
250                "manifest: at least one capability is required".into(),
251            ));
252        }
253        Ok(())
254    }
255
256    /// Parse a manifest from a JSON string.
257    pub fn from_json(json: &str) -> Result<Self, PluginError> {
258        let manifest: Self = serde_json::from_str(json)?;
259        manifest.validate()?;
260        Ok(manifest)
261    }
262
263    /// Parse a manifest from a YAML string.
264    ///
265    /// Note: `serde_yaml` is NOT a dependency of `clawft-plugin` to keep the
266    /// crate lightweight. YAML manifest parsing is handled in the loader
267    /// layer (C3) which calls `serde_yaml::from_str()` and then constructs a
268    /// `PluginManifest`. This method is a convenience stub.
269    pub fn from_yaml(_yaml: &str) -> Result<Self, PluginError> {
270        Err(PluginError::NotImplemented(
271            "YAML manifest parsing deferred to C3 skill loader".into(),
272        ))
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279
280    fn valid_manifest_json() -> String {
281        serde_json::json!({
282            "id": "com.example.test-plugin",
283            "name": "Test Plugin",
284            "version": "1.0.0",
285            "capabilities": ["tool", "skill"],
286            "permissions": {
287                "network": ["api.example.com"],
288                "filesystem": ["/tmp/plugin"],
289                "env_vars": ["MY_API_KEY"],
290                "shell": false
291            },
292            "resources": {
293                "max_fuel": 500_000_000u64,
294                "max_memory_mb": 8,
295                "max_http_requests_per_minute": 5,
296                "max_log_messages_per_minute": 50,
297                "max_execution_seconds": 15,
298                "max_table_elements": 5000
299            },
300            "wasm_module": "plugin.wasm",
301            "skills": ["code-review"],
302            "tools": ["lint_code"]
303        })
304        .to_string()
305    }
306
307    #[test]
308    fn test_manifest_parse_json() {
309        let json = valid_manifest_json();
310        let manifest = PluginManifest::from_json(&json).unwrap();
311        assert_eq!(manifest.id, "com.example.test-plugin");
312        assert_eq!(manifest.name, "Test Plugin");
313        assert_eq!(manifest.version, "1.0.0");
314        assert_eq!(manifest.capabilities.len(), 2);
315        assert_eq!(manifest.capabilities[0], PluginCapability::Tool);
316        assert_eq!(manifest.capabilities[1], PluginCapability::Skill);
317        assert_eq!(manifest.permissions.network, vec!["api.example.com"]);
318        assert_eq!(manifest.permissions.filesystem, vec!["/tmp/plugin"]);
319        assert_eq!(manifest.permissions.env_vars, vec!["MY_API_KEY"]);
320        assert!(!manifest.permissions.shell);
321        assert_eq!(manifest.resources.max_fuel, 500_000_000);
322        assert_eq!(manifest.resources.max_memory_mb, 8);
323        assert_eq!(manifest.resources.max_http_requests_per_minute, 5);
324        assert_eq!(manifest.resources.max_log_messages_per_minute, 50);
325        assert_eq!(manifest.resources.max_execution_seconds, 15);
326        assert_eq!(manifest.resources.max_table_elements, 5000);
327        assert_eq!(manifest.wasm_module, Some("plugin.wasm".into()));
328        assert_eq!(manifest.skills, vec!["code-review"]);
329        assert_eq!(manifest.tools, vec!["lint_code"]);
330    }
331
332    #[test]
333    fn test_manifest_parse_yaml_returns_not_implemented() {
334        let result = PluginManifest::from_yaml("name: test");
335        assert!(result.is_err());
336        match result.unwrap_err() {
337            PluginError::NotImplemented(msg) => {
338                assert!(msg.contains("YAML manifest parsing deferred"));
339            }
340            other => panic!("expected NotImplemented, got: {other}"),
341        }
342    }
343
344    #[test]
345    fn test_manifest_missing_id_fails() {
346        let json = serde_json::json!({
347            "id": "",
348            "name": "Test",
349            "version": "1.0.0",
350            "capabilities": ["tool"]
351        })
352        .to_string();
353        let err = PluginManifest::from_json(&json).unwrap_err();
354        let msg = err.to_string();
355        assert!(msg.contains("id is required"), "got: {msg}");
356    }
357
358    #[test]
359    fn test_manifest_invalid_version_fails() {
360        let json = serde_json::json!({
361            "id": "com.test",
362            "name": "Test",
363            "version": "not-semver",
364            "capabilities": ["tool"]
365        })
366        .to_string();
367        let err = PluginManifest::from_json(&json).unwrap_err();
368        let msg = err.to_string();
369        assert!(msg.contains("invalid semver"), "got: {msg}");
370    }
371
372    #[test]
373    fn test_manifest_empty_capabilities_fails() {
374        let json = serde_json::json!({
375            "id": "com.test",
376            "name": "Test",
377            "version": "1.0.0",
378            "capabilities": []
379        })
380        .to_string();
381        let err = PluginManifest::from_json(&json).unwrap_err();
382        let msg = err.to_string();
383        assert!(
384            msg.contains("at least one capability"),
385            "got: {msg}"
386        );
387    }
388
389    #[test]
390    fn test_manifest_missing_name_fails() {
391        let json = serde_json::json!({
392            "id": "com.test",
393            "name": "",
394            "version": "1.0.0",
395            "capabilities": ["tool"]
396        })
397        .to_string();
398        let err = PluginManifest::from_json(&json).unwrap_err();
399        let msg = err.to_string();
400        assert!(msg.contains("name is required"), "got: {msg}");
401    }
402
403    #[test]
404    fn test_plugin_capability_serde_roundtrip() {
405        let capabilities = vec![
406            PluginCapability::Tool,
407            PluginCapability::Channel,
408            PluginCapability::PipelineStage,
409            PluginCapability::Skill,
410            PluginCapability::MemoryBackend,
411            PluginCapability::Voice,
412        ];
413        for cap in &capabilities {
414            let json = serde_json::to_string(cap).unwrap();
415            let restored: PluginCapability = serde_json::from_str(&json).unwrap();
416            assert_eq!(&restored, cap);
417        }
418    }
419
420    #[test]
421    fn test_plugin_capability_json_values() {
422        assert_eq!(
423            serde_json::to_string(&PluginCapability::Tool).unwrap(),
424            "\"tool\""
425        );
426        assert_eq!(
427            serde_json::to_string(&PluginCapability::Channel).unwrap(),
428            "\"channel\""
429        );
430        assert_eq!(
431            serde_json::to_string(&PluginCapability::PipelineStage).unwrap(),
432            "\"pipeline_stage\""
433        );
434        assert_eq!(
435            serde_json::to_string(&PluginCapability::Skill).unwrap(),
436            "\"skill\""
437        );
438        assert_eq!(
439            serde_json::to_string(&PluginCapability::MemoryBackend).unwrap(),
440            "\"memory_backend\""
441        );
442        assert_eq!(
443            serde_json::to_string(&PluginCapability::Voice).unwrap(),
444            "\"voice\""
445        );
446    }
447
448    #[test]
449    fn test_permissions_default_is_empty() {
450        let perms = PluginPermissions::default();
451        assert!(perms.network.is_empty());
452        assert!(perms.filesystem.is_empty());
453        assert!(perms.env_vars.is_empty());
454        assert!(!perms.shell);
455    }
456
457    #[test]
458    fn test_resource_config_defaults() {
459        let config = PluginResourceConfig::default();
460        assert_eq!(config.max_fuel, 1_000_000_000);
461        assert_eq!(config.max_memory_mb, 16);
462        assert_eq!(config.max_http_requests_per_minute, 10);
463        assert_eq!(config.max_log_messages_per_minute, 100);
464        assert_eq!(config.max_execution_seconds, 30);
465        assert_eq!(config.max_table_elements, 10_000);
466    }
467
468    #[test]
469    fn test_manifest_with_defaults() {
470        let json = serde_json::json!({
471            "id": "com.test.minimal",
472            "name": "Minimal",
473            "version": "0.1.0",
474            "capabilities": ["tool"]
475        })
476        .to_string();
477        let manifest = PluginManifest::from_json(&json).unwrap();
478        // Permissions default to empty
479        assert!(manifest.permissions.network.is_empty());
480        assert!(!manifest.permissions.shell);
481        // Resources default to standard values
482        assert_eq!(manifest.resources.max_fuel, 1_000_000_000);
483        assert_eq!(manifest.resources.max_memory_mb, 16);
484        // Optional fields default to None/empty
485        assert!(manifest.wasm_module.is_none());
486        assert!(manifest.skills.is_empty());
487        assert!(manifest.tools.is_empty());
488    }
489
490    #[test]
491    fn test_manifest_serde_roundtrip() {
492        let json = valid_manifest_json();
493        let manifest = PluginManifest::from_json(&json).unwrap();
494        let serialized = serde_json::to_string(&manifest).unwrap();
495        let restored = PluginManifest::from_json(&serialized).unwrap();
496        assert_eq!(manifest.id, restored.id);
497        assert_eq!(manifest.name, restored.name);
498        assert_eq!(manifest.version, restored.version);
499        assert_eq!(manifest.capabilities, restored.capabilities);
500    }
501
502    #[test]
503    fn test_permissions_serde_roundtrip() {
504        let perms = PluginPermissions {
505            network: vec!["*.example.com".into(), "api.test.com".into()],
506            filesystem: vec!["/tmp".into(), "/data".into()],
507            env_vars: vec!["MY_KEY".into()],
508            shell: true,
509        };
510        let json = serde_json::to_string(&perms).unwrap();
511        let restored: PluginPermissions = serde_json::from_str(&json).unwrap();
512        assert_eq!(restored.network, perms.network);
513        assert_eq!(restored.filesystem, perms.filesystem);
514        assert_eq!(restored.env_vars, perms.env_vars);
515        assert_eq!(restored.shell, perms.shell);
516    }
517
518    // -- PermissionDiff tests --
519
520    #[test]
521    fn diff_identical_permissions_is_empty() {
522        let perms = PluginPermissions {
523            network: vec!["api.example.com".into()],
524            filesystem: vec!["/tmp".into()],
525            env_vars: vec!["HOME".into()],
526            shell: true,
527        };
528        let diff = PluginPermissions::diff(&perms, &perms);
529        assert!(diff.is_empty());
530        assert_eq!(diff, PermissionDiff::default());
531    }
532
533    #[test]
534    fn diff_detects_new_network_hosts() {
535        let approved = PluginPermissions {
536            network: vec!["api.example.com".into()],
537            ..Default::default()
538        };
539        let requested = PluginPermissions {
540            network: vec!["api.example.com".into(), "cdn.example.com".into()],
541            ..Default::default()
542        };
543        let diff = PluginPermissions::diff(&approved, &requested);
544        assert_eq!(diff.new_network, vec!["cdn.example.com"]);
545        assert!(diff.new_filesystem.is_empty());
546        assert!(diff.new_env_vars.is_empty());
547        assert!(!diff.shell_escalation);
548        assert!(!diff.is_empty());
549    }
550
551    #[test]
552    fn diff_detects_new_filesystem_paths() {
553        let approved = PluginPermissions {
554            filesystem: vec!["/tmp".into()],
555            ..Default::default()
556        };
557        let requested = PluginPermissions {
558            filesystem: vec!["/tmp".into(), "/data".into()],
559            ..Default::default()
560        };
561        let diff = PluginPermissions::diff(&approved, &requested);
562        assert_eq!(diff.new_filesystem, vec!["/data"]);
563    }
564
565    #[test]
566    fn diff_detects_new_env_vars() {
567        let approved = PluginPermissions {
568            env_vars: vec!["HOME".into()],
569            ..Default::default()
570        };
571        let requested = PluginPermissions {
572            env_vars: vec!["HOME".into(), "API_KEY".into()],
573            ..Default::default()
574        };
575        let diff = PluginPermissions::diff(&approved, &requested);
576        assert_eq!(diff.new_env_vars, vec!["API_KEY"]);
577    }
578
579    #[test]
580    fn diff_detects_shell_escalation() {
581        let approved = PluginPermissions {
582            shell: false,
583            ..Default::default()
584        };
585        let requested = PluginPermissions {
586            shell: true,
587            ..Default::default()
588        };
589        let diff = PluginPermissions::diff(&approved, &requested);
590        assert!(diff.shell_escalation);
591        assert!(!diff.is_empty());
592    }
593
594    #[test]
595    fn diff_no_shell_escalation_when_already_approved() {
596        let approved = PluginPermissions {
597            shell: true,
598            ..Default::default()
599        };
600        let requested = PluginPermissions {
601            shell: true,
602            ..Default::default()
603        };
604        let diff = PluginPermissions::diff(&approved, &requested);
605        assert!(!diff.shell_escalation);
606    }
607
608    #[test]
609    fn diff_no_shell_escalation_on_downgrade() {
610        let approved = PluginPermissions {
611            shell: true,
612            ..Default::default()
613        };
614        let requested = PluginPermissions {
615            shell: false,
616            ..Default::default()
617        };
618        let diff = PluginPermissions::diff(&approved, &requested);
619        assert!(!diff.shell_escalation);
620    }
621
622    #[test]
623    fn diff_empty_approved_all_requested_are_new() {
624        let approved = PluginPermissions::default();
625        let requested = PluginPermissions {
626            network: vec!["a.com".into(), "b.com".into()],
627            filesystem: vec!["/data".into()],
628            env_vars: vec!["KEY".into()],
629            shell: true,
630        };
631        let diff = PluginPermissions::diff(&approved, &requested);
632        assert_eq!(diff.new_network, vec!["a.com", "b.com"]);
633        assert_eq!(diff.new_filesystem, vec!["/data"]);
634        assert_eq!(diff.new_env_vars, vec!["KEY"]);
635        assert!(diff.shell_escalation);
636    }
637
638    #[test]
639    fn diff_removed_permissions_not_reported() {
640        // If requested drops a permission that was approved, it should NOT
641        // appear as a new permission (only additions are reported).
642        let approved = PluginPermissions {
643            network: vec!["old.example.com".into(), "keep.example.com".into()],
644            ..Default::default()
645        };
646        let requested = PluginPermissions {
647            network: vec!["keep.example.com".into()],
648            ..Default::default()
649        };
650        let diff = PluginPermissions::diff(&approved, &requested);
651        assert!(diff.is_empty());
652    }
653
654    #[test]
655    fn diff_wildcard_network_is_treated_as_new_entry() {
656        // Wildcard "*" is compared as a literal string entry.
657        // If the approved set has specific domains but the requested set
658        // adds a wildcard, the wildcard is detected as a new entry.
659        let approved = PluginPermissions {
660            network: vec!["api.example.com".into()],
661            ..Default::default()
662        };
663        let requested = PluginPermissions {
664            network: vec!["api.example.com".into(), "*".into()],
665            ..Default::default()
666        };
667        let diff = PluginPermissions::diff(&approved, &requested);
668        assert_eq!(diff.new_network, vec!["*"]);
669    }
670
671    #[test]
672    fn permission_diff_is_empty_default() {
673        let diff = PermissionDiff::default();
674        assert!(diff.is_empty());
675    }
676}