1pub mod auto_classifier;
18
19use serde::{Deserialize, Serialize};
20use std::sync::Arc;
21use tokio::sync::RwLock;
22
23#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum DecisionReason {
30 Rule { source: RuleSource, pattern: String },
32 Mode(PermissionMode),
34 Hook { name: String },
36 SafetyCheck { path: String },
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum Permission {
43 Allowed(DecisionReason),
45 Denied(DecisionReason, String),
47 Unknown,
53}
54
55impl Permission {
56 pub fn is_allowed(&self) -> bool {
58 matches!(self, Permission::Allowed(_))
59 }
60
61 pub fn is_denied(&self) -> bool {
63 matches!(self, Permission::Denied(_, _))
64 }
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Default)]
90#[serde(rename_all = "camelCase")]
91pub enum PermissionMode {
92 #[serde(alias = "allow")]
95 #[default]
96 Default,
97 AcceptEdits,
99 BypassPermissions,
101 #[serde(alias = "deny")]
103 #[serde(alias = "interactive")]
104 DontAsk,
105 Plan {
110 pre_plan_mode: Box<PermissionMode>,
112 bypass_available: bool,
115 },
116
117 Auto,
124}
125
126impl<'de> Deserialize<'de> for PermissionMode {
129 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
130 where
131 D: serde::Deserializer<'de>,
132 {
133 use serde::de::{self, MapAccess, Visitor};
134 use std::fmt;
135
136 struct PermissionModeVisitor;
137
138 impl<'de> Visitor<'de> for PermissionModeVisitor {
139 type Value = PermissionMode;
140
141 fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
142 formatter.write_str(
143 "a permission mode string (\"default\", \"acceptEdits\", \
144 \"bypassPermissions\", \"dontAsk\", \"plan\", \"auto\", \
145 or legacy \"allow\"/\"deny\"/\"interactive\") \
146 or a Plan object {\"prePlanMode\": \"...\", \"bypassAvailable\": bool}",
147 )
148 }
149
150 fn visit_str<E: de::Error>(self, s: &str) -> Result<PermissionMode, E> {
151 match s {
152 "default" | "allow" => Ok(PermissionMode::Default),
153 "acceptEdits" | "accept_edits" => Ok(PermissionMode::AcceptEdits),
154 "bypassPermissions" | "bypass_permissions" => {
155 Ok(PermissionMode::BypassPermissions)
156 }
157 "dontAsk" | "dont_ask" | "deny" | "interactive" => Ok(PermissionMode::DontAsk),
158 "auto" => Ok(PermissionMode::Auto),
159 "plan" => Ok(PermissionMode::Plan {
160 pre_plan_mode: Box::new(PermissionMode::Default),
161 bypass_available: false,
162 }),
163 _ => Err(de::Error::unknown_variant(
164 s,
165 &[
166 "default",
167 "acceptEdits",
168 "bypassPermissions",
169 "auto",
170 "dontAsk",
171 "plan",
172 ],
173 )),
174 }
175 }
176
177 fn visit_map<M: MapAccess<'de>>(self, mut map: M) -> Result<PermissionMode, M::Error> {
178 let mut pre_plan_mode: Option<PermissionMode> = None;
181 let mut bypass_available: Option<bool> = None;
182
183 while let Some(key) = map.next_key::<String>()? {
184 match key.as_str() {
185 "prePlanMode" | "pre_plan_mode" => {
186 if pre_plan_mode.is_some() {
187 return Err(de::Error::duplicate_field("pre_plan_mode"));
188 }
189 pre_plan_mode = Some(map.next_value()?);
190 }
191 "bypassAvailable" | "bypass_available" => {
192 if bypass_available.is_some() {
193 return Err(de::Error::duplicate_field("bypass_available"));
194 }
195 bypass_available = Some(map.next_value()?);
196 }
197 other => {
198 return Err(de::Error::unknown_field(
199 other,
200 &[
201 "pre_plan_mode",
202 "prePlanMode",
203 "bypass_available",
204 "bypassAvailable",
205 ],
206 ));
207 }
208 }
209 }
210
211 let pre_plan_mode =
212 pre_plan_mode.ok_or_else(|| de::Error::missing_field("pre_plan_mode"))?;
213 let bypass_available = bypass_available.unwrap_or(false);
214 Ok(PermissionMode::Plan {
215 pre_plan_mode: Box::new(pre_plan_mode),
216 bypass_available,
217 })
218 }
219 }
220
221 deserializer.deserialize_any(PermissionModeVisitor)
222 }
223}
224
225#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize)]
231pub enum RuleSource {
232 Session,
234 Project,
236 #[default]
238 User,
239}
240
241#[derive(Debug, Clone, Default, Deserialize)]
243pub struct PermissionLayer {
244 pub source: RuleSource,
246 pub allow: Vec<String>,
248 pub deny: Vec<String>,
250 pub interactive: Vec<String>,
252}
253
254#[derive(Debug, Clone, Default, Deserialize)]
265pub struct LayeredPermissionsConfig {
266 #[serde(default)]
268 pub mode: PermissionMode,
269 #[serde(default)]
271 pub layers: Vec<PermissionLayer>,
272}
273
274impl LayeredPermissionsConfig {
275 pub fn check_static(
300 &self,
301 tool_name: &str,
302 is_readonly: bool,
303 content: Option<&str>,
304 ) -> Permission {
305 if !is_readonly {
308 if let Some(path) = extract_file_path_from_content(tool_name, content) {
309 for protected in PROTECTED_PATHS {
310 if path_contains_protected(&path, protected) {
311 return Permission::Denied(
312 DecisionReason::SafetyCheck { path: path.clone() },
313 format!(
314 "writing to `{path}` is protected and requires explicit confirmation"
315 ),
316 );
317 }
318 }
319 }
320 }
321
322 if let PermissionMode::Plan {
324 bypass_available, ..
325 } = &self.mode
326 {
327 if !is_readonly && tool_name != "exit_plan_mode" && !bypass_available {
328 return Permission::Denied(
329 DecisionReason::Mode(self.mode.clone()),
330 "write tools are blocked in plan mode".to_string(),
331 );
332 }
333 }
335
336 if matches!(self.mode, PermissionMode::BypassPermissions) {
338 return Permission::Allowed(DecisionReason::Mode(self.mode.clone()));
339 }
340
341 if matches!(self.mode, PermissionMode::DontAsk) && self.any_interactive(tool_name) {
343 return Permission::Denied(
344 DecisionReason::Mode(self.mode.clone()),
345 format!("tool `{tool_name}` requires interaction but mode is dontAsk"),
346 );
347 }
348
349 if matches!(self.mode, PermissionMode::AcceptEdits) && !is_readonly {
351 return Permission::Allowed(DecisionReason::Mode(self.mode.clone()));
352 }
353
354 for pattern in self.all_deny() {
356 if matches_pattern(pattern, tool_name) {
357 return Permission::Denied(
358 DecisionReason::Rule {
359 source: RuleSource::User,
360 pattern: pattern.to_string(),
361 },
362 format!("tool `{tool_name}` matches deny pattern `{pattern}`"),
363 );
364 }
365 }
366 for pattern in self.all_allow() {
367 if matches_pattern(pattern, tool_name) {
368 return Permission::Allowed(DecisionReason::Rule {
369 source: RuleSource::User,
370 pattern: pattern.to_string(),
371 });
372 }
373 }
374
375 Permission::Unknown
377 }
378
379 pub fn is_plan_mode(&self, tool_name: &str) -> bool {
384 let _ = tool_name; matches!(self.mode, PermissionMode::Plan { .. })
388 }
389
390 pub fn is_interactive(&self, tool_name: &str) -> bool {
395 if matches!(
397 self.check_static(tool_name, false, None),
398 Permission::Denied(_, _)
399 ) {
400 return false;
401 }
402 self.any_interactive(tool_name)
403 }
404
405 pub fn any_interactive(&self, tool_name: &str) -> bool {
407 self.all_interactive()
408 .any(|p| matches_pattern(p, tool_name))
409 }
410
411 pub fn all_deny(&self) -> impl Iterator<Item = &str> {
413 self.layers
414 .iter()
415 .flat_map(|l| l.deny.iter())
416 .map(|s| s.as_str())
417 }
418
419 pub fn all_allow(&self) -> impl Iterator<Item = &str> {
421 self.layers
422 .iter()
423 .flat_map(|l| l.allow.iter())
424 .map(|s| s.as_str())
425 }
426
427 pub fn all_interactive(&self) -> impl Iterator<Item = &str> {
429 self.layers
430 .iter()
431 .flat_map(|l| l.interactive.iter())
432 .map(|s| s.as_str())
433 }
434
435 pub fn add_session_rule(&mut self, behavior: RuleBehavior, pattern: String) {
447 let layer = self.session_layer_mut();
448 match behavior {
449 RuleBehavior::Allow => layer.allow.push(pattern),
450 RuleBehavior::Deny => layer.deny.push(pattern),
451 RuleBehavior::Interactive => layer.interactive.push(pattern),
452 }
453 }
454
455 pub fn remove_session_rule(&mut self, behavior: RuleBehavior, pattern: &str) {
461 let layer = self.session_layer_mut();
462 let list = match behavior {
463 RuleBehavior::Allow => &mut layer.allow,
464 RuleBehavior::Deny => &mut layer.deny,
465 RuleBehavior::Interactive => &mut layer.interactive,
466 };
467 list.retain(|r| !r.contains(pattern));
468 }
469
470 pub fn session_rules(&self) -> &PermissionLayer {
475 self.layers
476 .iter()
477 .find(|l| l.source == RuleSource::Session)
478 .expect("session layer always present after first mutation")
479 }
480
481 fn session_layer_mut(&mut self) -> &mut PermissionLayer {
485 if !self.layers.iter().any(|l| l.source == RuleSource::Session) {
486 self.layers.insert(
487 0,
488 PermissionLayer {
489 source: RuleSource::Session,
490 ..Default::default()
491 },
492 );
493 }
494 self.layers
495 .iter_mut()
496 .find(|l| l.source == RuleSource::Session)
497 .expect("session layer just created")
498 }
499}
500
501#[derive(Debug, Clone, Deserialize)]
508#[serde(default)]
509#[derive(Default)]
510pub struct OldPermissionsConfig {
511 #[serde(default)]
513 pub allow: Vec<String>,
514 #[serde(default)]
516 pub deny: Vec<String>,
517 #[serde(default)]
519 pub interactive: Vec<String>,
520 #[serde(default)]
522 pub plan: Vec<String>,
523 #[serde(default)]
528 pub mode: PermissionMode,
529}
530
531impl From<OldPermissionsConfig> for LayeredPermissionsConfig {
532 fn from(old: OldPermissionsConfig) -> Self {
533 let mut layers = Vec::new();
534 if !old.allow.is_empty() || !old.deny.is_empty() || !old.interactive.is_empty() {
535 layers.push(PermissionLayer {
536 source: RuleSource::User,
537 allow: old.allow,
538 deny: old.deny,
539 interactive: old.interactive,
540 });
541 }
542 LayeredPermissionsConfig {
543 mode: old.mode,
544 layers,
545 }
546 }
547}
548
549pub type PermissionsConfig = LayeredPermissionsConfig;
554
555pub type SharedPermissions = Arc<RwLock<LayeredPermissionsConfig>>;
563
564#[derive(Debug, Clone, PartialEq, Eq)]
566pub enum RuleBehavior {
567 Allow,
569 Deny,
571 Interactive,
573}
574
575const PROTECTED_PATHS: &[&str] = &[
590 ".git",
591 ".recursive",
592 ".ssh",
593 ".gnupg",
594 ".bashrc",
595 ".zshrc",
596 ".profile",
597 ".bash_profile",
598 ".bash_logout",
599 ".env",
600];
601
602fn extract_file_path_from_content(tool_name: &str, content: Option<&str>) -> Option<String> {
613 let content = content?;
614 match tool_name {
615 "write_file" | "read_file" => Some(content.to_string()),
616 "apply_patch" => {
617 for line in content.lines() {
618 for prefix in ["*** Update File: ", "*** Add File: ", "*** Delete File: "] {
619 if let Some(rest) = line.strip_prefix(prefix) {
620 return Some(rest.trim().to_string());
621 }
622 }
623 }
624 None
625 }
626 _ => None,
627 }
628}
629
630fn path_contains_protected(path: &str, protected: &str) -> bool {
636 std::path::Path::new(path)
637 .components()
638 .any(|c| c.as_os_str().to_string_lossy().as_ref() == protected)
639}
640
641fn matches_pattern(pattern: &str, name: &str) -> bool {
649 if let Some(prefix) = pattern.strip_suffix('*') {
650 if prefix.is_empty() {
651 return true;
653 }
654 name.starts_with(prefix)
655 } else {
656 name == pattern
657 }
658}
659
660#[cfg(test)]
661mod tests {
662 use super::*;
663
664 #[test]
667 fn test_permission_mode_default_is_default() {
668 assert_eq!(PermissionMode::default(), PermissionMode::Default);
669 }
670
671 #[test]
672 fn test_permission_mode_deserialize_default() {
673 let mode: PermissionMode = serde_json::from_str("\"default\"").unwrap();
674 assert_eq!(mode, PermissionMode::Default);
675 }
676
677 #[test]
678 fn test_permission_mode_deserialize_accept_edits() {
679 let mode: PermissionMode = serde_json::from_str("\"acceptEdits\"").unwrap();
680 assert_eq!(mode, PermissionMode::AcceptEdits);
681 }
682
683 #[test]
684 fn test_permission_mode_deserialize_bypass() {
685 let mode: PermissionMode = serde_json::from_str("\"bypassPermissions\"").unwrap();
686 assert_eq!(mode, PermissionMode::BypassPermissions);
687 }
688
689 #[test]
690 fn test_permission_mode_deserialize_dont_ask() {
691 let mode: PermissionMode = serde_json::from_str("\"dontAsk\"").unwrap();
692 assert_eq!(mode, PermissionMode::DontAsk);
693 }
694
695 #[test]
696 fn test_permission_mode_deserialize_plan_string() {
697 let mode: PermissionMode = serde_json::from_str("\"plan\"").unwrap();
698 assert_eq!(
699 mode,
700 PermissionMode::Plan {
701 pre_plan_mode: Box::new(PermissionMode::Default),
702 bypass_available: false,
703 }
704 );
705 }
706
707 #[test]
708 fn test_permission_mode_deserialize_plan_object() {
709 let json = r#"{"prePlanMode": "acceptEdits", "bypassAvailable": true}"#;
710 let mode: PermissionMode = serde_json::from_str(json).unwrap();
711 assert_eq!(
712 mode,
713 PermissionMode::Plan {
714 pre_plan_mode: Box::new(PermissionMode::AcceptEdits),
715 bypass_available: true,
716 }
717 );
718 }
719
720 #[test]
721 fn test_permission_mode_deserialize_plan_object_default_bypass() {
722 let json = r#"{"prePlanMode": "default"}"#;
723 let mode: PermissionMode = serde_json::from_str(json).unwrap();
724 assert_eq!(
725 mode,
726 PermissionMode::Plan {
727 pre_plan_mode: Box::new(PermissionMode::Default),
728 bypass_available: false,
729 }
730 );
731 }
732
733 #[test]
736 fn test_permission_mode_old_allow_is_default() {
737 let mode: PermissionMode = serde_json::from_str("\"allow\"").unwrap();
738 assert_eq!(mode, PermissionMode::Default);
739 }
740
741 #[test]
742 fn test_permission_mode_old_deny_is_dont_ask() {
743 let mode: PermissionMode = serde_json::from_str("\"deny\"").unwrap();
744 assert_eq!(mode, PermissionMode::DontAsk);
745 }
746
747 #[test]
748 fn test_permission_mode_old_interactive_is_dont_ask() {
749 let mode: PermissionMode = serde_json::from_str("\"interactive\"").unwrap();
750 assert_eq!(mode, PermissionMode::DontAsk);
751 }
752
753 #[test]
754 fn test_permission_mode_snake_case_accept_edits() {
755 let mode: PermissionMode = serde_json::from_str("\"accept_edits\"").unwrap();
756 assert_eq!(mode, PermissionMode::AcceptEdits);
757 }
758
759 #[test]
760 fn test_permission_mode_snake_case_bypass() {
761 let mode: PermissionMode = serde_json::from_str("\"bypass_permissions\"").unwrap();
762 assert_eq!(mode, PermissionMode::BypassPermissions);
763 }
764
765 #[test]
766 fn test_permission_mode_snake_case_dont_ask() {
767 let mode: PermissionMode = serde_json::from_str("\"dont_ask\"").unwrap();
768 assert_eq!(mode, PermissionMode::DontAsk);
769 }
770
771 #[test]
774 fn test_mode_serialize_default() {
775 let json = serde_json::to_string(&PermissionMode::Default).unwrap();
776 assert_eq!(json, "\"default\"");
777 }
778
779 #[test]
780 fn test_mode_serialize_accept_edits() {
781 let json = serde_json::to_string(&PermissionMode::AcceptEdits).unwrap();
782 assert_eq!(json, "\"acceptEdits\"");
783 }
784
785 #[test]
786 fn test_mode_serialize_bypass() {
787 let json = serde_json::to_string(&PermissionMode::BypassPermissions).unwrap();
788 assert_eq!(json, "\"bypassPermissions\"");
789 }
790
791 #[test]
792 fn test_mode_serialize_dont_ask() {
793 let json = serde_json::to_string(&PermissionMode::DontAsk).unwrap();
794 assert_eq!(json, "\"dontAsk\"");
795 }
796
797 #[test]
798 fn test_mode_serialize_plan() {
799 let mode = PermissionMode::Plan {
800 pre_plan_mode: Box::new(PermissionMode::AcceptEdits),
801 bypass_available: true,
802 };
803 let json = serde_json::to_string(&mode).unwrap();
804 assert!(json.contains("\"pre_plan_mode\":\"acceptEdits\""));
805 assert!(json.contains("\"bypass_available\":true"));
806 }
807
808 #[test]
812 fn test_plan_mode_blocks_write() {
813 let config = LayeredPermissionsConfig {
814 mode: PermissionMode::Plan {
815 pre_plan_mode: Box::new(PermissionMode::Default),
816 bypass_available: false,
817 },
818 ..Default::default()
819 };
820 let result = config.check_static("write_file", false, None);
821 assert!(result.is_denied());
822 if let Permission::Denied(reason, msg) = result {
823 assert!(matches!(
824 reason,
825 DecisionReason::Mode(PermissionMode::Plan { .. })
826 ));
827 assert!(msg.contains("plan mode"));
828 } else {
829 panic!("expected Denied");
830 }
831 }
832
833 #[test]
835 fn test_plan_mode_allows_exit() {
836 let config = LayeredPermissionsConfig {
837 mode: PermissionMode::Plan {
838 pre_plan_mode: Box::new(PermissionMode::Default),
839 bypass_available: false,
840 },
841 ..Default::default()
842 };
843 let result = config.check_static("exit_plan_mode", false, None);
845 assert!(!result.is_denied());
846 assert!(matches!(result, Permission::Unknown));
848 }
849
850 #[test]
853 fn test_plan_mode_bypass_write_continues() {
854 let config = LayeredPermissionsConfig {
855 mode: PermissionMode::Plan {
856 pre_plan_mode: Box::new(PermissionMode::BypassPermissions),
857 bypass_available: true,
858 },
859 ..Default::default()
860 };
861 let result = config.check_static("write_file", false, None);
864 assert!(!result.is_denied());
865 assert!(matches!(result, Permission::Unknown));
866 }
867
868 #[test]
870 fn test_bypass_skips_deny_rules() {
871 let config = LayeredPermissionsConfig {
872 mode: PermissionMode::BypassPermissions,
873 layers: vec![PermissionLayer {
874 source: RuleSource::User,
875 deny: vec!["run_shell".into()],
876 ..Default::default()
877 }],
878 };
879 let result = config.check_static("run_shell", false, None);
880 assert!(result.is_allowed());
881 if let Permission::Allowed(reason) = result {
882 assert!(matches!(
883 reason,
884 DecisionReason::Mode(PermissionMode::BypassPermissions)
885 ));
886 } else {
887 panic!("expected Allowed");
888 }
889 }
890
891 #[test]
893 fn test_dontask_converts_interactive() {
894 let config = LayeredPermissionsConfig {
895 mode: PermissionMode::DontAsk,
896 layers: vec![PermissionLayer {
897 source: RuleSource::User,
898 interactive: vec!["run_shell".into()],
899 ..Default::default()
900 }],
901 };
902 let result = config.check_static("run_shell", false, None);
903 assert!(result.is_denied());
904 if let Permission::Denied(reason, msg) = result {
905 assert!(matches!(
906 reason,
907 DecisionReason::Mode(PermissionMode::DontAsk)
908 ));
909 assert!(msg.contains("dontAsk"));
910 } else {
911 panic!("expected Denied");
912 }
913 }
914
915 #[test]
917 fn test_accept_edits_allows_write() {
918 let config = LayeredPermissionsConfig {
919 mode: PermissionMode::AcceptEdits,
920 ..Default::default()
921 };
922 let result = config.check_static("write_file", false, None);
923 assert!(result.is_allowed());
924 if let Permission::Allowed(reason) = result {
925 assert!(matches!(
926 reason,
927 DecisionReason::Mode(PermissionMode::AcceptEdits)
928 ));
929 } else {
930 panic!("expected Allowed");
931 }
932 }
933
934 #[test]
936 fn test_deny_rule_takes_effect() {
937 let config = LayeredPermissionsConfig {
938 mode: PermissionMode::Default,
939 layers: vec![PermissionLayer {
940 source: RuleSource::User,
941 deny: vec!["run_shell".into()],
942 ..Default::default()
943 }],
944 };
945 let result = config.check_static("run_shell", true, None);
946 assert!(result.is_denied());
947 if let Permission::Denied(reason, _msg) = result {
948 assert!(matches!(
949 reason,
950 DecisionReason::Rule {
951 source: RuleSource::User,
952 ..
953 }
954 ));
955 } else {
956 panic!("expected Denied");
957 }
958 }
959
960 #[test]
962 fn test_allow_rule_takes_effect() {
963 let config = LayeredPermissionsConfig {
964 mode: PermissionMode::Default,
965 layers: vec![PermissionLayer {
966 source: RuleSource::User,
967 allow: vec!["read_file".into()],
968 ..Default::default()
969 }],
970 };
971 let result = config.check_static("read_file", true, None);
972 assert!(result.is_allowed());
973 if let Permission::Allowed(reason) = result {
974 assert!(matches!(
975 reason,
976 DecisionReason::Rule {
977 source: RuleSource::User,
978 ..
979 }
980 ));
981 } else {
982 panic!("expected Allowed");
983 }
984 }
985
986 #[test]
989 fn test_plan_mode_allows_readonly() {
990 let config = LayeredPermissionsConfig {
991 mode: PermissionMode::Plan {
992 pre_plan_mode: Box::new(PermissionMode::Default),
993 bypass_available: false,
994 },
995 ..Default::default()
996 };
997 let result = config.check_static("read_file", true, None);
999 assert!(!result.is_denied());
1000 }
1001
1002 #[test]
1005 fn test_any_interactive_detects_match() {
1006 let config = LayeredPermissionsConfig {
1007 layers: vec![PermissionLayer {
1008 source: RuleSource::User,
1009 interactive: vec!["run_*".into()],
1010 ..Default::default()
1011 }],
1012 ..Default::default()
1013 };
1014 assert!(config.any_interactive("run_shell"));
1015 assert!(!config.any_interactive("read_file"));
1016 }
1017
1018 #[test]
1021 fn test_empty_config_passthrough() {
1022 let config = LayeredPermissionsConfig::default();
1023 let result = config.check_static("anything", false, None);
1025 assert!(matches!(result, Permission::Unknown));
1026 }
1027
1028 #[test]
1029 fn test_single_layer_deny() {
1030 let config = LayeredPermissionsConfig {
1031 layers: vec![PermissionLayer {
1032 source: RuleSource::User,
1033 deny: vec!["run_shell".into()],
1034 ..Default::default()
1035 }],
1036 ..Default::default()
1037 };
1038 assert!(config.check_static("run_shell", false, None).is_denied());
1039 assert!(!config.check_static("read_file", false, None).is_denied());
1040 }
1041
1042 #[test]
1043 fn test_single_layer_allow() {
1044 let config = LayeredPermissionsConfig {
1045 layers: vec![PermissionLayer {
1046 source: RuleSource::User,
1047 allow: vec!["read_file".into()],
1048 ..Default::default()
1049 }],
1050 ..Default::default()
1051 };
1052 assert!(config.check_static("read_file", false, None).is_allowed());
1053 assert!(matches!(
1054 config.check_static("run_shell", false, None),
1055 Permission::Unknown
1056 ));
1057 }
1058
1059 #[test]
1060 fn test_is_interactive_layer_match() {
1061 let config = LayeredPermissionsConfig {
1062 layers: vec![PermissionLayer {
1063 source: RuleSource::User,
1064 interactive: vec!["run_shell".into()],
1065 ..Default::default()
1066 }],
1067 ..Default::default()
1068 };
1069 assert!(config.is_interactive("run_shell"));
1070 assert!(!config.is_interactive("read_file"));
1071 }
1072
1073 #[test]
1076 fn test_deny_wins_across_layers() {
1077 let config = LayeredPermissionsConfig {
1078 layers: vec![
1079 PermissionLayer {
1080 source: RuleSource::Project,
1081 deny: vec!["run_shell".into()],
1082 ..Default::default()
1083 },
1084 PermissionLayer {
1085 source: RuleSource::User,
1086 allow: vec!["run_shell".into()],
1087 ..Default::default()
1088 },
1089 ],
1090 ..Default::default()
1091 };
1092 assert!(config.check_static("run_shell", false, None).is_denied());
1093 }
1094
1095 #[test]
1096 fn test_allow_union_across_layers() {
1097 let config = LayeredPermissionsConfig {
1099 layers: vec![
1100 PermissionLayer {
1101 source: RuleSource::Project,
1102 allow: vec!["write_file".into()],
1103 ..Default::default()
1104 },
1105 PermissionLayer {
1106 source: RuleSource::User,
1107 allow: vec!["read_file".into()],
1108 ..Default::default()
1109 },
1110 ],
1111 ..Default::default()
1112 };
1113 assert!(config.check_static("read_file", true, None).is_allowed());
1114 assert!(config.check_static("write_file", false, None).is_allowed());
1115 }
1116
1117 #[test]
1118 fn test_interactive_union() {
1119 let config = LayeredPermissionsConfig {
1120 layers: vec![
1121 PermissionLayer {
1122 source: RuleSource::Project,
1123 interactive: vec!["write_file".into()],
1124 ..Default::default()
1125 },
1126 PermissionLayer {
1127 source: RuleSource::User,
1128 interactive: vec!["run_shell".into()],
1129 ..Default::default()
1130 },
1131 ],
1132 ..Default::default()
1133 };
1134 assert!(config.is_interactive("run_shell"));
1135 assert!(config.is_interactive("write_file"));
1136 assert!(!config.is_interactive("read_file"));
1137 }
1138
1139 #[test]
1140 fn test_session_layer_present() {
1141 let config = LayeredPermissionsConfig {
1142 layers: vec![
1143 PermissionLayer {
1144 source: RuleSource::Session,
1145 ..Default::default()
1146 },
1147 PermissionLayer {
1148 source: RuleSource::Project,
1149 allow: vec!["read_file".into()],
1150 ..Default::default()
1151 },
1152 ],
1153 ..Default::default()
1154 };
1155 assert!(config.check_static("read_file", true, None).is_allowed());
1157 }
1158
1159 #[test]
1160 fn test_deny_overrides_allow_same_layer() {
1161 let config = LayeredPermissionsConfig {
1162 layers: vec![PermissionLayer {
1163 source: RuleSource::User,
1164 allow: vec!["run_shell".into()],
1165 deny: vec!["run_shell".into()],
1166 ..Default::default()
1167 }],
1168 ..Default::default()
1169 };
1170 assert!(config.check_static("run_shell", false, None).is_denied());
1171 }
1172
1173 #[test]
1174 fn test_wildcard_matches_prefix() {
1175 let config = LayeredPermissionsConfig {
1176 layers: vec![PermissionLayer {
1177 source: RuleSource::User,
1178 allow: vec!["run_*".into()],
1179 ..Default::default()
1180 }],
1181 ..Default::default()
1182 };
1183 assert!(config.check_static("run_shell", false, None).is_allowed());
1184 assert!(config
1185 .check_static("run_background", false, None)
1186 .is_allowed());
1187 assert!(matches!(
1188 config.check_static("read_file", false, None),
1189 Permission::Unknown
1190 ));
1191 }
1192
1193 #[test]
1194 fn test_wildcard_exact() {
1195 let config = LayeredPermissionsConfig {
1196 layers: vec![PermissionLayer {
1197 source: RuleSource::User,
1198 allow: vec!["*".into()],
1199 ..Default::default()
1200 }],
1201 ..Default::default()
1202 };
1203 assert!(config.check_static("anything", false, None).is_allowed());
1204 assert!(config.check_static("", false, None).is_allowed());
1205 }
1206
1207 #[test]
1208 fn test_deny_with_wildcard() {
1209 let config = LayeredPermissionsConfig {
1210 layers: vec![PermissionLayer {
1211 source: RuleSource::User,
1212 allow: vec!["*".into()],
1213 deny: vec!["run_*".into()],
1214 ..Default::default()
1215 }],
1216 ..Default::default()
1217 };
1218 assert!(config.check_static("read_file", true, None).is_allowed());
1219 assert!(config.check_static("run_shell", false, None).is_denied());
1220 }
1221
1222 #[test]
1223 fn test_matches_pattern_exact() {
1224 assert!(matches_pattern("run_shell", "run_shell"));
1225 assert!(!matches_pattern("run_shell", "run_background"));
1226 }
1227
1228 #[test]
1229 fn test_matches_pattern_wildcard() {
1230 assert!(matches_pattern("run_*", "run_shell"));
1231 assert!(matches_pattern("run_*", "run_background"));
1232 assert!(!matches_pattern("run_*", "read_file"));
1233 }
1234
1235 #[test]
1236 fn test_matches_pattern_star_only() {
1237 assert!(matches_pattern("*", "anything"));
1238 assert!(matches_pattern("*", ""));
1239 }
1240
1241 #[test]
1242 fn test_is_plan_mode_when_mode_is_plan() {
1243 let config = LayeredPermissionsConfig {
1244 mode: PermissionMode::Plan {
1245 pre_plan_mode: Box::new(PermissionMode::Default),
1246 bypass_available: false,
1247 },
1248 ..Default::default()
1249 };
1250 assert!(config.is_plan_mode("anything"));
1251 }
1252
1253 #[test]
1254 fn test_is_plan_mode_false_when_default() {
1255 let config = LayeredPermissionsConfig::default();
1256 assert!(!config.is_plan_mode("anything"));
1257 }
1258
1259 #[test]
1262 fn test_old_config_converts_to_layered() {
1263 let old = OldPermissionsConfig {
1264 allow: vec!["read_file".into()],
1265 deny: vec!["run_shell".into()],
1266 interactive: vec!["write_file".into()],
1267 plan: vec![],
1268 mode: PermissionMode::Default,
1269 };
1270 let layered: LayeredPermissionsConfig = old.into();
1271 assert_eq!(layered.layers.len(), 1);
1272 assert_eq!(layered.layers[0].source, RuleSource::User);
1273 assert_eq!(layered.layers[0].allow, vec!["read_file"]);
1274 assert_eq!(layered.layers[0].deny, vec!["run_shell"]);
1275 assert_eq!(layered.layers[0].interactive, vec!["write_file"]);
1276 }
1277
1278 #[test]
1279 fn test_old_config_empty_allow_produces_no_layer() {
1280 let old = OldPermissionsConfig::default();
1281 let layered: LayeredPermissionsConfig = old.into();
1282 assert_eq!(layered.layers.len(), 0);
1283 }
1284
1285 #[test]
1288 fn test_all_deny_union() {
1289 let config = LayeredPermissionsConfig {
1290 layers: vec![
1291 PermissionLayer {
1292 source: RuleSource::Project,
1293 deny: vec!["write_file".into()],
1294 ..Default::default()
1295 },
1296 PermissionLayer {
1297 source: RuleSource::User,
1298 deny: vec!["run_shell".into()],
1299 ..Default::default()
1300 },
1301 ],
1302 ..Default::default()
1303 };
1304 let denies: Vec<&str> = config.all_deny().collect();
1305 assert!(denies.contains(&"write_file"));
1306 assert!(denies.contains(&"run_shell"));
1307 assert_eq!(denies.len(), 2);
1308 }
1309
1310 #[test]
1311 fn test_all_allow_union() {
1312 let config = LayeredPermissionsConfig {
1313 layers: vec![
1314 PermissionLayer {
1315 source: RuleSource::Project,
1316 allow: vec!["read_file".into()],
1317 ..Default::default()
1318 },
1319 PermissionLayer {
1320 source: RuleSource::User,
1321 allow: vec!["write_file".into()],
1322 ..Default::default()
1323 },
1324 ],
1325 ..Default::default()
1326 };
1327 let allows: Vec<&str> = config.all_allow().collect();
1328 assert!(allows.contains(&"read_file"));
1329 assert!(allows.contains(&"write_file"));
1330 assert_eq!(allows.len(), 2);
1331 }
1332
1333 #[test]
1334 fn test_all_interactive_union() {
1335 let config = LayeredPermissionsConfig {
1336 layers: vec![
1337 PermissionLayer {
1338 source: RuleSource::Project,
1339 interactive: vec!["run_shell".into()],
1340 ..Default::default()
1341 },
1342 PermissionLayer {
1343 source: RuleSource::User,
1344 interactive: vec!["write_file".into()],
1345 ..Default::default()
1346 },
1347 ],
1348 ..Default::default()
1349 };
1350 let interactives: Vec<&str> = config.all_interactive().collect();
1351 assert!(interactives.contains(&"run_shell"));
1352 assert!(interactives.contains(&"write_file"));
1353 assert_eq!(interactives.len(), 2);
1354 }
1355
1356 #[test]
1359 fn permission_is_allowed_helper() {
1360 let reason = DecisionReason::Mode(PermissionMode::Default);
1361 assert!(Permission::Allowed(reason.clone()).is_allowed());
1362 assert!(!Permission::Allowed(reason).is_denied());
1363 }
1364
1365 #[test]
1366 fn permission_is_denied_helper() {
1367 let reason = DecisionReason::Mode(PermissionMode::DontAsk);
1368 assert!(Permission::Denied(reason.clone(), "blocked".into()).is_denied());
1369 assert!(!Permission::Denied(reason, "blocked".into()).is_allowed());
1370 }
1371
1372 #[test]
1373 fn passthrough_is_neither() {
1374 assert!(!Permission::Unknown.is_allowed());
1375 assert!(!Permission::Unknown.is_denied());
1376 }
1377
1378 #[test]
1379 fn decision_reason_rule_debug() {
1380 let reason = DecisionReason::Rule {
1381 source: RuleSource::User,
1382 pattern: "run_shell".into(),
1383 };
1384 let debug = format!("{:?}", reason);
1385 assert!(debug.contains("Rule"));
1386 assert!(debug.contains("User"));
1387 assert!(debug.contains("run_shell"));
1388 }
1389
1390 #[test]
1391 fn decision_reason_mode_debug() {
1392 let reason = DecisionReason::Mode(PermissionMode::DontAsk);
1393 let debug = format!("{:?}", reason);
1394 assert!(debug.contains("DontAsk"));
1395 }
1396
1397 #[test]
1398 fn decision_reason_hook_debug() {
1399 let reason = DecisionReason::Hook {
1400 name: "my_hook".into(),
1401 };
1402 let debug = format!("{:?}", reason);
1403 assert!(debug.contains("Hook"));
1404 assert!(debug.contains("my_hook"));
1405 }
1406
1407 #[test]
1408 fn decision_reason_safety_check_debug() {
1409 let reason = DecisionReason::SafetyCheck {
1410 path: "/etc/passwd".into(),
1411 };
1412 let debug = format!("{:?}", reason);
1413 assert!(debug.contains("SafetyCheck"));
1414 assert!(debug.contains("/etc/passwd"));
1415 }
1416
1417 #[test]
1418 fn check_static_deny_returns_rule_reason() {
1419 let config = LayeredPermissionsConfig {
1420 layers: vec![PermissionLayer {
1421 source: RuleSource::User,
1422 deny: vec!["run_shell".into()],
1423 ..Default::default()
1424 }],
1425 ..Default::default()
1426 };
1427 let result = config.check_static("run_shell", false, None);
1428 assert!(result.is_denied());
1429 if let Permission::Denied(reason, msg) = result {
1430 assert!(matches!(
1431 reason,
1432 DecisionReason::Rule {
1433 source: RuleSource::User,
1434 ..
1435 }
1436 ));
1437 assert!(msg.contains("run_shell"));
1438 } else {
1439 panic!("expected Denied");
1440 }
1441 }
1442
1443 #[test]
1446 fn protected_path_denied_in_default_mode() {
1447 let config = LayeredPermissionsConfig::default();
1448 let result = config.check_static("write_file", false, Some(".git/config"));
1449 assert!(result.is_denied());
1450 if let Permission::Denied(DecisionReason::SafetyCheck { path }, msg) = &result {
1451 assert_eq!(path, ".git/config");
1452 assert!(msg.contains("protected"));
1453 } else {
1454 panic!("expected Denied(SafetyCheck), got {result:?}");
1455 }
1456 }
1457
1458 #[test]
1459 fn protected_path_denied_in_bypass_mode() {
1460 let config = LayeredPermissionsConfig {
1461 mode: PermissionMode::BypassPermissions,
1462 ..Default::default()
1463 };
1464 let result = config.check_static("write_file", false, Some(".ssh/id_rsa"));
1465 assert!(result.is_denied());
1466 if let Permission::Denied(DecisionReason::SafetyCheck { path }, msg) = &result {
1467 assert_eq!(path, ".ssh/id_rsa");
1468 assert!(msg.contains("protected"));
1469 } else {
1470 panic!("expected Denied(SafetyCheck), got {result:?}");
1471 }
1472 }
1473
1474 #[test]
1475 fn protected_path_readonly_allowed() {
1476 let config = LayeredPermissionsConfig::default();
1477 let result = config.check_static("read_file", true, Some(".git/config"));
1479 assert!(matches!(result, Permission::Unknown));
1480 }
1481
1482 #[test]
1483 fn non_protected_path_not_blocked() {
1484 let config = LayeredPermissionsConfig::default();
1485 let result = config.check_static("write_file", false, Some("src/main.rs"));
1486 assert!(matches!(result, Permission::Unknown));
1487 }
1488
1489 #[test]
1490 fn nested_protected_path_detected() {
1491 let config = LayeredPermissionsConfig::default();
1492 let result =
1493 config.check_static("write_file", false, Some("some/dir/.recursive/config.toml"));
1494 assert!(result.is_denied());
1495 if let Permission::Denied(DecisionReason::SafetyCheck { path }, _) = &result {
1496 assert_eq!(path, "some/dir/.recursive/config.toml");
1497 } else {
1498 panic!("expected Denied(SafetyCheck), got {result:?}");
1499 }
1500 }
1501
1502 #[test]
1503 fn path_contains_protected_fn() {
1504 assert!(path_contains_protected(".git/config", ".git"));
1506 assert!(path_contains_protected("a/.git/config", ".git"));
1507 assert!(path_contains_protected(
1509 "some/dir/.recursive/config.toml",
1510 ".recursive"
1511 ));
1512 assert!(!path_contains_protected("legitimate.git_info/x", ".git"));
1514 assert!(path_contains_protected(".env", ".env"));
1516 assert!(!path_contains_protected(".env.example", ".env"));
1517 assert!(!path_contains_protected("", ".git"));
1519 assert!(path_contains_protected(".ssh/authorized_keys", ".ssh"));
1521 }
1522
1523 #[test]
1526 fn add_session_allow_rule() {
1527 let mut config = LayeredPermissionsConfig::default();
1528 config.add_session_rule(RuleBehavior::Allow, "run_shell".into());
1529 let session = config.session_rules();
1530 assert_eq!(session.source, RuleSource::Session);
1531 assert!(session.allow.contains(&"run_shell".to_string()));
1532 assert!(session.deny.is_empty());
1533 assert!(session.interactive.is_empty());
1534 }
1535
1536 #[test]
1537 fn add_session_deny_rule() {
1538 let mut config = LayeredPermissionsConfig::default();
1539 config.add_session_rule(RuleBehavior::Deny, "run_shell".into());
1540 let session = config.session_rules();
1541 assert!(session.deny.contains(&"run_shell".to_string()));
1542 }
1543
1544 #[test]
1545 fn add_session_interactive_rule() {
1546 let mut config = LayeredPermissionsConfig::default();
1547 config.add_session_rule(RuleBehavior::Interactive, "run_shell".into());
1548 let session = config.session_rules();
1549 assert!(session.interactive.contains(&"run_shell".to_string()));
1550 }
1551
1552 #[test]
1553 fn remove_session_rule() {
1554 let mut config = LayeredPermissionsConfig::default();
1555 config.add_session_rule(RuleBehavior::Allow, "run_shell".into());
1556 config.add_session_rule(RuleBehavior::Allow, "write_file".into());
1557 assert_eq!(config.session_rules().allow.len(), 2);
1558
1559 config.remove_session_rule(RuleBehavior::Allow, "run_shell");
1560 assert_eq!(config.session_rules().allow.len(), 1);
1561 assert!(config
1562 .session_rules()
1563 .allow
1564 .contains(&"write_file".to_string()));
1565 }
1566
1567 #[test]
1568 fn session_layer_created_on_first_use() {
1569 let mut config = LayeredPermissionsConfig::default();
1570 assert!(config.layers.is_empty());
1572 config.add_session_rule(RuleBehavior::Allow, "read_file".into());
1574 assert_eq!(config.layers.len(), 1);
1575 assert_eq!(config.layers[0].source, RuleSource::Session);
1576 }
1577
1578 #[test]
1579 fn session_deny_takes_precedence_over_user_allow() {
1580 let mut config = LayeredPermissionsConfig {
1581 mode: PermissionMode::Default,
1582 layers: vec![PermissionLayer {
1583 source: RuleSource::User,
1584 allow: vec!["run_shell".into()],
1585 ..Default::default()
1586 }],
1587 };
1588 config.add_session_rule(RuleBehavior::Deny, "run_shell".into());
1590 let result = config.check_static("run_shell", false, None);
1591 assert!(result.is_denied());
1592 }
1593
1594 #[test]
1595 fn shared_permissions_arc_clone_sees_mutation() {
1596 let config = LayeredPermissionsConfig::default();
1597 let shared = Arc::new(RwLock::new(config));
1598 let clone = Arc::clone(&shared);
1599
1600 {
1602 let mut guard = shared.try_write().unwrap();
1603 guard.add_session_rule(RuleBehavior::Allow, "run_shell".into());
1604 }
1605
1606 let guard = clone.try_read().unwrap();
1608 assert!(guard
1609 .session_rules()
1610 .allow
1611 .contains(&"run_shell".to_string()));
1612 }
1613}