1use serde::{Deserialize, Serialize};
39use thiserror::Error;
40use vellaveto_types::{has_dangerous_chars, Action};
41
42pub const MAX_PLUGINS: usize = 64;
48
49pub const MAX_PLUGIN_NAME_LEN: usize = 256;
51
52pub const MAX_PLUGIN_PATH_LEN: usize = 4096;
54
55pub const MIN_MEMORY_LIMIT: u64 = 1_048_576;
57
58pub const MAX_MEMORY_LIMIT: u64 = 268_435_456;
60
61const MIN_FUEL_LIMIT: u64 = 1_000;
63
64const MAX_FUEL_LIMIT: u64 = 10_000_000_000;
66
67const MIN_TIMEOUT_MS: u64 = 1;
69
70const MAX_TIMEOUT_MS: u64 = 10_000;
72
73const MAX_REASON_LEN: usize = 4096;
75
76#[derive(Error, Debug)]
82pub enum PluginError {
83 #[error("plugin config validation failed: {0}")]
85 ConfigValidation(String),
86
87 #[error("maximum plugin count ({MAX_PLUGINS}) exceeded")]
89 MaxPluginsExceeded,
90
91 #[error("plugin already loaded: {0}")]
93 DuplicatePlugin(String),
94
95 #[error("plugin load failed: {0}")]
97 LoadFailed(String),
98
99 #[error("plugin evaluation error in '{plugin_name}': {reason}")]
101 EvaluationFailed {
102 plugin_name: String,
104 reason: String,
106 },
107
108 #[error("plugin serialization error: {0}")]
110 Serialization(String),
111
112 #[error("plugin '{plugin_name}' exceeded resource limit: {resource}")]
114 ResourceExceeded {
115 plugin_name: String,
117 resource: String,
119 },
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize)]
132#[serde(deny_unknown_fields)]
133pub struct PluginAction {
134 pub tool: String,
136 pub function: String,
138 pub parameters: serde_json::Value,
140 pub target_paths: Vec<String>,
142 pub target_domains: Vec<String>,
144}
145
146impl PluginAction {
147 pub fn from_action(action: &Action) -> Self {
152 Self {
159 tool: crate::normalize::normalize_full(&action.tool),
160 function: crate::normalize::normalize_full(&action.function),
161 parameters: action.parameters.clone(),
162 target_paths: action
163 .target_paths
164 .iter()
165 .filter_map(|p| {
166 match crate::PolicyEngine::normalize_path(p) {
171 Ok(norm) => Some(norm),
172 Err(_) => {
173 tracing::warn!(
174 path = %vellaveto_types::sanitize_for_log(p, 128),
175 "PluginAction: path normalization failed, omitting (fail-closed)"
176 );
177 None
178 }
179 }
180 })
181 .collect(),
182 target_domains: action
183 .target_domains
184 .iter()
185 .map(|d| crate::normalize::normalize_full(d))
186 .collect(),
187 }
188 }
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize)]
193#[serde(deny_unknown_fields)]
194pub struct PluginVerdict {
195 pub allow: bool,
197 pub reason: Option<String>,
199}
200
201impl PluginVerdict {
202 pub fn validate(&self) -> Result<(), PluginError> {
204 if let Some(ref reason) = self.reason {
205 if reason.len() > MAX_REASON_LEN {
206 return Err(PluginError::ConfigValidation(format!(
207 "verdict reason exceeds {MAX_REASON_LEN} bytes"
208 )));
209 }
210 if has_dangerous_chars(reason) {
211 return Err(PluginError::ConfigValidation(
212 "verdict reason contains control or format characters".to_string(),
213 ));
214 }
215 }
216 Ok(())
217 }
218}
219
220pub trait PolicyPlugin: Send + Sync {
229 fn name(&self) -> &str;
231
232 fn evaluate(&self, action: &PluginAction) -> Result<PluginVerdict, PluginError>;
237}
238
239#[derive(Debug, Clone, Serialize, Deserialize)]
245#[serde(deny_unknown_fields)]
246pub struct PluginConfig {
247 pub name: String,
249 pub path: String,
251 pub memory_limit_bytes: u64,
253 pub fuel_limit: u64,
255 pub timeout_ms: u64,
257}
258
259impl PluginConfig {
260 pub fn validate(&self) -> Result<(), PluginError> {
264 if self.name.is_empty() {
266 return Err(PluginError::ConfigValidation(
267 "plugin name must not be empty".to_string(),
268 ));
269 }
270 if self.name.len() > MAX_PLUGIN_NAME_LEN {
271 return Err(PluginError::ConfigValidation(format!(
272 "plugin name exceeds {MAX_PLUGIN_NAME_LEN} bytes"
273 )));
274 }
275 if has_dangerous_chars(&self.name) {
276 return Err(PluginError::ConfigValidation(
277 "plugin name contains control or format characters".to_string(),
278 ));
279 }
280
281 if self.path.is_empty() {
283 return Err(PluginError::ConfigValidation(
284 "plugin path must not be empty".to_string(),
285 ));
286 }
287 if self.path.len() > MAX_PLUGIN_PATH_LEN {
288 return Err(PluginError::ConfigValidation(format!(
289 "plugin path exceeds {MAX_PLUGIN_PATH_LEN} bytes"
290 )));
291 }
292 if has_dangerous_chars(&self.path) {
293 return Err(PluginError::ConfigValidation(
294 "plugin path contains control or format characters".to_string(),
295 ));
296 }
297 for component in std::path::Path::new(&self.path).components() {
301 if matches!(component, std::path::Component::ParentDir) {
302 return Err(PluginError::ConfigValidation(
303 "plugin path must not contain '..' (path traversal)".to_string(),
304 ));
305 }
306 }
307
308 if self.memory_limit_bytes < MIN_MEMORY_LIMIT {
310 return Err(PluginError::ConfigValidation(format!(
311 "memory_limit_bytes ({}) below minimum ({MIN_MEMORY_LIMIT})",
312 self.memory_limit_bytes
313 )));
314 }
315 if self.memory_limit_bytes > MAX_MEMORY_LIMIT {
316 return Err(PluginError::ConfigValidation(format!(
317 "memory_limit_bytes ({}) exceeds maximum ({MAX_MEMORY_LIMIT})",
318 self.memory_limit_bytes
319 )));
320 }
321
322 if self.fuel_limit < MIN_FUEL_LIMIT {
324 return Err(PluginError::ConfigValidation(format!(
325 "fuel_limit ({}) below minimum ({MIN_FUEL_LIMIT})",
326 self.fuel_limit
327 )));
328 }
329 if self.fuel_limit > MAX_FUEL_LIMIT {
330 return Err(PluginError::ConfigValidation(format!(
331 "fuel_limit ({}) exceeds maximum ({MAX_FUEL_LIMIT})",
332 self.fuel_limit
333 )));
334 }
335
336 if self.timeout_ms < MIN_TIMEOUT_MS {
338 return Err(PluginError::ConfigValidation(format!(
339 "timeout_ms ({}) below minimum ({MIN_TIMEOUT_MS})",
340 self.timeout_ms
341 )));
342 }
343 if self.timeout_ms > MAX_TIMEOUT_MS {
344 return Err(PluginError::ConfigValidation(format!(
345 "timeout_ms ({}) exceeds maximum ({MAX_TIMEOUT_MS})",
346 self.timeout_ms
347 )));
348 }
349
350 Ok(())
351 }
352}
353
354#[derive(Debug, Clone, Serialize, Deserialize)]
356#[serde(deny_unknown_fields)]
357pub struct PluginManagerConfig {
358 pub enabled: bool,
360 pub max_plugins: usize,
362 pub default_memory_limit: u64,
364 pub default_fuel_limit: u64,
366 pub default_timeout_ms: u64,
368}
369
370impl Default for PluginManagerConfig {
371 fn default() -> Self {
372 Self {
373 enabled: false,
374 max_plugins: 32,
375 default_memory_limit: 16 * 1024 * 1024, default_fuel_limit: 100_000_000,
377 default_timeout_ms: 5,
378 }
379 }
380}
381
382impl PluginManagerConfig {
383 pub fn validate(&self) -> Result<(), PluginError> {
385 if self.max_plugins > MAX_PLUGINS {
386 return Err(PluginError::ConfigValidation(format!(
387 "max_plugins ({}) exceeds hard limit ({MAX_PLUGINS})",
388 self.max_plugins
389 )));
390 }
391 if self.max_plugins == 0 && self.enabled {
392 return Err(PluginError::ConfigValidation(
393 "max_plugins cannot be 0 when plugins are enabled".to_string(),
394 ));
395 }
396 if self.default_memory_limit < MIN_MEMORY_LIMIT {
397 return Err(PluginError::ConfigValidation(format!(
398 "default_memory_limit ({}) below minimum ({MIN_MEMORY_LIMIT})",
399 self.default_memory_limit
400 )));
401 }
402 if self.default_memory_limit > MAX_MEMORY_LIMIT {
403 return Err(PluginError::ConfigValidation(format!(
404 "default_memory_limit ({}) exceeds maximum ({MAX_MEMORY_LIMIT})",
405 self.default_memory_limit
406 )));
407 }
408 if self.default_fuel_limit < MIN_FUEL_LIMIT {
409 return Err(PluginError::ConfigValidation(format!(
410 "default_fuel_limit ({}) below minimum ({MIN_FUEL_LIMIT})",
411 self.default_fuel_limit
412 )));
413 }
414 if self.default_fuel_limit > MAX_FUEL_LIMIT {
415 return Err(PluginError::ConfigValidation(format!(
416 "default_fuel_limit ({}) exceeds maximum ({MAX_FUEL_LIMIT})",
417 self.default_fuel_limit
418 )));
419 }
420 if self.default_timeout_ms < MIN_TIMEOUT_MS {
421 return Err(PluginError::ConfigValidation(format!(
422 "default_timeout_ms ({}) below minimum ({MIN_TIMEOUT_MS})",
423 self.default_timeout_ms
424 )));
425 }
426 if self.default_timeout_ms > MAX_TIMEOUT_MS {
427 return Err(PluginError::ConfigValidation(format!(
428 "default_timeout_ms ({}) exceeds maximum ({MAX_TIMEOUT_MS})",
429 self.default_timeout_ms
430 )));
431 }
432 Ok(())
433 }
434}
435
436struct LoadedPlugin {
442 config: PluginConfig,
443 instance: Box<dyn PolicyPlugin>,
444}
445
446pub struct PluginManager {
458 plugins: Vec<LoadedPlugin>,
459 config: PluginManagerConfig,
460}
461
462impl std::fmt::Debug for PluginManager {
463 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
464 f.debug_struct("PluginManager")
465 .field("plugin_count", &self.plugins.len())
466 .field("config", &self.config)
467 .finish()
468 }
469}
470
471impl PluginManager {
472 pub fn new(config: PluginManagerConfig) -> Result<Self, PluginError> {
476 config.validate()?;
477 Ok(Self {
478 plugins: Vec::new(),
479 config,
480 })
481 }
482
483 pub fn load_plugin(
494 &mut self,
495 config: PluginConfig,
496 instance: Box<dyn PolicyPlugin>,
497 ) -> Result<(), PluginError> {
498 if !self.config.enabled {
499 return Err(PluginError::ConfigValidation(
500 "plugin system is not enabled".to_string(),
501 ));
502 }
503
504 config.validate()?;
505
506 if self.plugins.len() >= self.config.max_plugins {
507 return Err(PluginError::MaxPluginsExceeded);
508 }
509
510 if self
513 .plugins
514 .iter()
515 .any(|p| p.config.name.eq_ignore_ascii_case(&config.name))
516 {
517 return Err(PluginError::DuplicatePlugin(config.name.clone()));
518 }
519
520 self.plugins.push(LoadedPlugin { config, instance });
521 Ok(())
522 }
523
524 pub fn evaluate_all(&self, action: &Action) -> Vec<(String, PluginVerdict)> {
532 if !self.config.enabled {
533 return Vec::new();
534 }
535
536 let plugin_action = PluginAction::from_action(action);
537 let mut results = Vec::with_capacity(self.plugins.len());
538
539 for loaded in &self.plugins {
540 let name = loaded.config.name.clone();
541 let verdict = match loaded.instance.evaluate(&plugin_action) {
542 Ok(v) => {
543 match v.validate() {
545 Ok(()) => v,
546 Err(e) => {
547 let raw = format!("plugin '{name}' returned invalid verdict: {e}");
549 PluginVerdict {
550 allow: false,
551 reason: Some(vellaveto_types::sanitize_for_log(
552 &raw,
553 MAX_REASON_LEN,
554 )),
555 }
556 }
557 }
558 }
559 Err(e) => {
560 let raw_reason = format!("plugin '{name}' error: {e}");
564 let sanitized = vellaveto_types::sanitize_for_log(&raw_reason, MAX_REASON_LEN);
565 PluginVerdict {
566 allow: false,
567 reason: Some(sanitized),
568 }
569 }
570 };
571 results.push((name, verdict));
572 }
573
574 results
575 }
576
577 pub fn reload_plugins(
582 &mut self,
583 configs_and_instances: Vec<(PluginConfig, Box<dyn PolicyPlugin>)>,
584 ) -> Result<(), PluginError> {
585 if !self.config.enabled {
586 return Err(PluginError::ConfigValidation(
587 "plugin system is not enabled".to_string(),
588 ));
589 }
590
591 if configs_and_instances.len() > self.config.max_plugins {
592 return Err(PluginError::MaxPluginsExceeded);
593 }
594
595 let mut names = std::collections::HashSet::new();
598 for (config, _) in &configs_and_instances {
599 config.validate()?;
600 if !names.insert(config.name.to_ascii_lowercase()) {
601 return Err(PluginError::DuplicatePlugin(config.name.clone()));
602 }
603 }
604
605 self.plugins = configs_and_instances
607 .into_iter()
608 .map(|(config, instance)| LoadedPlugin { config, instance })
609 .collect();
610
611 Ok(())
612 }
613
614 pub fn plugin_count(&self) -> usize {
616 self.plugins.len()
617 }
618
619 pub fn plugin_names(&self) -> Vec<&str> {
621 self.plugins
622 .iter()
623 .map(|p| p.config.name.as_str())
624 .collect()
625 }
626
627 pub fn is_enabled(&self) -> bool {
629 self.config.enabled
630 }
631}
632
633#[cfg(test)]
638mod tests {
639 use super::*;
640 use serde_json::json;
641
642 struct StubPlugin {
644 plugin_name: String,
645 verdict: PluginVerdict,
646 }
647
648 impl StubPlugin {
649 fn allowing(name: &str) -> Self {
650 Self {
651 plugin_name: name.to_string(),
652 verdict: PluginVerdict {
653 allow: true,
654 reason: None,
655 },
656 }
657 }
658
659 fn denying(name: &str, reason: &str) -> Self {
660 Self {
661 plugin_name: name.to_string(),
662 verdict: PluginVerdict {
663 allow: false,
664 reason: Some(reason.to_string()),
665 },
666 }
667 }
668 }
669
670 impl PolicyPlugin for StubPlugin {
671 fn name(&self) -> &str {
672 &self.plugin_name
673 }
674
675 fn evaluate(&self, _action: &PluginAction) -> Result<PluginVerdict, PluginError> {
676 Ok(self.verdict.clone())
677 }
678 }
679
680 struct ErrorPlugin {
682 plugin_name: String,
683 }
684
685 impl PolicyPlugin for ErrorPlugin {
686 fn name(&self) -> &str {
687 &self.plugin_name
688 }
689
690 fn evaluate(&self, _action: &PluginAction) -> Result<PluginVerdict, PluginError> {
691 Err(PluginError::EvaluationFailed {
692 plugin_name: self.plugin_name.clone(),
693 reason: "simulated failure".to_string(),
694 })
695 }
696 }
697
698 fn valid_plugin_config(name: &str) -> PluginConfig {
699 PluginConfig {
700 name: name.to_string(),
701 path: "/opt/vellaveto/plugins/test.wasm".to_string(),
702 memory_limit_bytes: 16 * 1024 * 1024,
703 fuel_limit: 100_000_000,
704 timeout_ms: 5,
705 }
706 }
707
708 fn enabled_manager_config() -> PluginManagerConfig {
709 PluginManagerConfig {
710 enabled: true,
711 ..PluginManagerConfig::default()
712 }
713 }
714
715 fn test_action() -> Action {
716 Action::new("filesystem", "read", json!({"path": "/etc/passwd"}))
717 }
718
719 #[test]
722 fn test_plugin_config_validation_valid() {
723 let config = valid_plugin_config("my-plugin");
724 assert!(config.validate().is_ok());
725 }
726
727 #[test]
728 fn test_plugin_config_validation_empty_name() {
729 let mut config = valid_plugin_config("test");
730 config.name = String::new();
731 let err = config.validate().unwrap_err();
732 assert!(err.to_string().contains("must not be empty"));
733 }
734
735 #[test]
736 fn test_invalid_plugin_name_too_long() {
737 let mut config = valid_plugin_config("test");
738 config.name = "x".repeat(MAX_PLUGIN_NAME_LEN + 1);
739 let err = config.validate().unwrap_err();
740 assert!(err.to_string().contains("exceeds"));
741 }
742
743 #[test]
744 fn test_invalid_plugin_name_control_chars() {
745 let mut config = valid_plugin_config("test");
746 config.name = "plugin\x00name".to_string();
747 let err = config.validate().unwrap_err();
748 assert!(err.to_string().contains("control or format characters"));
749 }
750
751 #[test]
752 fn test_invalid_plugin_path_empty() {
753 let mut config = valid_plugin_config("test");
754 config.path = String::new();
755 let err = config.validate().unwrap_err();
756 assert!(err.to_string().contains("must not be empty"));
757 }
758
759 #[test]
760 fn test_invalid_plugin_path_traversal() {
761 let mut config = valid_plugin_config("test");
762 config.path = "/opt/../etc/passwd".to_string();
763 let err = config.validate().unwrap_err();
764 assert!(err.to_string().contains("path traversal"));
765 }
766
767 #[test]
768 fn test_invalid_plugin_path_control_chars() {
769 let mut config = valid_plugin_config("test");
770 config.path = "/opt/plugins/\x07evil.wasm".to_string();
771 let err = config.validate().unwrap_err();
772 assert!(err.to_string().contains("control or format characters"));
773 }
774
775 #[test]
776 fn test_memory_limit_bounds_too_low() {
777 let mut config = valid_plugin_config("test");
778 config.memory_limit_bytes = MIN_MEMORY_LIMIT - 1;
779 let err = config.validate().unwrap_err();
780 assert!(err.to_string().contains("below minimum"));
781 }
782
783 #[test]
784 fn test_memory_limit_bounds_too_high() {
785 let mut config = valid_plugin_config("test");
786 config.memory_limit_bytes = MAX_MEMORY_LIMIT + 1;
787 let err = config.validate().unwrap_err();
788 assert!(err.to_string().contains("exceeds maximum"));
789 }
790
791 #[test]
792 fn test_memory_limit_bounds_edge_values() {
793 let mut config = valid_plugin_config("test");
794 config.memory_limit_bytes = MIN_MEMORY_LIMIT;
795 assert!(config.validate().is_ok());
796 config.memory_limit_bytes = MAX_MEMORY_LIMIT;
797 assert!(config.validate().is_ok());
798 }
799
800 #[test]
801 fn test_fuel_limit_validation_too_low() {
802 let mut config = valid_plugin_config("test");
803 config.fuel_limit = 0;
804 let err = config.validate().unwrap_err();
805 assert!(err.to_string().contains("below minimum"));
806 }
807
808 #[test]
809 fn test_fuel_limit_validation_too_high() {
810 let mut config = valid_plugin_config("test");
811 config.fuel_limit = MAX_FUEL_LIMIT + 1;
812 let err = config.validate().unwrap_err();
813 assert!(err.to_string().contains("exceeds maximum"));
814 }
815
816 #[test]
817 fn test_timeout_bounds() {
818 let mut config = valid_plugin_config("test");
819 config.timeout_ms = 0;
820 let err = config.validate().unwrap_err();
821 assert!(err.to_string().contains("below minimum"));
822
823 config.timeout_ms = MAX_TIMEOUT_MS + 1;
824 let err = config.validate().unwrap_err();
825 assert!(err.to_string().contains("exceeds maximum"));
826
827 config.timeout_ms = MIN_TIMEOUT_MS;
828 assert!(config.validate().is_ok());
829 config.timeout_ms = MAX_TIMEOUT_MS;
830 assert!(config.validate().is_ok());
831 }
832
833 #[test]
836 fn test_plugin_manager_creation_default() {
837 let config = PluginManagerConfig::default();
838 let mgr = PluginManager::new(config);
840 assert!(mgr.is_ok());
841 let mgr = mgr.unwrap();
842 assert!(!mgr.is_enabled());
843 assert_eq!(mgr.plugin_count(), 0);
844 }
845
846 #[test]
847 fn test_plugin_manager_creation_enabled() {
848 let config = enabled_manager_config();
849 let mgr = PluginManager::new(config);
850 assert!(mgr.is_ok());
851 assert!(mgr.unwrap().is_enabled());
852 }
853
854 #[test]
855 fn test_plugin_manager_config_max_plugins_exceeded() {
856 let config = PluginManagerConfig {
857 enabled: true,
858 max_plugins: MAX_PLUGINS + 1,
859 ..PluginManagerConfig::default()
860 };
861 let err = PluginManager::new(config).unwrap_err();
862 assert!(err.to_string().contains("exceeds hard limit"));
863 }
864
865 #[test]
866 fn test_plugin_manager_config_zero_plugins_when_enabled() {
867 let config = PluginManagerConfig {
868 enabled: true,
869 max_plugins: 0,
870 ..PluginManagerConfig::default()
871 };
872 let err = PluginManager::new(config).unwrap_err();
873 assert!(err.to_string().contains("cannot be 0"));
874 }
875
876 #[test]
879 fn test_plugin_action_from_action() {
880 let action = Action {
881 tool: "fs".to_string(),
882 function: "read".to_string(),
883 parameters: json!({"path": "/tmp/test"}),
884 target_paths: vec!["/tmp/test".to_string()],
885 target_domains: vec!["example.com".to_string()],
886 resolved_ips: vec!["93.184.216.34".to_string()],
887 };
888
889 let plugin_action = PluginAction::from_action(&action);
890
891 assert_eq!(plugin_action.tool, "fs");
892 assert_eq!(plugin_action.function, "read");
893 assert_eq!(plugin_action.parameters, json!({"path": "/tmp/test"}));
894 assert_eq!(plugin_action.target_paths, vec!["/tmp/test"]);
895 assert_eq!(plugin_action.target_domains, vec!["example.com"]);
896 }
898
899 #[test]
900 fn test_plugin_action_serialization_roundtrip() {
901 let action = PluginAction {
902 tool: "http".to_string(),
903 function: "get".to_string(),
904 parameters: json!({"url": "https://example.com"}),
905 target_paths: vec![],
906 target_domains: vec!["example.com".to_string()],
907 };
908
909 let serialized = serde_json::to_string(&action);
910 assert!(serialized.is_ok());
911 let deserialized: Result<PluginAction, _> =
912 serde_json::from_str(serialized.as_ref().unwrap());
913 assert!(deserialized.is_ok());
914 let roundtripped = deserialized.unwrap();
915 assert_eq!(roundtripped.tool, "http");
916 assert_eq!(roundtripped.function, "get");
917 }
918
919 #[test]
922 fn test_plugin_verdict_serialization() {
923 let verdict = PluginVerdict {
924 allow: false,
925 reason: Some("blocked by custom policy".to_string()),
926 };
927
928 let serialized = serde_json::to_string(&verdict);
929 assert!(serialized.is_ok());
930 let json_str = serialized.unwrap();
931 assert!(json_str.contains("\"allow\":false"));
932 assert!(json_str.contains("blocked by custom policy"));
933
934 let deserialized: Result<PluginVerdict, _> = serde_json::from_str(&json_str);
935 assert!(deserialized.is_ok());
936 let v = deserialized.unwrap();
937 assert!(!v.allow);
938 assert_eq!(v.reason.as_deref(), Some("blocked by custom policy"));
939 }
940
941 #[test]
942 fn test_plugin_verdict_validation_reason_too_long() {
943 let verdict = PluginVerdict {
944 allow: false,
945 reason: Some("x".repeat(MAX_REASON_LEN + 1)),
946 };
947 assert!(verdict.validate().is_err());
948 }
949
950 #[test]
951 fn test_plugin_verdict_validation_reason_control_chars() {
952 let verdict = PluginVerdict {
953 allow: true,
954 reason: Some("okay\x00not-okay".to_string()),
955 };
956 assert!(verdict.validate().is_err());
957 }
958
959 #[test]
962 fn test_load_plugin_success() {
963 let mut mgr = PluginManager::new(enabled_manager_config()).unwrap();
964 let config = valid_plugin_config("test-plugin");
965 let plugin = Box::new(StubPlugin::allowing("test-plugin"));
966 assert!(mgr.load_plugin(config, plugin).is_ok());
967 assert_eq!(mgr.plugin_count(), 1);
968 assert_eq!(mgr.plugin_names(), vec!["test-plugin"]);
969 }
970
971 #[test]
972 fn test_load_plugin_disabled_system() {
973 let mut mgr = PluginManager::new(PluginManagerConfig::default()).unwrap();
974 let config = valid_plugin_config("test-plugin");
975 let plugin = Box::new(StubPlugin::allowing("test-plugin"));
976 let err = mgr.load_plugin(config, plugin).unwrap_err();
977 assert!(err.to_string().contains("not enabled"));
978 }
979
980 #[test]
981 fn test_max_plugins_bound_enforced() {
982 let config = PluginManagerConfig {
983 enabled: true,
984 max_plugins: 2,
985 ..PluginManagerConfig::default()
986 };
987 let mut mgr = PluginManager::new(config).unwrap();
988
989 let p1 = valid_plugin_config("plugin-1");
990 mgr.load_plugin(p1, Box::new(StubPlugin::allowing("plugin-1")))
991 .unwrap();
992
993 let p2 = valid_plugin_config("plugin-2");
994 mgr.load_plugin(p2, Box::new(StubPlugin::allowing("plugin-2")))
995 .unwrap();
996
997 let p3 = valid_plugin_config("plugin-3");
998 let err = mgr
999 .load_plugin(p3, Box::new(StubPlugin::allowing("plugin-3")))
1000 .unwrap_err();
1001 assert!(matches!(err, PluginError::MaxPluginsExceeded));
1002 }
1003
1004 #[test]
1005 fn test_duplicate_plugin_name_rejected() {
1006 let mut mgr = PluginManager::new(enabled_manager_config()).unwrap();
1007 let config1 = valid_plugin_config("my-plugin");
1008 mgr.load_plugin(config1, Box::new(StubPlugin::allowing("my-plugin")))
1009 .unwrap();
1010
1011 let config2 = valid_plugin_config("my-plugin");
1012 let err = mgr
1013 .load_plugin(config2, Box::new(StubPlugin::allowing("my-plugin")))
1014 .unwrap_err();
1015 assert!(matches!(err, PluginError::DuplicatePlugin(_)));
1016 }
1017
1018 #[test]
1021 fn test_evaluate_all_empty_returns_empty() {
1022 let mgr = PluginManager::new(enabled_manager_config()).unwrap();
1023 let action = test_action();
1024 let results = mgr.evaluate_all(&action);
1025 assert!(results.is_empty());
1026 }
1027
1028 #[test]
1029 fn test_evaluate_all_disabled_returns_empty() {
1030 let mgr = PluginManager::new(PluginManagerConfig::default()).unwrap();
1031 let action = test_action();
1032 let results = mgr.evaluate_all(&action);
1033 assert!(results.is_empty());
1034 }
1035
1036 #[test]
1037 fn test_evaluate_all_allow_and_deny() {
1038 let mut mgr = PluginManager::new(enabled_manager_config()).unwrap();
1039
1040 let c1 = valid_plugin_config("allow-plugin");
1041 mgr.load_plugin(c1, Box::new(StubPlugin::allowing("allow-plugin")))
1042 .unwrap();
1043
1044 let c2 = valid_plugin_config("deny-plugin");
1045 mgr.load_plugin(c2, Box::new(StubPlugin::denying("deny-plugin", "blocked")))
1046 .unwrap();
1047
1048 let action = test_action();
1049 let results = mgr.evaluate_all(&action);
1050
1051 assert_eq!(results.len(), 2);
1052 assert_eq!(results[0].0, "allow-plugin");
1053 assert!(results[0].1.allow);
1054 assert_eq!(results[1].0, "deny-plugin");
1055 assert!(!results[1].1.allow);
1056 assert_eq!(results[1].1.reason.as_deref(), Some("blocked"));
1057 }
1058
1059 #[test]
1060 fn test_evaluate_all_error_produces_deny() {
1061 let mut mgr = PluginManager::new(enabled_manager_config()).unwrap();
1062 let config = valid_plugin_config("error-plugin");
1063 mgr.load_plugin(
1064 config,
1065 Box::new(ErrorPlugin {
1066 plugin_name: "error-plugin".to_string(),
1067 }),
1068 )
1069 .unwrap();
1070
1071 let action = test_action();
1072 let results = mgr.evaluate_all(&action);
1073
1074 assert_eq!(results.len(), 1);
1075 assert!(!results[0].1.allow, "plugin error must produce deny");
1076 let reason = results[0].1.reason.as_deref().unwrap_or("");
1077 assert!(reason.contains("error"));
1078 }
1079
1080 struct ControlCharErrorPlugin;
1084
1085 impl PolicyPlugin for ControlCharErrorPlugin {
1086 fn name(&self) -> &str {
1087 "control-char-plugin"
1088 }
1089
1090 fn evaluate(&self, _action: &PluginAction) -> Result<PluginVerdict, PluginError> {
1091 Err(PluginError::EvaluationFailed {
1092 plugin_name: "control-char-plugin".to_string(),
1093 reason: "injected\x00null\x07bell\x1besc".to_string(),
1094 })
1095 }
1096 }
1097
1098 #[test]
1099 fn test_r252_eng1_error_reason_sanitized_no_control_chars() {
1100 let mut mgr = PluginManager::new(enabled_manager_config()).unwrap();
1101 let config = valid_plugin_config("control-char-plugin");
1102 mgr.load_plugin(config, Box::new(ControlCharErrorPlugin))
1103 .unwrap();
1104
1105 let action = test_action();
1106 let results = mgr.evaluate_all(&action);
1107
1108 assert_eq!(results.len(), 1);
1109 assert!(!results[0].1.allow, "plugin error must produce deny");
1110 let reason = results[0].1.reason.as_deref().unwrap_or("");
1111 assert!(
1113 !reason.chars().any(|c| c.is_control() && c != '\n'),
1114 "reason must not contain control chars after sanitization, got: {:?}",
1115 reason
1116 );
1117 }
1118
1119 struct ControlCharVerdictPlugin;
1121
1122 impl PolicyPlugin for ControlCharVerdictPlugin {
1123 fn name(&self) -> &str {
1124 "control-verdict-plugin"
1125 }
1126
1127 fn evaluate(&self, _action: &PluginAction) -> Result<PluginVerdict, PluginError> {
1128 Ok(PluginVerdict {
1129 allow: false,
1130 reason: Some("good\x00bad\x1bchars".to_string()),
1131 })
1132 }
1133 }
1134
1135 #[test]
1136 fn test_r252_eng1_invalid_verdict_reason_sanitized() {
1137 let mut mgr = PluginManager::new(enabled_manager_config()).unwrap();
1138 let config = valid_plugin_config("control-verdict-plugin");
1139 mgr.load_plugin(config, Box::new(ControlCharVerdictPlugin))
1140 .unwrap();
1141
1142 let action = test_action();
1143 let results = mgr.evaluate_all(&action);
1144
1145 assert_eq!(results.len(), 1);
1146 assert!(!results[0].1.allow, "invalid verdict must produce deny");
1147 let reason = results[0].1.reason.as_deref().unwrap_or("");
1148 assert!(
1150 !reason.chars().any(|c| c.is_control() && c != '\n'),
1151 "reason must not contain control chars, got: {:?}",
1152 reason
1153 );
1154 }
1155
1156 #[test]
1159 fn test_reload_plugins_replaces_all() {
1160 let mut mgr = PluginManager::new(enabled_manager_config()).unwrap();
1161
1162 let c1 = valid_plugin_config("old-plugin");
1164 mgr.load_plugin(c1, Box::new(StubPlugin::allowing("old-plugin")))
1165 .unwrap();
1166 assert_eq!(mgr.plugin_count(), 1);
1167
1168 let new_plugins: Vec<(PluginConfig, Box<dyn PolicyPlugin>)> = vec![
1170 (
1171 valid_plugin_config("new-1"),
1172 Box::new(StubPlugin::allowing("new-1")),
1173 ),
1174 (
1175 valid_plugin_config("new-2"),
1176 Box::new(StubPlugin::denying("new-2", "policy")),
1177 ),
1178 ];
1179
1180 assert!(mgr.reload_plugins(new_plugins).is_ok());
1181 assert_eq!(mgr.plugin_count(), 2);
1182 assert_eq!(mgr.plugin_names(), vec!["new-1", "new-2"]);
1183 }
1184
1185 #[test]
1186 fn test_reload_plugins_atomic_on_validation_failure() {
1187 let mut mgr = PluginManager::new(enabled_manager_config()).unwrap();
1188
1189 let c1 = valid_plugin_config("original");
1191 mgr.load_plugin(c1, Box::new(StubPlugin::allowing("original")))
1192 .unwrap();
1193
1194 let mut invalid_config = valid_plugin_config("bad-plugin");
1196 invalid_config.memory_limit_bytes = 0; let new_plugins: Vec<(PluginConfig, Box<dyn PolicyPlugin>)> = vec![
1199 (
1200 valid_plugin_config("good"),
1201 Box::new(StubPlugin::allowing("good")),
1202 ),
1203 (invalid_config, Box::new(StubPlugin::allowing("bad-plugin"))),
1204 ];
1205
1206 let result = mgr.reload_plugins(new_plugins);
1207 assert!(result.is_err());
1208 assert_eq!(mgr.plugin_count(), 1);
1210 assert_eq!(mgr.plugin_names(), vec!["original"]);
1211 }
1212
1213 #[test]
1214 fn test_reload_plugins_duplicate_names_rejected() {
1215 let mut mgr = PluginManager::new(enabled_manager_config()).unwrap();
1216
1217 let new_plugins: Vec<(PluginConfig, Box<dyn PolicyPlugin>)> = vec![
1218 (
1219 valid_plugin_config("same-name"),
1220 Box::new(StubPlugin::allowing("same-name")),
1221 ),
1222 (
1223 valid_plugin_config("same-name"),
1224 Box::new(StubPlugin::allowing("same-name")),
1225 ),
1226 ];
1227
1228 let err = mgr.reload_plugins(new_plugins).unwrap_err();
1229 assert!(matches!(err, PluginError::DuplicatePlugin(_)));
1230 }
1231
1232 #[test]
1233 fn test_r237_eng1_reload_plugins_case_variant_duplicate_rejected() {
1234 let mut mgr = PluginManager::new(enabled_manager_config()).unwrap();
1237
1238 let new_plugins: Vec<(PluginConfig, Box<dyn PolicyPlugin>)> = vec![
1239 (
1240 valid_plugin_config("MyPlugin"),
1241 Box::new(StubPlugin::allowing("MyPlugin")),
1242 ),
1243 (
1244 valid_plugin_config("myplugin"),
1245 Box::new(StubPlugin::allowing("myplugin")),
1246 ),
1247 ];
1248
1249 let err = mgr.reload_plugins(new_plugins).unwrap_err();
1250 assert!(
1251 matches!(err, PluginError::DuplicatePlugin(_)),
1252 "Case-variant duplicate plugin names must be rejected: {err:?}"
1253 );
1254 }
1255
1256 #[test]
1257 fn test_reload_plugins_disabled_system() {
1258 let mut mgr = PluginManager::new(PluginManagerConfig::default()).unwrap();
1259 let result = mgr.reload_plugins(Vec::new());
1260 assert!(result.is_err());
1261 }
1262
1263 #[test]
1266 fn test_plugin_error_types_display() {
1267 let err = PluginError::ConfigValidation("bad config".to_string());
1268 assert!(err.to_string().contains("bad config"));
1269
1270 let err = PluginError::MaxPluginsExceeded;
1271 assert!(err.to_string().contains("64"));
1272
1273 let err = PluginError::DuplicatePlugin("dup".to_string());
1274 assert!(err.to_string().contains("dup"));
1275
1276 let err = PluginError::LoadFailed("module error".to_string());
1277 assert!(err.to_string().contains("module error"));
1278
1279 let err = PluginError::EvaluationFailed {
1280 plugin_name: "test".to_string(),
1281 reason: "timeout".to_string(),
1282 };
1283 assert!(err.to_string().contains("test"));
1284 assert!(err.to_string().contains("timeout"));
1285
1286 let err = PluginError::Serialization("json error".to_string());
1287 assert!(err.to_string().contains("json error"));
1288
1289 let err = PluginError::ResourceExceeded {
1290 plugin_name: "heavy".to_string(),
1291 resource: "memory".to_string(),
1292 };
1293 assert!(err.to_string().contains("heavy"));
1294 assert!(err.to_string().contains("memory"));
1295 }
1296
1297 #[test]
1300 fn test_plugin_verdict_deny_unknown_fields() {
1301 let json_str = r#"{"allow": true, "reason": null, "extra_field": "bad"}"#;
1302 let result: Result<PluginVerdict, _> = serde_json::from_str(json_str);
1303 assert!(
1304 result.is_err(),
1305 "deny_unknown_fields should reject extra fields"
1306 );
1307 }
1308
1309 #[test]
1310 fn test_plugin_action_deny_unknown_fields() {
1311 let json_str = r#"{"tool":"t","function":"f","parameters":{},"target_paths":[],"target_domains":[],"evil":"yes"}"#;
1312 let result: Result<PluginAction, _> = serde_json::from_str(json_str);
1313 assert!(
1314 result.is_err(),
1315 "deny_unknown_fields should reject extra fields"
1316 );
1317 }
1318
1319 #[test]
1322 fn test_r238_eng1_plugin_action_path_normalization_fail_closed() {
1323 let mut traversal_path = String::from("/");
1326 for _ in 0..500 {
1327 traversal_path.push_str("..%2f");
1328 }
1329 let action = Action {
1330 tool: "fs".to_string(),
1331 function: "read".to_string(),
1332 parameters: json!({}),
1333 target_paths: vec!["/tmp/safe".to_string(), traversal_path],
1334 target_domains: vec![],
1335 resolved_ips: vec![],
1336 };
1337
1338 let plugin_action = PluginAction::from_action(&action);
1339
1340 assert!(
1344 plugin_action.target_paths.len() <= 2,
1345 "paths should be bounded"
1346 );
1347 assert!(
1349 plugin_action.target_paths.iter().any(|p| p.contains("tmp")),
1350 "safe path should be present"
1351 );
1352 }
1353
1354 #[test]
1355 fn test_plugin_config_deny_unknown_fields() {
1356 let json_str = r#"{"name":"n","path":"/p","memory_limit_bytes":1048576,"fuel_limit":1000,"timeout_ms":5,"rogue":true}"#;
1357 let result: Result<PluginConfig, _> = serde_json::from_str(json_str);
1358 assert!(
1359 result.is_err(),
1360 "deny_unknown_fields should reject extra fields"
1361 );
1362 }
1363}