1pub mod auto_classifier;
18
19use serde::{Deserialize, Serialize};
20use std::sync::Arc;
21use tokio::sync::RwLock;
22
23#[derive(Debug, Clone, PartialEq, Eq)]
29#[non_exhaustive]
30pub enum DecisionReason {
31 Rule { source: RuleSource, pattern: String },
33 Mode(PermissionMode),
35 Hook { name: String },
37 SafetyCheck { path: String },
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum Permission {
44 Allowed(DecisionReason),
46 Denied(DecisionReason, String),
48 Unknown,
54}
55
56impl Permission {
57 pub fn is_allowed(&self) -> bool {
59 matches!(self, Permission::Allowed(_))
60 }
61
62 pub fn is_denied(&self) -> bool {
64 matches!(self, Permission::Denied(_, _))
65 }
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Default)]
91#[serde(rename_all = "camelCase")]
92pub enum PermissionMode {
93 #[serde(alias = "allow")]
96 #[default]
97 Default,
98 AcceptEdits,
100 BypassPermissions,
102 #[serde(alias = "deny")]
104 #[serde(alias = "interactive")]
105 DontAsk,
106 Plan {
111 pre_plan_mode: Box<PermissionMode>,
113 bypass_available: bool,
116 },
117
118 Auto,
125
126 Strict,
133}
134
135impl<'de> Deserialize<'de> for PermissionMode {
138 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
139 where
140 D: serde::Deserializer<'de>,
141 {
142 use serde::de::{self, MapAccess, Visitor};
143 use std::fmt;
144
145 struct PermissionModeVisitor;
146
147 impl<'de> Visitor<'de> for PermissionModeVisitor {
148 type Value = PermissionMode;
149
150 fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
151 formatter.write_str(
152 "a permission mode string (\"default\", \"acceptEdits\", \
153 \"bypassPermissions\", \"dontAsk\", \"plan\", \"auto\", \
154 or legacy \"allow\"/\"deny\"/\"interactive\") \
155 or a Plan object {\"prePlanMode\": \"...\", \"bypassAvailable\": bool}",
156 )
157 }
158
159 fn visit_str<E: de::Error>(self, s: &str) -> Result<PermissionMode, E> {
160 match s {
161 "default" | "allow" => Ok(PermissionMode::Default),
162 "acceptEdits" | "accept_edits" => Ok(PermissionMode::AcceptEdits),
163 "bypassPermissions" | "bypass_permissions" => {
164 Ok(PermissionMode::BypassPermissions)
165 }
166 "dontAsk" | "dont_ask" | "deny" | "interactive" => Ok(PermissionMode::DontAsk),
167 "auto" => Ok(PermissionMode::Auto),
168 "strict" => Ok(PermissionMode::Strict),
169 "plan" => Ok(PermissionMode::Plan {
170 pre_plan_mode: Box::new(PermissionMode::Default),
171 bypass_available: false,
172 }),
173 _ => Err(de::Error::unknown_variant(
174 s,
175 &[
176 "default",
177 "acceptEdits",
178 "bypassPermissions",
179 "auto",
180 "dontAsk",
181 "plan",
182 "strict",
183 ],
184 )),
185 }
186 }
187
188 fn visit_map<M: MapAccess<'de>>(self, mut map: M) -> Result<PermissionMode, M::Error> {
189 let mut pre_plan_mode: Option<PermissionMode> = None;
192 let mut bypass_available: Option<bool> = None;
193
194 while let Some(key) = map.next_key::<String>()? {
195 match key.as_str() {
196 "prePlanMode" | "pre_plan_mode" => {
197 if pre_plan_mode.is_some() {
198 return Err(de::Error::duplicate_field("pre_plan_mode"));
199 }
200 pre_plan_mode = Some(map.next_value()?);
201 }
202 "bypassAvailable" | "bypass_available" => {
203 if bypass_available.is_some() {
204 return Err(de::Error::duplicate_field("bypass_available"));
205 }
206 bypass_available = Some(map.next_value()?);
207 }
208 other => {
209 return Err(de::Error::unknown_field(
210 other,
211 &[
212 "pre_plan_mode",
213 "prePlanMode",
214 "bypass_available",
215 "bypassAvailable",
216 ],
217 ));
218 }
219 }
220 }
221
222 let pre_plan_mode =
223 pre_plan_mode.ok_or_else(|| de::Error::missing_field("pre_plan_mode"))?;
224 let bypass_available = bypass_available.unwrap_or(false);
225 Ok(PermissionMode::Plan {
226 pre_plan_mode: Box::new(pre_plan_mode),
227 bypass_available,
228 })
229 }
230 }
231
232 deserializer.deserialize_any(PermissionModeVisitor)
233 }
234}
235
236#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize)]
242#[non_exhaustive]
243pub enum RuleSource {
244 Session,
246 Project,
248 #[default]
250 User,
251}
252
253#[derive(Debug, Clone, Default, Deserialize)]
255pub struct PermissionLayer {
256 pub source: RuleSource,
258 pub allow: Vec<String>,
260 pub deny: Vec<String>,
262 pub interactive: Vec<String>,
264}
265
266#[derive(Debug, Clone, Default, Deserialize)]
277pub struct LayeredPermissionsConfig {
278 #[serde(default)]
280 pub mode: PermissionMode,
281 #[serde(default)]
283 pub layers: Vec<PermissionLayer>,
284}
285
286impl LayeredPermissionsConfig {
287 pub fn check_static(
312 &self,
313 tool_name: &str,
314 is_readonly: bool,
315 content: Option<&str>,
316 ) -> Permission {
317 if !is_readonly {
320 if let Some(path) = extract_file_path_from_content(tool_name, content) {
321 for protected in PROTECTED_PATHS {
322 if path_contains_protected(&path, protected) {
323 return Permission::Denied(
324 DecisionReason::SafetyCheck { path: path.clone() },
325 format!(
326 "writing to `{path}` is protected and requires explicit confirmation"
327 ),
328 );
329 }
330 }
331 }
332 }
333
334 if let PermissionMode::Plan {
336 bypass_available, ..
337 } = &self.mode
338 {
339 if !is_readonly && tool_name != "exit_plan_mode" && !bypass_available {
340 return Permission::Denied(
341 DecisionReason::Mode(self.mode.clone()),
342 "write tools are blocked in plan mode".to_string(),
343 );
344 }
345 }
347
348 if matches!(self.mode, PermissionMode::BypassPermissions) {
350 return Permission::Allowed(DecisionReason::Mode(self.mode.clone()));
351 }
352
353 if matches!(self.mode, PermissionMode::DontAsk) && self.any_interactive(tool_name) {
355 return Permission::Denied(
356 DecisionReason::Mode(self.mode.clone()),
357 format!("tool `{tool_name}` requires interaction but mode is dontAsk"),
358 );
359 }
360
361 if matches!(self.mode, PermissionMode::AcceptEdits) && !is_readonly {
363 return Permission::Allowed(DecisionReason::Mode(self.mode.clone()));
364 }
365
366 for (pattern, source) in self.all_deny() {
368 if matches_pattern(pattern, tool_name) {
369 return Permission::Denied(
370 DecisionReason::Rule {
371 source: *source,
372 pattern: pattern.to_string(),
373 },
374 format!("tool `{tool_name}` matches deny pattern `{pattern}`"),
375 );
376 }
377 }
378 for (pattern, source) in self.all_allow() {
379 if matches_pattern(pattern, tool_name) {
380 return Permission::Allowed(DecisionReason::Rule {
381 source: *source,
382 pattern: pattern.to_string(),
383 });
384 }
385 }
386
387 if matches!(self.mode, PermissionMode::Strict) {
389 return Permission::Denied(
390 DecisionReason::Mode(PermissionMode::Strict),
391 format!("tool `{tool_name}` has no explicit allow rule and mode is strict"),
392 );
393 }
394
395 Permission::Unknown
397 }
398
399 pub fn is_plan_mode(&self, tool_name: &str) -> bool {
404 let _ = tool_name; matches!(self.mode, PermissionMode::Plan { .. })
408 }
409
410 pub fn is_interactive(&self, tool_name: &str) -> bool {
415 if matches!(
417 self.check_static(tool_name, false, None),
418 Permission::Denied(_, _)
419 ) {
420 return false;
421 }
422 self.any_interactive(tool_name)
423 }
424
425 pub fn any_interactive(&self, tool_name: &str) -> bool {
427 self.all_interactive()
428 .any(|p| matches_pattern(p, tool_name))
429 }
430
431 pub fn all_deny(&self) -> impl Iterator<Item = (&str, &RuleSource)> {
433 self.layers
434 .iter()
435 .flat_map(|l| l.deny.iter().map(move |s| (s.as_str(), &l.source)))
436 }
437
438 pub fn all_allow(&self) -> impl Iterator<Item = (&str, &RuleSource)> {
440 self.layers
441 .iter()
442 .flat_map(|l| l.allow.iter().map(move |s| (s.as_str(), &l.source)))
443 }
444
445 pub fn all_interactive(&self) -> impl Iterator<Item = &str> {
447 self.layers
448 .iter()
449 .flat_map(|l| l.interactive.iter())
450 .map(|s| s.as_str())
451 }
452
453 pub fn add_session_rule(&mut self, behavior: RuleBehavior, pattern: String) {
465 let layer = self.session_layer_mut();
466 match behavior {
467 RuleBehavior::Allow => layer.allow.push(pattern),
468 RuleBehavior::Deny => layer.deny.push(pattern),
469 RuleBehavior::Interactive => layer.interactive.push(pattern),
470 }
471 }
472
473 pub fn remove_session_rule(&mut self, behavior: RuleBehavior, pattern: &str) {
479 let layer = self.session_layer_mut();
480 let list = match behavior {
481 RuleBehavior::Allow => &mut layer.allow,
482 RuleBehavior::Deny => &mut layer.deny,
483 RuleBehavior::Interactive => &mut layer.interactive,
484 };
485 list.retain(|r| !r.contains(pattern));
486 }
487
488 pub fn session_rules(&self) -> &PermissionLayer {
493 #[allow(
494 clippy::expect_used,
495 reason = "session_layer_mut() must be called first to ensure existence"
496 )]
497 self.layers
498 .iter()
499 .find(|l| l.source == RuleSource::Session)
500 .expect("session layer always present after first mutation")
501 }
502
503 fn session_layer_mut(&mut self) -> &mut PermissionLayer {
507 if !self.layers.iter().any(|l| l.source == RuleSource::Session) {
508 self.layers.insert(
509 0,
510 PermissionLayer {
511 source: RuleSource::Session,
512 ..Default::default()
513 },
514 );
515 }
516 #[allow(clippy::expect_used, reason = "session layer was just inserted above")]
517 self.layers
518 .iter_mut()
519 .find(|l| l.source == RuleSource::Session)
520 .expect("session layer just created")
521 }
522}
523
524#[derive(Debug, Clone, Deserialize)]
531#[serde(default)]
532#[derive(Default)]
533pub struct OldPermissionsConfig {
534 #[serde(default)]
536 pub allow: Vec<String>,
537 #[serde(default)]
539 pub deny: Vec<String>,
540 #[serde(default)]
542 pub interactive: Vec<String>,
543 #[serde(default)]
545 pub plan: Vec<String>,
546 #[serde(default)]
551 pub mode: PermissionMode,
552}
553
554impl From<OldPermissionsConfig> for LayeredPermissionsConfig {
555 fn from(old: OldPermissionsConfig) -> Self {
556 let mut layers = Vec::new();
557 if !old.allow.is_empty() || !old.deny.is_empty() || !old.interactive.is_empty() {
558 layers.push(PermissionLayer {
559 source: RuleSource::User,
560 allow: old.allow,
561 deny: old.deny,
562 interactive: old.interactive,
563 });
564 }
565 LayeredPermissionsConfig {
566 mode: old.mode,
567 layers,
568 }
569 }
570}
571
572pub type PermissionsConfig = LayeredPermissionsConfig;
577
578pub type SharedPermissions = Arc<RwLock<LayeredPermissionsConfig>>;
586
587#[derive(Debug, Clone, PartialEq, Eq)]
589#[non_exhaustive]
590pub enum RuleBehavior {
591 Allow,
593 Deny,
595 Interactive,
597}
598
599const PROTECTED_PATHS: &[&str] = &[
614 ".git",
615 ".recursive",
616 ".ssh",
617 ".gnupg",
618 ".bashrc",
619 ".zshrc",
620 ".profile",
621 ".bash_profile",
622 ".bash_logout",
623 ".env",
624];
625
626fn extract_file_path_from_content(tool_name: &str, content: Option<&str>) -> Option<String> {
634 let content = content?;
635 match tool_name {
636 "Write" | "Read" => Some(content.to_string()),
637 "Edit" => Some(content.to_string()),
638 _ => None,
639 }
640}
641
642fn path_contains_protected(path: &str, protected: &str) -> bool {
648 std::path::Path::new(path)
649 .components()
650 .any(|c| c.as_os_str().to_string_lossy().as_ref() == protected)
651}
652
653pub fn check_protected_path(
664 tool_name: &str,
665 is_readonly: bool,
666 content: Option<&str>,
667) -> Option<String> {
668 if is_readonly {
669 return None;
670 }
671 let path = extract_file_path_from_content(tool_name, content)?;
672 for protected in PROTECTED_PATHS {
673 if path_contains_protected(&path, protected) {
674 return Some(path);
675 }
676 }
677 None
678}
679
680fn matches_pattern(pattern: &str, name: &str) -> bool {
686 if let Some(prefix) = pattern.strip_suffix('*') {
687 if prefix.is_empty() {
688 return true;
690 }
691 name.starts_with(prefix)
692 } else {
693 name == pattern
694 }
695}
696
697#[cfg(test)]
698mod tests {
699 use super::*;
700
701 #[test]
704 fn test_permission_mode_default_is_default() {
705 assert_eq!(PermissionMode::default(), PermissionMode::Default);
706 }
707
708 #[test]
709 fn test_permission_mode_deserialize_default() {
710 let mode: PermissionMode = serde_json::from_str("\"default\"").unwrap();
711 assert_eq!(mode, PermissionMode::Default);
712 }
713
714 #[test]
715 fn test_permission_mode_deserialize_accept_edits() {
716 let mode: PermissionMode = serde_json::from_str("\"acceptEdits\"").unwrap();
717 assert_eq!(mode, PermissionMode::AcceptEdits);
718 }
719
720 #[test]
721 fn test_permission_mode_deserialize_bypass() {
722 let mode: PermissionMode = serde_json::from_str("\"bypassPermissions\"").unwrap();
723 assert_eq!(mode, PermissionMode::BypassPermissions);
724 }
725
726 #[test]
727 fn test_permission_mode_deserialize_dont_ask() {
728 let mode: PermissionMode = serde_json::from_str("\"dontAsk\"").unwrap();
729 assert_eq!(mode, PermissionMode::DontAsk);
730 }
731
732 #[test]
733 fn test_permission_mode_deserialize_plan_string() {
734 let mode: PermissionMode = serde_json::from_str("\"plan\"").unwrap();
735 assert_eq!(
736 mode,
737 PermissionMode::Plan {
738 pre_plan_mode: Box::new(PermissionMode::Default),
739 bypass_available: false,
740 }
741 );
742 }
743
744 #[test]
745 fn test_permission_mode_deserialize_plan_object() {
746 let json = r#"{"prePlanMode": "acceptEdits", "bypassAvailable": true}"#;
747 let mode: PermissionMode = serde_json::from_str(json).unwrap();
748 assert_eq!(
749 mode,
750 PermissionMode::Plan {
751 pre_plan_mode: Box::new(PermissionMode::AcceptEdits),
752 bypass_available: true,
753 }
754 );
755 }
756
757 #[test]
758 fn test_permission_mode_deserialize_plan_object_default_bypass() {
759 let json = r#"{"prePlanMode": "default"}"#;
760 let mode: PermissionMode = serde_json::from_str(json).unwrap();
761 assert_eq!(
762 mode,
763 PermissionMode::Plan {
764 pre_plan_mode: Box::new(PermissionMode::Default),
765 bypass_available: false,
766 }
767 );
768 }
769
770 #[test]
773 fn test_permission_mode_old_allow_is_default() {
774 let mode: PermissionMode = serde_json::from_str("\"allow\"").unwrap();
775 assert_eq!(mode, PermissionMode::Default);
776 }
777
778 #[test]
779 fn test_permission_mode_old_deny_is_dont_ask() {
780 let mode: PermissionMode = serde_json::from_str("\"deny\"").unwrap();
781 assert_eq!(mode, PermissionMode::DontAsk);
782 }
783
784 #[test]
785 fn test_permission_mode_old_interactive_is_dont_ask() {
786 let mode: PermissionMode = serde_json::from_str("\"interactive\"").unwrap();
787 assert_eq!(mode, PermissionMode::DontAsk);
788 }
789
790 #[test]
791 fn test_permission_mode_snake_case_accept_edits() {
792 let mode: PermissionMode = serde_json::from_str("\"accept_edits\"").unwrap();
793 assert_eq!(mode, PermissionMode::AcceptEdits);
794 }
795
796 #[test]
797 fn test_permission_mode_snake_case_bypass() {
798 let mode: PermissionMode = serde_json::from_str("\"bypass_permissions\"").unwrap();
799 assert_eq!(mode, PermissionMode::BypassPermissions);
800 }
801
802 #[test]
803 fn test_permission_mode_snake_case_dont_ask() {
804 let mode: PermissionMode = serde_json::from_str("\"dont_ask\"").unwrap();
805 assert_eq!(mode, PermissionMode::DontAsk);
806 }
807
808 #[test]
811 fn test_mode_serialize_default() {
812 let json = serde_json::to_string(&PermissionMode::Default).unwrap();
813 assert_eq!(json, "\"default\"");
814 }
815
816 #[test]
817 fn test_mode_serialize_accept_edits() {
818 let json = serde_json::to_string(&PermissionMode::AcceptEdits).unwrap();
819 assert_eq!(json, "\"acceptEdits\"");
820 }
821
822 #[test]
823 fn test_mode_serialize_bypass() {
824 let json = serde_json::to_string(&PermissionMode::BypassPermissions).unwrap();
825 assert_eq!(json, "\"bypassPermissions\"");
826 }
827
828 #[test]
829 fn test_mode_serialize_dont_ask() {
830 let json = serde_json::to_string(&PermissionMode::DontAsk).unwrap();
831 assert_eq!(json, "\"dontAsk\"");
832 }
833
834 #[test]
835 fn test_mode_serialize_plan() {
836 let mode = PermissionMode::Plan {
837 pre_plan_mode: Box::new(PermissionMode::AcceptEdits),
838 bypass_available: true,
839 };
840 let json = serde_json::to_string(&mode).unwrap();
841 assert!(json.contains("\"pre_plan_mode\":\"acceptEdits\""));
842 assert!(json.contains("\"bypass_available\":true"));
843 }
844
845 #[test]
849 fn test_plan_mode_blocks_write() {
850 let config = LayeredPermissionsConfig {
851 mode: PermissionMode::Plan {
852 pre_plan_mode: Box::new(PermissionMode::Default),
853 bypass_available: false,
854 },
855 ..Default::default()
856 };
857 let result = config.check_static("Write", false, None);
858 assert!(result.is_denied());
859 if let Permission::Denied(reason, msg) = result {
860 assert!(matches!(
861 reason,
862 DecisionReason::Mode(PermissionMode::Plan { .. })
863 ));
864 assert!(msg.contains("plan mode"));
865 } else {
866 panic!("expected Denied");
867 }
868 }
869
870 #[test]
872 fn test_plan_mode_allows_exit() {
873 let config = LayeredPermissionsConfig {
874 mode: PermissionMode::Plan {
875 pre_plan_mode: Box::new(PermissionMode::Default),
876 bypass_available: false,
877 },
878 ..Default::default()
879 };
880 let result = config.check_static("exit_plan_mode", false, None);
882 assert!(!result.is_denied());
883 assert!(matches!(result, Permission::Unknown));
885 }
886
887 #[test]
890 fn test_plan_mode_bypass_write_continues() {
891 let config = LayeredPermissionsConfig {
892 mode: PermissionMode::Plan {
893 pre_plan_mode: Box::new(PermissionMode::BypassPermissions),
894 bypass_available: true,
895 },
896 ..Default::default()
897 };
898 let result = config.check_static("Write", false, None);
901 assert!(!result.is_denied());
902 assert!(matches!(result, Permission::Unknown));
903 }
904
905 #[test]
907 fn test_bypass_skips_deny_rules() {
908 let config = LayeredPermissionsConfig {
909 mode: PermissionMode::BypassPermissions,
910 layers: vec![PermissionLayer {
911 source: RuleSource::User,
912 deny: vec!["Bash".into()],
913 ..Default::default()
914 }],
915 };
916 let result = config.check_static("Bash", false, None);
917 assert!(result.is_allowed());
918 if let Permission::Allowed(reason) = result {
919 assert!(matches!(
920 reason,
921 DecisionReason::Mode(PermissionMode::BypassPermissions)
922 ));
923 } else {
924 panic!("expected Allowed");
925 }
926 }
927
928 #[test]
930 fn test_dontask_converts_interactive() {
931 let config = LayeredPermissionsConfig {
932 mode: PermissionMode::DontAsk,
933 layers: vec![PermissionLayer {
934 source: RuleSource::User,
935 interactive: vec!["Bash".into()],
936 ..Default::default()
937 }],
938 };
939 let result = config.check_static("Bash", false, None);
940 assert!(result.is_denied());
941 if let Permission::Denied(reason, msg) = result {
942 assert!(matches!(
943 reason,
944 DecisionReason::Mode(PermissionMode::DontAsk)
945 ));
946 assert!(msg.contains("dontAsk"));
947 } else {
948 panic!("expected Denied");
949 }
950 }
951
952 #[test]
954 fn test_accept_edits_allows_write() {
955 let config = LayeredPermissionsConfig {
956 mode: PermissionMode::AcceptEdits,
957 ..Default::default()
958 };
959 let result = config.check_static("Write", false, None);
960 assert!(result.is_allowed());
961 if let Permission::Allowed(reason) = result {
962 assert!(matches!(
963 reason,
964 DecisionReason::Mode(PermissionMode::AcceptEdits)
965 ));
966 } else {
967 panic!("expected Allowed");
968 }
969 }
970
971 #[test]
973 fn test_deny_rule_takes_effect() {
974 let config = LayeredPermissionsConfig {
975 mode: PermissionMode::Default,
976 layers: vec![PermissionLayer {
977 source: RuleSource::User,
978 deny: vec!["Bash".into()],
979 ..Default::default()
980 }],
981 };
982 let result = config.check_static("Bash", true, None);
983 assert!(result.is_denied());
984 if let Permission::Denied(reason, _msg) = result {
985 assert!(matches!(
986 reason,
987 DecisionReason::Rule {
988 source: RuleSource::User,
989 ..
990 }
991 ));
992 } else {
993 panic!("expected Denied");
994 }
995 }
996
997 #[test]
999 fn test_allow_rule_takes_effect() {
1000 let config = LayeredPermissionsConfig {
1001 mode: PermissionMode::Default,
1002 layers: vec![PermissionLayer {
1003 source: RuleSource::User,
1004 allow: vec!["Read".into()],
1005 ..Default::default()
1006 }],
1007 };
1008 let result = config.check_static("Read", true, None);
1009 assert!(result.is_allowed());
1010 if let Permission::Allowed(reason) = result {
1011 assert!(matches!(
1012 reason,
1013 DecisionReason::Rule {
1014 source: RuleSource::User,
1015 ..
1016 }
1017 ));
1018 } else {
1019 panic!("expected Allowed");
1020 }
1021 }
1022
1023 #[test]
1026 fn test_plan_mode_allows_readonly() {
1027 let config = LayeredPermissionsConfig {
1028 mode: PermissionMode::Plan {
1029 pre_plan_mode: Box::new(PermissionMode::Default),
1030 bypass_available: false,
1031 },
1032 ..Default::default()
1033 };
1034 let result = config.check_static("Read", true, None);
1036 assert!(!result.is_denied());
1037 }
1038
1039 #[test]
1042 fn test_any_interactive_detects_match() {
1043 let config = LayeredPermissionsConfig {
1044 layers: vec![PermissionLayer {
1045 source: RuleSource::User,
1046 interactive: vec!["Ba*".into()],
1047 ..Default::default()
1048 }],
1049 ..Default::default()
1050 };
1051 assert!(config.any_interactive("Bash"));
1052 assert!(!config.any_interactive("Read"));
1053 }
1054
1055 #[test]
1058 fn test_empty_config_passthrough() {
1059 let config = LayeredPermissionsConfig::default();
1060 let result = config.check_static("anything", false, None);
1062 assert!(matches!(result, Permission::Unknown));
1063 }
1064
1065 #[test]
1066 fn test_single_layer_deny() {
1067 let config = LayeredPermissionsConfig {
1068 layers: vec![PermissionLayer {
1069 source: RuleSource::User,
1070 deny: vec!["Bash".into()],
1071 ..Default::default()
1072 }],
1073 ..Default::default()
1074 };
1075 assert!(config.check_static("Bash", false, None).is_denied());
1076 assert!(!config.check_static("Read", false, None).is_denied());
1077 }
1078
1079 #[test]
1080 fn test_single_layer_allow() {
1081 let config = LayeredPermissionsConfig {
1082 layers: vec![PermissionLayer {
1083 source: RuleSource::User,
1084 allow: vec!["Read".into()],
1085 ..Default::default()
1086 }],
1087 ..Default::default()
1088 };
1089 assert!(config.check_static("Read", false, None).is_allowed());
1090 assert!(matches!(
1091 config.check_static("Bash", false, None),
1092 Permission::Unknown
1093 ));
1094 }
1095
1096 #[test]
1097 fn test_is_interactive_layer_match() {
1098 let config = LayeredPermissionsConfig {
1099 layers: vec![PermissionLayer {
1100 source: RuleSource::User,
1101 interactive: vec!["Bash".into()],
1102 ..Default::default()
1103 }],
1104 ..Default::default()
1105 };
1106 assert!(config.is_interactive("Bash"));
1107 assert!(!config.is_interactive("Read"));
1108 }
1109
1110 #[test]
1113 fn test_deny_wins_across_layers() {
1114 let config = LayeredPermissionsConfig {
1115 layers: vec![
1116 PermissionLayer {
1117 source: RuleSource::Project,
1118 deny: vec!["Bash".into()],
1119 ..Default::default()
1120 },
1121 PermissionLayer {
1122 source: RuleSource::User,
1123 allow: vec!["Bash".into()],
1124 ..Default::default()
1125 },
1126 ],
1127 ..Default::default()
1128 };
1129 assert!(config.check_static("Bash", false, None).is_denied());
1130 }
1131
1132 #[test]
1133 fn test_allow_union_across_layers() {
1134 let config = LayeredPermissionsConfig {
1136 layers: vec![
1137 PermissionLayer {
1138 source: RuleSource::Project,
1139 allow: vec!["Write".into()],
1140 ..Default::default()
1141 },
1142 PermissionLayer {
1143 source: RuleSource::User,
1144 allow: vec!["Read".into()],
1145 ..Default::default()
1146 },
1147 ],
1148 ..Default::default()
1149 };
1150 assert!(config.check_static("Read", true, None).is_allowed());
1151 assert!(config.check_static("Write", false, None).is_allowed());
1152 }
1153
1154 #[test]
1155 fn test_interactive_union() {
1156 let config = LayeredPermissionsConfig {
1157 layers: vec![
1158 PermissionLayer {
1159 source: RuleSource::Project,
1160 interactive: vec!["Write".into()],
1161 ..Default::default()
1162 },
1163 PermissionLayer {
1164 source: RuleSource::User,
1165 interactive: vec!["Bash".into()],
1166 ..Default::default()
1167 },
1168 ],
1169 ..Default::default()
1170 };
1171 assert!(config.is_interactive("Bash"));
1172 assert!(config.is_interactive("Write"));
1173 assert!(!config.is_interactive("Read"));
1174 }
1175
1176 #[test]
1177 fn test_session_layer_present() {
1178 let config = LayeredPermissionsConfig {
1179 layers: vec![
1180 PermissionLayer {
1181 source: RuleSource::Session,
1182 ..Default::default()
1183 },
1184 PermissionLayer {
1185 source: RuleSource::Project,
1186 allow: vec!["Read".into()],
1187 ..Default::default()
1188 },
1189 ],
1190 ..Default::default()
1191 };
1192 assert!(config.check_static("Read", true, None).is_allowed());
1194 }
1195
1196 #[test]
1197 fn test_deny_overrides_allow_same_layer() {
1198 let config = LayeredPermissionsConfig {
1199 layers: vec![PermissionLayer {
1200 source: RuleSource::User,
1201 allow: vec!["Bash".into()],
1202 deny: vec!["Bash".into()],
1203 ..Default::default()
1204 }],
1205 ..Default::default()
1206 };
1207 assert!(config.check_static("Bash", false, None).is_denied());
1208 }
1209
1210 #[test]
1211 fn test_wildcard_matches_prefix() {
1212 let config = LayeredPermissionsConfig {
1213 layers: vec![PermissionLayer {
1214 source: RuleSource::User,
1215 allow: vec!["run_*".into()],
1216 ..Default::default()
1217 }],
1218 ..Default::default()
1219 };
1220 assert!(config
1221 .check_static("run_background", false, None)
1222 .is_allowed());
1223 assert!(matches!(
1224 config.check_static("Read", false, None),
1225 Permission::Unknown
1226 ));
1227 assert!(matches!(
1228 config.check_static("Bash", false, None),
1229 Permission::Unknown
1230 ));
1231 }
1232
1233 #[test]
1234 fn test_wildcard_exact() {
1235 let config = LayeredPermissionsConfig {
1236 layers: vec![PermissionLayer {
1237 source: RuleSource::User,
1238 allow: vec!["*".into()],
1239 ..Default::default()
1240 }],
1241 ..Default::default()
1242 };
1243 assert!(config.check_static("anything", false, None).is_allowed());
1244 assert!(config.check_static("", false, None).is_allowed());
1245 }
1246
1247 #[test]
1248 fn test_deny_with_wildcard() {
1249 let config = LayeredPermissionsConfig {
1250 layers: vec![PermissionLayer {
1251 source: RuleSource::User,
1252 allow: vec!["*".into()],
1253 deny: vec!["run_*".into()],
1254 ..Default::default()
1255 }],
1256 ..Default::default()
1257 };
1258 assert!(config.check_static("Read", true, None).is_allowed());
1259 assert!(config
1260 .check_static("run_background", false, None)
1261 .is_denied());
1262 assert!(config.check_static("Bash", false, None).is_allowed());
1264 }
1265
1266 #[test]
1267 fn test_matches_pattern_exact() {
1268 assert!(matches_pattern("Bash", "Bash"));
1269 assert!(!matches_pattern("Bash", "run_background"));
1270 }
1271
1272 #[test]
1273 fn test_matches_pattern_wildcard() {
1274 assert!(matches_pattern("run_*", "run_background"));
1275 assert!(!matches_pattern("run_*", "Read"));
1276 assert!(!matches_pattern("run_*", "Bash"));
1277 assert!(matches_pattern("Ba*", "Bash"));
1278 }
1279
1280 #[test]
1281 fn test_matches_pattern_star_only() {
1282 assert!(matches_pattern("*", "anything"));
1283 assert!(matches_pattern("*", ""));
1284 }
1285
1286 #[test]
1287 fn test_is_plan_mode_when_mode_is_plan() {
1288 let config = LayeredPermissionsConfig {
1289 mode: PermissionMode::Plan {
1290 pre_plan_mode: Box::new(PermissionMode::Default),
1291 bypass_available: false,
1292 },
1293 ..Default::default()
1294 };
1295 assert!(config.is_plan_mode("anything"));
1296 }
1297
1298 #[test]
1299 fn test_is_plan_mode_false_when_default() {
1300 let config = LayeredPermissionsConfig::default();
1301 assert!(!config.is_plan_mode("anything"));
1302 }
1303
1304 #[test]
1307 fn test_old_config_converts_to_layered() {
1308 let old = OldPermissionsConfig {
1309 allow: vec!["Read".into()],
1310 deny: vec!["Bash".into()],
1311 interactive: vec!["Write".into()],
1312 plan: vec![],
1313 mode: PermissionMode::Default,
1314 };
1315 let layered: LayeredPermissionsConfig = old.into();
1316 assert_eq!(layered.layers.len(), 1);
1317 assert_eq!(layered.layers[0].source, RuleSource::User);
1318 assert_eq!(layered.layers[0].allow, vec!["Read"]);
1319 assert_eq!(layered.layers[0].deny, vec!["Bash"]);
1320 assert_eq!(layered.layers[0].interactive, vec!["Write"]);
1321 }
1322
1323 #[test]
1324 fn test_old_config_empty_allow_produces_no_layer() {
1325 let old = OldPermissionsConfig::default();
1326 let layered: LayeredPermissionsConfig = old.into();
1327 assert_eq!(layered.layers.len(), 0);
1328 }
1329
1330 #[test]
1331 fn test_old_config_only_allow_produces_layer() {
1332 let old = OldPermissionsConfig {
1336 allow: vec!["Read".into()],
1337 deny: vec![],
1338 interactive: vec![],
1339 plan: vec![],
1340 mode: PermissionMode::Default,
1341 };
1342 let layered: LayeredPermissionsConfig = old.into();
1343 assert_eq!(
1344 layered.layers.len(),
1345 1,
1346 "non-empty allow alone must create a layer (|| not &&)"
1347 );
1348 assert_eq!(layered.layers[0].allow, vec!["Read"]);
1349 }
1350
1351 #[test]
1352 fn test_old_config_only_deny_produces_layer() {
1353 let old = OldPermissionsConfig {
1355 allow: vec![],
1356 deny: vec!["Bash".into()],
1357 interactive: vec![],
1358 plan: vec![],
1359 mode: PermissionMode::Default,
1360 };
1361 let layered: LayeredPermissionsConfig = old.into();
1362 assert_eq!(
1363 layered.layers.len(),
1364 1,
1365 "non-empty deny alone must create a layer"
1366 );
1367 }
1368
1369 #[test]
1372 fn test_all_deny_union() {
1373 let config = LayeredPermissionsConfig {
1374 layers: vec![
1375 PermissionLayer {
1376 source: RuleSource::Project,
1377 deny: vec!["Write".into()],
1378 ..Default::default()
1379 },
1380 PermissionLayer {
1381 source: RuleSource::User,
1382 deny: vec!["Bash".into()],
1383 ..Default::default()
1384 },
1385 ],
1386 ..Default::default()
1387 };
1388 let denies: Vec<(&str, &RuleSource)> = config.all_deny().collect();
1389 assert!(denies.iter().any(|(p, _)| *p == "Write"));
1390 assert!(denies.iter().any(|(p, _)| *p == "Bash"));
1391 assert!(denies
1393 .iter()
1394 .any(|(p, s)| *p == "Write" && matches!(s, RuleSource::Project)));
1395 assert!(denies
1396 .iter()
1397 .any(|(p, s)| *p == "Bash" && matches!(s, RuleSource::User)));
1398 assert_eq!(denies.len(), 2);
1399 }
1400
1401 #[test]
1402 fn test_all_allow_union() {
1403 let config = LayeredPermissionsConfig {
1404 layers: vec![
1405 PermissionLayer {
1406 source: RuleSource::Project,
1407 allow: vec!["Read".into()],
1408 ..Default::default()
1409 },
1410 PermissionLayer {
1411 source: RuleSource::User,
1412 allow: vec!["Write".into()],
1413 ..Default::default()
1414 },
1415 ],
1416 ..Default::default()
1417 };
1418 let allows: Vec<(&str, &RuleSource)> = config.all_allow().collect();
1419 assert!(allows.iter().any(|(p, _)| *p == "Read"));
1420 assert!(allows.iter().any(|(p, _)| *p == "Write"));
1421 assert!(allows
1423 .iter()
1424 .any(|(p, s)| *p == "Read" && matches!(s, RuleSource::Project)));
1425 assert!(allows
1426 .iter()
1427 .any(|(p, s)| *p == "Write" && matches!(s, RuleSource::User)));
1428 assert_eq!(allows.len(), 2);
1429 }
1430
1431 #[test]
1432 fn test_all_interactive_union() {
1433 let config = LayeredPermissionsConfig {
1434 layers: vec![
1435 PermissionLayer {
1436 source: RuleSource::Project,
1437 interactive: vec!["Bash".into()],
1438 ..Default::default()
1439 },
1440 PermissionLayer {
1441 source: RuleSource::User,
1442 interactive: vec!["Write".into()],
1443 ..Default::default()
1444 },
1445 ],
1446 ..Default::default()
1447 };
1448 let interactives: Vec<&str> = config.all_interactive().collect();
1449 assert!(interactives.contains(&"Bash"));
1450 assert!(interactives.contains(&"Write"));
1451 assert_eq!(interactives.len(), 2);
1452 }
1453
1454 #[test]
1457 fn permission_is_allowed_helper() {
1458 let reason = DecisionReason::Mode(PermissionMode::Default);
1459 assert!(Permission::Allowed(reason.clone()).is_allowed());
1460 assert!(!Permission::Allowed(reason).is_denied());
1461 }
1462
1463 #[test]
1464 fn permission_is_denied_helper() {
1465 let reason = DecisionReason::Mode(PermissionMode::DontAsk);
1466 assert!(Permission::Denied(reason.clone(), "blocked".into()).is_denied());
1467 assert!(!Permission::Denied(reason, "blocked".into()).is_allowed());
1468 }
1469
1470 #[test]
1471 fn passthrough_is_neither() {
1472 assert!(!Permission::Unknown.is_allowed());
1473 assert!(!Permission::Unknown.is_denied());
1474 }
1475
1476 #[test]
1477 fn decision_reason_rule_debug() {
1478 let reason = DecisionReason::Rule {
1479 source: RuleSource::User,
1480 pattern: "Bash".into(),
1481 };
1482 let debug = format!("{:?}", reason);
1483 assert!(debug.contains("Rule"));
1484 assert!(debug.contains("User"));
1485 assert!(debug.contains("Bash"));
1486 }
1487
1488 #[test]
1489 fn decision_reason_mode_debug() {
1490 let reason = DecisionReason::Mode(PermissionMode::DontAsk);
1491 let debug = format!("{:?}", reason);
1492 assert!(debug.contains("DontAsk"));
1493 }
1494
1495 #[test]
1496 fn decision_reason_hook_debug() {
1497 let reason = DecisionReason::Hook {
1498 name: "my_hook".into(),
1499 };
1500 let debug = format!("{:?}", reason);
1501 assert!(debug.contains("Hook"));
1502 assert!(debug.contains("my_hook"));
1503 }
1504
1505 #[test]
1506 fn decision_reason_safety_check_debug() {
1507 let reason = DecisionReason::SafetyCheck {
1508 path: "/etc/passwd".into(),
1509 };
1510 let debug = format!("{:?}", reason);
1511 assert!(debug.contains("SafetyCheck"));
1512 assert!(debug.contains("/etc/passwd"));
1513 }
1514
1515 #[test]
1516 fn check_static_deny_returns_rule_reason() {
1517 let config = LayeredPermissionsConfig {
1518 layers: vec![PermissionLayer {
1519 source: RuleSource::User,
1520 deny: vec!["Bash".into()],
1521 ..Default::default()
1522 }],
1523 ..Default::default()
1524 };
1525 let result = config.check_static("Bash", false, None);
1526 assert!(result.is_denied());
1527 if let Permission::Denied(reason, msg) = result {
1528 assert!(matches!(
1529 reason,
1530 DecisionReason::Rule {
1531 source: RuleSource::User,
1532 ..
1533 }
1534 ));
1535 assert!(msg.contains("Bash"));
1536 } else {
1537 panic!("expected Denied");
1538 }
1539 }
1540
1541 #[test]
1544 fn protected_path_denied_in_default_mode() {
1545 let config = LayeredPermissionsConfig::default();
1546 let result = config.check_static("Write", false, Some(".git/config"));
1547 assert!(result.is_denied());
1548 if let Permission::Denied(DecisionReason::SafetyCheck { path }, msg) = &result {
1549 assert_eq!(path, ".git/config");
1550 assert!(msg.contains("protected"));
1551 } else {
1552 panic!("expected Denied(SafetyCheck), got {result:?}");
1553 }
1554 }
1555
1556 #[test]
1557 fn protected_path_denied_in_bypass_mode() {
1558 let config = LayeredPermissionsConfig {
1559 mode: PermissionMode::BypassPermissions,
1560 ..Default::default()
1561 };
1562 let result = config.check_static("Write", false, Some(".ssh/id_rsa"));
1563 assert!(result.is_denied());
1564 if let Permission::Denied(DecisionReason::SafetyCheck { path }, msg) = &result {
1565 assert_eq!(path, ".ssh/id_rsa");
1566 assert!(msg.contains("protected"));
1567 } else {
1568 panic!("expected Denied(SafetyCheck), got {result:?}");
1569 }
1570 }
1571
1572 #[test]
1573 fn protected_path_readonly_allowed() {
1574 let config = LayeredPermissionsConfig::default();
1575 let result = config.check_static("Read", true, Some(".git/config"));
1577 assert!(matches!(result, Permission::Unknown));
1578 }
1579
1580 #[test]
1581 fn non_protected_path_not_blocked() {
1582 let config = LayeredPermissionsConfig::default();
1583 let result = config.check_static("Write", false, Some("src/main.rs"));
1584 assert!(matches!(result, Permission::Unknown));
1585 }
1586
1587 #[test]
1588 fn nested_protected_path_detected() {
1589 let config = LayeredPermissionsConfig::default();
1590 let result = config.check_static("Write", false, Some("some/dir/.recursive/config.toml"));
1591 assert!(result.is_denied());
1592 if let Permission::Denied(DecisionReason::SafetyCheck { path }, _) = &result {
1593 assert_eq!(path, "some/dir/.recursive/config.toml");
1594 } else {
1595 panic!("expected Denied(SafetyCheck), got {result:?}");
1596 }
1597 }
1598
1599 #[test]
1600 fn path_contains_protected_fn() {
1601 assert!(path_contains_protected(".git/config", ".git"));
1603 assert!(path_contains_protected("a/.git/config", ".git"));
1604 assert!(path_contains_protected(
1606 "some/dir/.recursive/config.toml",
1607 ".recursive"
1608 ));
1609 assert!(!path_contains_protected("legitimate.git_info/x", ".git"));
1611 assert!(path_contains_protected(".env", ".env"));
1613 assert!(!path_contains_protected(".env.example", ".env"));
1614 assert!(!path_contains_protected("", ".git"));
1616 assert!(path_contains_protected(".ssh/authorized_keys", ".ssh"));
1618 }
1619
1620 #[test]
1623 fn add_session_allow_rule() {
1624 let mut config = LayeredPermissionsConfig::default();
1625 config.add_session_rule(RuleBehavior::Allow, "Bash".into());
1626 let session = config.session_rules();
1627 assert_eq!(session.source, RuleSource::Session);
1628 assert!(session.allow.contains(&"Bash".to_string()));
1629 assert!(session.deny.is_empty());
1630 assert!(session.interactive.is_empty());
1631 }
1632
1633 #[test]
1634 fn add_session_deny_rule() {
1635 let mut config = LayeredPermissionsConfig::default();
1636 config.add_session_rule(RuleBehavior::Deny, "Bash".into());
1637 let session = config.session_rules();
1638 assert!(session.deny.contains(&"Bash".to_string()));
1639 }
1640
1641 #[test]
1642 fn add_session_interactive_rule() {
1643 let mut config = LayeredPermissionsConfig::default();
1644 config.add_session_rule(RuleBehavior::Interactive, "Bash".into());
1645 let session = config.session_rules();
1646 assert!(session.interactive.contains(&"Bash".to_string()));
1647 }
1648
1649 #[test]
1650 fn remove_session_rule() {
1651 let mut config = LayeredPermissionsConfig::default();
1652 config.add_session_rule(RuleBehavior::Allow, "Bash".into());
1653 config.add_session_rule(RuleBehavior::Allow, "Write".into());
1654 assert_eq!(config.session_rules().allow.len(), 2);
1655
1656 config.remove_session_rule(RuleBehavior::Allow, "Bash");
1657 assert_eq!(config.session_rules().allow.len(), 1);
1658 assert!(config.session_rules().allow.contains(&"Write".to_string()));
1659 }
1660
1661 #[test]
1662 fn session_layer_created_on_first_use() {
1663 let mut config = LayeredPermissionsConfig::default();
1664 assert!(config.layers.is_empty());
1666 config.add_session_rule(RuleBehavior::Allow, "Read".into());
1668 assert_eq!(config.layers.len(), 1);
1669 assert_eq!(config.layers[0].source, RuleSource::Session);
1670 }
1671
1672 #[test]
1673 fn session_deny_takes_precedence_over_user_allow() {
1674 let mut config = LayeredPermissionsConfig {
1675 mode: PermissionMode::Default,
1676 layers: vec![PermissionLayer {
1677 source: RuleSource::User,
1678 allow: vec!["Bash".into()],
1679 ..Default::default()
1680 }],
1681 };
1682 config.add_session_rule(RuleBehavior::Deny, "Bash".into());
1684 let result = config.check_static("Bash", false, None);
1685 assert!(result.is_denied());
1686 }
1687
1688 #[test]
1689 fn shared_permissions_arc_clone_sees_mutation() {
1690 let config = LayeredPermissionsConfig::default();
1691 let shared = Arc::new(RwLock::new(config));
1692 let clone = Arc::clone(&shared);
1693
1694 {
1696 let mut guard = shared.try_write().unwrap();
1697 guard.add_session_rule(RuleBehavior::Allow, "Bash".into());
1698 }
1699
1700 let guard = clone.try_read().unwrap();
1702 assert!(guard.session_rules().allow.contains(&"Bash".to_string()));
1703 }
1704}