1use crate::behavior::{BehaviorMode, BehaviorPack};
7use anyhow::Result;
8use serde::{Deserialize, Serialize};
9use std::collections::HashSet;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct BehaviorPackDiff {
14 pub metadata: DiffMetadata,
16 pub capability_changes: CapabilityChanges,
18 pub parameter_changes: Vec<ParameterChange>,
20 pub guard_changes: GuardChanges,
22 pub risk_analysis: RiskAnalysis,
24 pub summary: DiffSummary,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct DiffMetadata {
31 pub from_pack: String,
33 pub to_pack: String,
35 pub from_mode: BehaviorMode,
37 pub to_mode: BehaviorMode,
39 pub timestamp: String,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct CapabilityChanges {
46 pub atoms_enabled: Vec<String>,
48 pub atoms_disabled: Vec<String>,
50 pub macros_enabled: Vec<String>,
52 pub macros_disabled: Vec<String>,
54 pub playbooks_enabled: Vec<String>,
56 pub playbooks_disabled: Vec<String>,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct ParameterChange {
63 pub capability: String,
65 pub key: String,
67 pub old_value: Option<serde_json::Value>,
69 pub new_value: Option<serde_json::Value>,
71 pub change_type: ParameterChangeType,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
77#[serde(rename_all = "snake_case")]
78pub enum ParameterChangeType {
79 Added,
81 Removed,
83 Modified,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct GuardChanges {
90 pub atom_guards: Vec<GuardChange>,
92 pub macro_guards: Vec<GuardChange>,
94 pub playbook_guards: Vec<GuardChange>,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct GuardChange {
101 pub setting: String,
103 pub old_value: serde_json::Value,
105 pub new_value: serde_json::Value,
107 pub impact: GuardImpact,
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize)]
113#[serde(rename_all = "snake_case")]
114pub enum GuardImpact {
115 Restrictive,
117 Permissive,
119 Neutral,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct RiskAnalysis {
126 pub overall_risk: RiskLevel,
128 pub security_scope: ScopeRiskAnalysis,
130 pub resource_limits: ResourceRiskAnalysis,
132 pub mode_risk: ModeRiskAnalysis,
134 pub scope_expansion_detected: bool,
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
140#[serde(rename_all = "snake_case")]
141pub enum RiskLevel {
142 Low,
143 Medium,
144 High,
145 Critical,
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct ScopeRiskAnalysis {
151 pub filesystem_changes: Vec<String>,
153 pub network_changes: Vec<String>,
155 pub risk_level: RiskLevel,
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct ResourceRiskAnalysis {
162 pub memory_limit_changes: Vec<String>,
164 pub time_limit_changes: Vec<String>,
166 pub risk_level: RiskLevel,
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize)]
172pub struct ModeRiskAnalysis {
173 pub risk_level: RiskLevel,
175 pub description: String,
177}
178
179#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct DiffSummary {
182 pub total_capability_changes: usize,
184 pub total_parameter_changes: usize,
186 pub total_guard_changes: usize,
188 pub requires_review: bool,
190 pub description: String,
192}
193
194impl BehaviorPackDiff {
195 pub fn compare(from: &BehaviorPack, to: &BehaviorPack) -> Result<Self> {
197 let metadata = DiffMetadata {
198 from_pack: from.name.clone(),
199 to_pack: to.name.clone(),
200 from_mode: from.mode.clone(),
201 to_mode: to.mode.clone(),
202 timestamp: chrono::Utc::now().to_rfc3339(),
203 };
204
205 let capability_changes = Self::analyze_capability_changes(from, to);
206 let parameter_changes = Self::analyze_parameter_changes(from, to);
207 let guard_changes = Self::analyze_guard_changes(from, to);
208 let risk_analysis = Self::analyze_risk(
209 &capability_changes,
210 ¶meter_changes,
211 &guard_changes,
212 &metadata,
213 );
214 let summary = Self::generate_summary(
215 &capability_changes,
216 ¶meter_changes,
217 &guard_changes,
218 &risk_analysis,
219 );
220
221 Ok(Self {
222 metadata,
223 capability_changes,
224 parameter_changes,
225 guard_changes,
226 risk_analysis,
227 summary,
228 })
229 }
230
231 fn analyze_capability_changes(from: &BehaviorPack, to: &BehaviorPack) -> CapabilityChanges {
233 let from_atoms: HashSet<_> = from.enable.atoms.iter().cloned().collect();
234 let to_atoms: HashSet<_> = to.enable.atoms.iter().cloned().collect();
235
236 let from_macros: HashSet<_> = from.enable.macros.iter().cloned().collect();
237 let to_macros: HashSet<_> = to.enable.macros.iter().cloned().collect();
238
239 let from_playbooks: HashSet<_> = from.enable.playbooks.iter().cloned().collect();
240 let to_playbooks: HashSet<_> = to.enable.playbooks.iter().cloned().collect();
241
242 CapabilityChanges {
243 atoms_enabled: to_atoms.difference(&from_atoms).cloned().collect(),
244 atoms_disabled: from_atoms.difference(&to_atoms).cloned().collect(),
245 macros_enabled: to_macros.difference(&from_macros).cloned().collect(),
246 macros_disabled: from_macros.difference(&to_macros).cloned().collect(),
247 playbooks_enabled: to_playbooks.difference(&from_playbooks).cloned().collect(),
248 playbooks_disabled: from_playbooks.difference(&to_playbooks).cloned().collect(),
249 }
250 }
251
252 fn analyze_parameter_changes(from: &BehaviorPack, to: &BehaviorPack) -> Vec<ParameterChange> {
254 let mut changes = Vec::new();
255
256 let mut all_capabilities = HashSet::new();
258 all_capabilities.extend(from.params.keys());
259 all_capabilities.extend(to.params.keys());
260
261 for capability in all_capabilities {
262 let from_params = from.params.get(capability);
263 let to_params = to.params.get(capability);
264
265 match (from_params, to_params) {
266 (None, Some(to_val)) => {
267 if let Some(obj) = to_val.as_object() {
269 for (key, value) in obj {
270 changes.push(ParameterChange {
271 capability: capability.clone(),
272 key: key.clone(),
273 old_value: None,
274 new_value: Some(value.clone()),
275 change_type: ParameterChangeType::Added,
276 });
277 }
278 }
279 }
280 (Some(_from_val), None) => {
281 changes.push(ParameterChange {
283 capability: capability.clone(),
284 key: "*".to_string(), old_value: from_params.cloned(),
286 new_value: None,
287 change_type: ParameterChangeType::Removed,
288 });
289 }
290 (Some(from_val), Some(to_val)) => {
291 Self::compare_param_objects(capability, from_val, to_val, &mut changes);
293 }
294 (None, None) => {
295 }
297 }
298 }
299
300 changes
301 }
302
303 fn compare_param_objects(
305 capability: &str,
306 from_val: &serde_json::Value,
307 to_val: &serde_json::Value,
308 changes: &mut Vec<ParameterChange>,
309 ) {
310 if let (Some(from_obj), Some(to_obj)) = (from_val.as_object(), to_val.as_object()) {
311 let mut all_keys = HashSet::new();
312 all_keys.extend(from_obj.keys());
313 all_keys.extend(to_obj.keys());
314
315 for key in all_keys {
316 let from_key_val = from_obj.get(key);
317 let to_key_val = to_obj.get(key);
318
319 match (from_key_val, to_key_val) {
320 (None, Some(new_val)) => {
321 changes.push(ParameterChange {
322 capability: capability.to_string(),
323 key: key.clone(),
324 old_value: None,
325 new_value: Some(new_val.clone()),
326 change_type: ParameterChangeType::Added,
327 });
328 }
329 (Some(old_val), None) => {
330 changes.push(ParameterChange {
331 capability: capability.to_string(),
332 key: key.clone(),
333 old_value: Some(old_val.clone()),
334 new_value: None,
335 change_type: ParameterChangeType::Removed,
336 });
337 }
338 (Some(old_val), Some(new_val)) => {
339 if old_val != new_val {
340 changes.push(ParameterChange {
341 capability: capability.to_string(),
342 key: key.clone(),
343 old_value: Some(old_val.clone()),
344 new_value: Some(new_val.clone()),
345 change_type: ParameterChangeType::Modified,
346 });
347 }
348 }
349 (None, None) => {
350 }
352 }
353 }
354 }
355 }
356
357 fn analyze_guard_changes(from: &BehaviorPack, to: &BehaviorPack) -> GuardChanges {
359 let mut guard_changes = GuardChanges {
360 atom_guards: Vec::new(),
361 macro_guards: Vec::new(),
362 playbook_guards: Vec::new(),
363 };
364
365 if let (Some(from_atom_guards), Some(to_atom_guards)) =
367 (&from.guards.atoms, &to.guards.atoms)
368 {
369 if from_atom_guards.default_max_bytes != to_atom_guards.default_max_bytes {
370 guard_changes.atom_guards.push(GuardChange {
371 setting: "default_max_bytes".to_string(),
372 old_value: serde_json::json!(from_atom_guards.default_max_bytes),
373 new_value: serde_json::json!(to_atom_guards.default_max_bytes),
374 impact: if to_atom_guards.default_max_bytes > from_atom_guards.default_max_bytes
375 {
376 GuardImpact::Permissive
377 } else {
378 GuardImpact::Restrictive
379 },
380 });
381 }
382
383 if from_atom_guards.require_justification != to_atom_guards.require_justification {
384 guard_changes.atom_guards.push(GuardChange {
385 setting: "require_justification".to_string(),
386 old_value: serde_json::json!(from_atom_guards.require_justification),
387 new_value: serde_json::json!(to_atom_guards.require_justification),
388 impact: if to_atom_guards.require_justification {
389 GuardImpact::Restrictive
390 } else {
391 GuardImpact::Permissive
392 },
393 });
394 }
395 }
396
397 if let (Some(from_pb_guards), Some(to_pb_guards)) =
399 (&from.guards.playbooks, &to.guards.playbooks)
400 {
401 if from_pb_guards.max_steps != to_pb_guards.max_steps {
402 guard_changes.playbook_guards.push(GuardChange {
403 setting: "max_steps".to_string(),
404 old_value: serde_json::json!(from_pb_guards.max_steps),
405 new_value: serde_json::json!(to_pb_guards.max_steps),
406 impact: if to_pb_guards.max_steps > from_pb_guards.max_steps {
407 GuardImpact::Permissive
408 } else {
409 GuardImpact::Restrictive
410 },
411 });
412 }
413 }
414
415 guard_changes
416 }
417
418 fn analyze_risk(
420 cap_changes: &CapabilityChanges,
421 param_changes: &[ParameterChange],
422 guard_changes: &GuardChanges,
423 metadata: &DiffMetadata,
424 ) -> RiskAnalysis {
425 let mut risk_factors = Vec::new();
426
427 let mode_risk = Self::assess_mode_risk(&metadata.from_mode, &metadata.to_mode);
429 risk_factors.push(mode_risk.risk_level.clone());
430
431 let total_new_capabilities = cap_changes.atoms_enabled.len()
433 + cap_changes.macros_enabled.len()
434 + cap_changes.playbooks_enabled.len();
435
436 if total_new_capabilities > 0 {
437 risk_factors.push(match total_new_capabilities {
438 1..=2 => RiskLevel::Low,
439 3..=5 => RiskLevel::Medium,
440 _ => RiskLevel::High,
441 });
442 }
443
444 let scope_expansion_detected = Self::detect_silent_scope_expansion(param_changes);
446 if scope_expansion_detected {
447 risk_factors.push(RiskLevel::Critical);
448 }
449
450 let permissive_guard_changes = guard_changes
452 .atom_guards
453 .iter()
454 .chain(guard_changes.macro_guards.iter())
455 .chain(guard_changes.playbook_guards.iter())
456 .filter(|change| matches!(change.impact, GuardImpact::Permissive))
457 .count();
458
459 if permissive_guard_changes > 0 {
460 risk_factors.push(RiskLevel::Medium);
461 }
462
463 let overall_risk = risk_factors.into_iter().max().unwrap_or(RiskLevel::Low);
465
466 RiskAnalysis {
467 overall_risk,
468 security_scope: ScopeRiskAnalysis {
469 filesystem_changes: Self::extract_filesystem_changes(param_changes),
470 network_changes: Self::extract_network_changes(param_changes),
471 risk_level: if scope_expansion_detected {
472 RiskLevel::High
473 } else {
474 RiskLevel::Low
475 },
476 },
477 resource_limits: ResourceRiskAnalysis {
478 memory_limit_changes: Self::extract_memory_changes(param_changes, guard_changes),
479 time_limit_changes: Self::extract_time_changes(param_changes),
480 risk_level: RiskLevel::Low, },
482 mode_risk,
483 scope_expansion_detected,
484 }
485 }
486
487 fn assess_mode_risk(from_mode: &BehaviorMode, to_mode: &BehaviorMode) -> ModeRiskAnalysis {
489 use BehaviorMode::*;
490
491 match (from_mode, to_mode) {
492 (Strict, Explore) => ModeRiskAnalysis {
493 risk_level: RiskLevel::Medium,
494 description: "Transition from strict to explore mode enables direct atom usage"
495 .to_string(),
496 },
497 (Strict, Shadow) => ModeRiskAnalysis {
498 risk_level: RiskLevel::Low,
499 description: "Transition to shadow mode - no actual execution".to_string(),
500 },
501 (Explore, Strict) => ModeRiskAnalysis {
502 risk_level: RiskLevel::Low,
503 description: "Transition to strict mode - more restrictive".to_string(),
504 },
505 (Explore, Shadow) => ModeRiskAnalysis {
506 risk_level: RiskLevel::Low,
507 description: "Transition to shadow mode - no actual execution".to_string(),
508 },
509 (Shadow, Strict) => ModeRiskAnalysis {
510 risk_level: RiskLevel::Low,
511 description: "Transition from shadow to strict mode".to_string(),
512 },
513 (Shadow, Explore) => ModeRiskAnalysis {
514 risk_level: RiskLevel::Medium,
515 description: "Transition from shadow to explore mode - enables execution"
516 .to_string(),
517 },
518 (Strict, Strict) | (Explore, Explore) | (Shadow, Shadow) => ModeRiskAnalysis {
519 risk_level: RiskLevel::Low,
520 description: "No mode change".to_string(),
521 },
522 }
523 }
524
525 fn detect_silent_scope_expansion(param_changes: &[ParameterChange]) -> bool {
527 for change in param_changes {
528 match &change.key as &str {
529 "hosts" => {
530 if let (Some(old_val), Some(new_val)) = (&change.old_value, &change.new_value) {
531 if Self::array_expanded(old_val, new_val) {
532 return true;
533 }
534 }
535 }
536 "paths" | "allowed_paths" => {
537 if let (Some(old_val), Some(new_val)) = (&change.old_value, &change.new_value) {
538 if Self::array_expanded(old_val, new_val) {
539 return true;
540 }
541 }
542 }
543 "max_bytes" | "timeout_ms" => {
544 if let (Some(old_val), Some(new_val)) = (&change.old_value, &change.new_value) {
545 if Self::numeric_increased(old_val, new_val) {
546 return true;
547 }
548 }
549 }
550 _ => {}
551 }
552 }
553 false
554 }
555
556 fn array_expanded(old_val: &serde_json::Value, new_val: &serde_json::Value) -> bool {
558 if let (Some(old_arr), Some(new_arr)) = (old_val.as_array(), new_val.as_array()) {
559 new_arr.len() > old_arr.len()
560 } else {
561 false
562 }
563 }
564
565 fn numeric_increased(old_val: &serde_json::Value, new_val: &serde_json::Value) -> bool {
567 if let (Some(old_num), Some(new_num)) = (old_val.as_f64(), new_val.as_f64()) {
568 new_num > old_num
569 } else {
570 false
571 }
572 }
573
574 fn extract_filesystem_changes(param_changes: &[ParameterChange]) -> Vec<String> {
576 param_changes
577 .iter()
578 .filter(|change| matches!(change.key.as_str(), "paths" | "allowed_paths" | "max_bytes"))
579 .map(|change| {
580 format!(
581 "{}.{}: {:?} → {:?}",
582 change.capability, change.key, change.old_value, change.new_value
583 )
584 })
585 .collect()
586 }
587
588 fn extract_network_changes(param_changes: &[ParameterChange]) -> Vec<String> {
590 param_changes
591 .iter()
592 .filter(|change| matches!(change.key.as_str(), "hosts" | "timeout_ms"))
593 .map(|change| {
594 format!(
595 "{}.{}: {:?} → {:?}",
596 change.capability, change.key, change.old_value, change.new_value
597 )
598 })
599 .collect()
600 }
601
602 fn extract_memory_changes(
604 param_changes: &[ParameterChange],
605 guard_changes: &GuardChanges,
606 ) -> Vec<String> {
607 let mut changes = Vec::new();
608
609 for change in param_changes {
611 if change.key.contains("memory") || change.key.contains("max_bytes") {
612 changes.push(format!(
613 "{}.{}: {:?} → {:?}",
614 change.capability, change.key, change.old_value, change.new_value
615 ));
616 }
617 }
618
619 for guard_change in &guard_changes.atom_guards {
621 if guard_change.setting == "default_max_bytes" {
622 changes.push(format!(
623 "guards.atoms.{}: {:?} → {:?}",
624 guard_change.setting, guard_change.old_value, guard_change.new_value
625 ));
626 }
627 }
628
629 changes
630 }
631
632 fn extract_time_changes(param_changes: &[ParameterChange]) -> Vec<String> {
634 param_changes
635 .iter()
636 .filter(|change| change.key.contains("timeout") || change.key.contains("duration"))
637 .map(|change| {
638 format!(
639 "{}.{}: {:?} → {:?}",
640 change.capability, change.key, change.old_value, change.new_value
641 )
642 })
643 .collect()
644 }
645
646 fn generate_summary(
648 cap_changes: &CapabilityChanges,
649 param_changes: &[ParameterChange],
650 guard_changes: &GuardChanges,
651 risk_analysis: &RiskAnalysis,
652 ) -> DiffSummary {
653 let total_capability_changes = cap_changes.atoms_enabled.len()
654 + cap_changes.atoms_disabled.len()
655 + cap_changes.macros_enabled.len()
656 + cap_changes.macros_disabled.len()
657 + cap_changes.playbooks_enabled.len()
658 + cap_changes.playbooks_disabled.len();
659
660 let total_guard_changes = guard_changes.atom_guards.len()
661 + guard_changes.macro_guards.len()
662 + guard_changes.playbook_guards.len();
663
664 let requires_review = matches!(
665 risk_analysis.overall_risk,
666 RiskLevel::High | RiskLevel::Critical
667 ) || risk_analysis.scope_expansion_detected
668 || total_capability_changes > 5;
669
670 let description = Self::generate_description(
671 total_capability_changes,
672 param_changes.len(),
673 total_guard_changes,
674 &risk_analysis.overall_risk,
675 );
676
677 DiffSummary {
678 total_capability_changes,
679 total_parameter_changes: param_changes.len(),
680 total_guard_changes,
681 requires_review,
682 description,
683 }
684 }
685
686 fn generate_description(
688 cap_changes: usize,
689 param_changes: usize,
690 guard_changes: usize,
691 risk_level: &RiskLevel,
692 ) -> String {
693 let mut parts = Vec::new();
694
695 if cap_changes > 0 {
696 parts.push(format!("{} capability changes", cap_changes));
697 }
698
699 if param_changes > 0 {
700 parts.push(format!("{} parameter changes", param_changes));
701 }
702
703 if guard_changes > 0 {
704 parts.push(format!("{} guard changes", guard_changes));
705 }
706
707 let changes_desc = if parts.is_empty() {
708 "No changes detected".to_string()
709 } else {
710 parts.join(", ")
711 };
712
713 let risk_desc = match risk_level {
714 RiskLevel::Low => "low risk",
715 RiskLevel::Medium => "medium risk",
716 RiskLevel::High => "high risk",
717 RiskLevel::Critical => "CRITICAL risk",
718 };
719
720 format!("{} ({})", changes_desc, risk_desc)
721 }
722
723 pub fn to_report(&self) -> String {
725 let mut report = String::new();
726
727 report.push_str("# Behavior Pack Diff Report\n\n");
728 report.push_str(&format!(
729 "**From:** {} ({})\n",
730 self.metadata.from_pack,
731 format!("{:?}", self.metadata.from_mode).to_lowercase()
732 ));
733 report.push_str(&format!(
734 "**To:** {} ({})\n",
735 self.metadata.to_pack,
736 format!("{:?}", self.metadata.to_mode).to_lowercase()
737 ));
738 report.push_str(&format!("**Timestamp:** {}\n\n", self.metadata.timestamp));
739
740 report.push_str("## Summary\n\n");
742 report.push_str(&format!("{}\n\n", self.summary.description));
743
744 report.push_str(&format!(
746 "**Total Changes:** {} capabilities, {} parameters, {} guards\n\n",
747 self.summary.total_capability_changes,
748 self.summary.total_parameter_changes,
749 self.summary.total_guard_changes
750 ));
751
752 if self.summary.requires_review {
753 report.push_str("⚠️ **Manual review required**\n\n");
754 }
755
756 report.push_str("## Risk Analysis\n\n");
758 report.push_str(&format!(
759 "**Risk Level:** {:?}\n",
760 self.risk_analysis.overall_risk
761 ));
762
763 if self.risk_analysis.scope_expansion_detected {
764 report.push_str("🚨 **Silent scope expansion detected**\n");
765 }
766
767 report.push_str(&format!(
768 "**Mode Change Risk:** {:?} - {}\n\n",
769 self.risk_analysis.mode_risk.risk_level, self.risk_analysis.mode_risk.description
770 ));
771
772 if self.summary.total_capability_changes > 0 {
774 report.push_str("## Capability Changes\n\n");
775
776 if !self.capability_changes.atoms_enabled.is_empty() {
777 report.push_str(&format!(
778 "**Atoms Enabled:** {}\n",
779 self.capability_changes.atoms_enabled.join(", ")
780 ));
781 }
782 if !self.capability_changes.atoms_disabled.is_empty() {
783 report.push_str(&format!(
784 "**Atoms Disabled:** {}\n",
785 self.capability_changes.atoms_disabled.join(", ")
786 ));
787 }
788 if !self.capability_changes.macros_enabled.is_empty() {
789 report.push_str(&format!(
790 "**Macros Enabled:** {}\n",
791 self.capability_changes.macros_enabled.join(", ")
792 ));
793 }
794 if !self.capability_changes.macros_disabled.is_empty() {
795 report.push_str(&format!(
796 "**Macros Disabled:** {}\n",
797 self.capability_changes.macros_disabled.join(", ")
798 ));
799 }
800 if !self.capability_changes.playbooks_enabled.is_empty() {
801 report.push_str(&format!(
802 "**Playbooks Enabled:** {}\n",
803 self.capability_changes.playbooks_enabled.join(", ")
804 ));
805 }
806 if !self.capability_changes.playbooks_disabled.is_empty() {
807 report.push_str(&format!(
808 "**Playbooks Disabled:** {}\n",
809 self.capability_changes.playbooks_disabled.join(", ")
810 ));
811 }
812 report.push('\n');
813 }
814
815 if !self.parameter_changes.is_empty() {
817 report.push_str("## Parameter Changes\n\n");
818 for change in &self.parameter_changes {
819 let change_symbol = match change.change_type {
820 ParameterChangeType::Added => "➕",
821 ParameterChangeType::Removed => "➖",
822 ParameterChangeType::Modified => "🔄",
823 };
824 report.push_str(&format!(
825 "{} **{}.{}:** {:?} → {:?}\n",
826 change_symbol,
827 change.capability,
828 change.key,
829 change.old_value,
830 change.new_value
831 ));
832 }
833 report.push('\n');
834 }
835
836 if self.summary.total_guard_changes > 0 {
838 report.push_str("## Guard Changes\n\n");
839 for change in &self.guard_changes.atom_guards {
840 let impact_symbol = match change.impact {
841 GuardImpact::Restrictive => "🔒",
842 GuardImpact::Permissive => "🔓",
843 GuardImpact::Neutral => "⚖️",
844 };
845 report.push_str(&format!(
846 "{} **atoms.{}:** {:?} → {:?}\n",
847 impact_symbol, change.setting, change.old_value, change.new_value
848 ));
849 }
850 report.push('\n');
851 }
852
853 report
854 }
855}
856
857#[cfg(test)]
862mod tests {
863 use super::*;
864 use crate::behavior::{EnabledCapabilities, GuardConfig};
865 use std::collections::HashMap;
866
867 #[test]
868 fn test_behavior_pack_diff_no_changes() {
869 let pack1 = BehaviorPack {
870 name: "test-pack".to_string(),
871 mode: BehaviorMode::Strict,
872 enable: EnabledCapabilities::default(),
873 params: HashMap::new(),
874 guards: GuardConfig::default(),
875 };
876
877 let pack2 = pack1.clone();
878
879 let diff = BehaviorPackDiff::compare(&pack1, &pack2).unwrap();
880 assert_eq!(diff.summary.total_capability_changes, 0);
881 assert_eq!(diff.summary.total_parameter_changes, 0);
882 assert!(matches!(diff.risk_analysis.overall_risk, RiskLevel::Low));
883 }
884
885 #[test]
886 fn test_capability_changes_detection() {
887 let pack1 = BehaviorPack {
888 name: "pack1".to_string(),
889 mode: BehaviorMode::Strict,
890 enable: EnabledCapabilities {
891 atoms: vec!["atom1".to_string()],
892 macros: vec!["macro1".to_string()],
893 playbooks: vec!["playbook1".to_string()],
894 },
895 params: HashMap::new(),
896 guards: GuardConfig::default(),
897 };
898
899 let pack2 = BehaviorPack {
900 name: "pack2".to_string(),
901 mode: BehaviorMode::Strict,
902 enable: EnabledCapabilities {
903 atoms: vec!["atom1".to_string(), "atom2".to_string()],
904 macros: vec!["macro2".to_string()],
905 playbooks: vec!["playbook1".to_string()],
906 },
907 params: HashMap::new(),
908 guards: GuardConfig::default(),
909 };
910
911 let diff = BehaviorPackDiff::compare(&pack1, &pack2).unwrap();
912
913 assert_eq!(diff.capability_changes.atoms_enabled, vec!["atom2"]);
914 assert_eq!(diff.capability_changes.macros_enabled, vec!["macro2"]);
915 assert_eq!(diff.capability_changes.macros_disabled, vec!["macro1"]);
916 assert!(diff.summary.total_capability_changes > 0);
917 }
918
919 #[test]
920 fn test_scope_expansion_detection() {
921 let mut params1 = HashMap::new();
922 params1.insert(
923 "http.fetch.v1".to_string(),
924 serde_json::json!({
925 "hosts": ["api.example.com"]
926 }),
927 );
928
929 let mut params2 = HashMap::new();
930 params2.insert(
931 "http.fetch.v1".to_string(),
932 serde_json::json!({
933 "hosts": ["api.example.com", "untrusted.com"]
934 }),
935 );
936
937 let pack1 = BehaviorPack {
938 name: "pack1".to_string(),
939 mode: BehaviorMode::Strict,
940 enable: EnabledCapabilities::default(),
941 params: params1,
942 guards: GuardConfig::default(),
943 };
944
945 let pack2 = BehaviorPack {
946 name: "pack2".to_string(),
947 mode: BehaviorMode::Strict,
948 enable: EnabledCapabilities::default(),
949 params: params2,
950 guards: GuardConfig::default(),
951 };
952
953 let diff = BehaviorPackDiff::compare(&pack1, &pack2).unwrap();
954 assert!(diff.risk_analysis.scope_expansion_detected);
955 assert!(matches!(
956 diff.risk_analysis.overall_risk,
957 RiskLevel::Critical
958 ));
959 }
960}