1use anyhow::Result;
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use std::path::PathBuf;
7use std::time::Duration;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct ShellConfig {
12 pub default_shell: String,
14
15 #[serde(with = "duration_serde")]
17 pub default_timeout: Duration,
18
19 pub max_output_size: usize,
21
22 pub pty: PtyConfig,
24
25 pub strip_ansi_codes: bool,
27
28 pub environment: EnvironmentConfig,
30
31 pub security: ShellSecurityConfig,
33
34 pub shell_specific: HashMap<String, ShellSpecificConfig>,
36
37 pub working_directory: WorkingDirectoryConfig,
39
40 pub logging: ShellLoggingConfig,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct PtyConfig {
47 pub rows: Option<u16>,
49
50 pub cols: Option<u16>,
52
53 pub terminal_type: Option<String>,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct EnvironmentConfig {
60 pub capture_changes: bool,
62
63 pub snapshot_size_limit: usize,
65
66 pub preserve_vars: Vec<String>,
68
69 pub remove_vars: Vec<String>,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct ShellSecurityConfig {
76 pub allow_interactive_commands: bool,
78
79 pub sandbox_mode: bool,
81
82 pub allowed_commands: Option<Vec<String>>,
84
85 pub blocked_commands: Vec<String>,
87
88 pub max_command_length: usize,
90
91 pub block_dangerous_patterns: bool,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize, Default)]
97pub struct ShellSpecificConfig {
98 #[serde(with = "duration_serde_opt")]
100 pub timeout: Option<Duration>,
101
102 pub environment: HashMap<String, String>,
104
105 pub init_commands: Vec<String>,
107
108 pub login_args: Option<Vec<String>>,
110
111 pub non_login_args: Option<Vec<String>>,
113
114 pub features: ShellFeatures,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct ShellFeatures {
121 pub job_control: bool,
123
124 pub history: bool,
126
127 pub completion: bool,
129
130 pub color: bool,
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct WorkingDirectoryConfig {
137 pub preserve_cwd: bool,
139
140 pub default_cwd: Option<PathBuf>,
142
143 pub allowed_directories: Option<Vec<PathBuf>>,
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct ShellLoggingConfig {
150 pub log_commands: bool,
152
153 pub log_output: bool,
155
156 pub log_environment_changes: bool,
158
159 pub debug_pty: bool,
161
162 pub max_log_line_length: usize,
164}
165
166impl Default for ShellConfig {
167 fn default() -> Self {
168 Self {
169 default_shell: "/bin/sh".to_string(), default_timeout: Duration::from_secs(120), max_output_size: 2 * 1024 * 1024, pty: PtyConfig::default(),
173 strip_ansi_codes: false,
174 environment: EnvironmentConfig::default(),
175 security: ShellSecurityConfig::default(),
176 shell_specific: HashMap::new(),
177 working_directory: WorkingDirectoryConfig::default(),
178 logging: ShellLoggingConfig::default(),
179 }
180 }
181}
182
183impl Default for PtyConfig {
184 fn default() -> Self {
185 Self {
186 rows: Some(24),
187 cols: Some(80),
188 terminal_type: Some("xterm-256color".to_string()),
189 }
190 }
191}
192
193impl Default for EnvironmentConfig {
194 fn default() -> Self {
195 Self {
196 capture_changes: true,
197 snapshot_size_limit: 1000,
198 preserve_vars: vec![
199 "PATH".to_string(),
200 "HOME".to_string(),
201 "USER".to_string(),
202 "SHELL".to_string(),
203 "TERM".to_string(),
204 "LANG".to_string(),
205 "LC_ALL".to_string(),
206 ],
207 remove_vars: vec![
208 "SSH_AUTH_SOCK".to_string(),
209 "SSH_AGENT_PID".to_string(),
210 "GPG_AGENT_INFO".to_string(),
211 ],
212 }
213 }
214}
215
216impl Default for ShellSecurityConfig {
217 fn default() -> Self {
218 Self {
219 allow_interactive_commands: true,
220 sandbox_mode: false,
221 allowed_commands: None,
222 blocked_commands: vec![
223 ":(){ :|:& };:".to_string(),
225 "fork() { fork|fork& }; fork".to_string(),
226 "rm -rf /".to_string(),
228 "sudo rm -rf".to_string(),
229 "> /dev/sda".to_string(),
230 "mkfs".to_string(),
232 "fdisk".to_string(),
233 "dd if=/dev/zero".to_string(),
234 "dd if=/dev/urandom".to_string(),
235 "ping -f".to_string(),
237 "hping".to_string(),
238 "nmap".to_string(),
239 "kill -9 -1".to_string(),
241 "killall -9".to_string(),
242 "pkill -9".to_string(),
243 ],
244 max_command_length: 8192, block_dangerous_patterns: true,
246 }
247 }
248}
249
250impl Default for ShellFeatures {
251 fn default() -> Self {
252 Self {
253 job_control: true,
254 history: true,
255 completion: true,
256 color: true,
257 }
258 }
259}
260
261impl Default for WorkingDirectoryConfig {
262 fn default() -> Self {
263 Self {
264 preserve_cwd: true,
265 default_cwd: None,
266 allowed_directories: None, }
268 }
269}
270
271impl Default for ShellLoggingConfig {
272 fn default() -> Self {
273 Self {
274 log_commands: true,
275 log_output: false, log_environment_changes: false,
277 debug_pty: false,
278 max_log_line_length: 1024,
279 }
280 }
281}
282
283impl ShellConfig {
284 pub fn validate(&self) -> Result<()> {
285 if self.default_shell.is_empty() {
287 return Err(anyhow::anyhow!("Default shell cannot be empty"));
288 }
289
290 if self.default_timeout.as_millis() == 0 {
292 return Err(anyhow::anyhow!("Default timeout cannot be zero"));
293 }
294
295 if self.default_timeout.as_secs() > 10 * 60 {
296 tracing::warn!("Default timeout > 10 minutes may cause resource issues");
297 }
298
299 if self.max_output_size == 0 {
301 return Err(anyhow::anyhow!("Max output size cannot be zero"));
302 }
303
304 if self.max_output_size > 100 * 1024 * 1024 {
305 return Err(anyhow::anyhow!("Max output size too large (max 100MB)"));
306 }
307
308 self.pty.validate()?;
310 self.environment.validate()?;
311 self.security.validate()?;
312 self.working_directory.validate()?;
313 self.logging.validate()?;
314
315 for (shell_name, shell_config) in &self.shell_specific {
317 shell_config.validate().map_err(|e| {
318 anyhow::anyhow!(
319 "Shell-specific config for '{}' validation failed: {}",
320 shell_name,
321 e
322 )
323 })?;
324 }
325
326 Ok(())
327 }
328
329 pub fn development() -> Self {
330 Self {
331 default_timeout: Duration::from_secs(300), security: ShellSecurityConfig {
333 sandbox_mode: false,
334 allow_interactive_commands: true,
335 block_dangerous_patterns: false, ..Default::default()
337 },
338 logging: ShellLoggingConfig {
339 log_commands: true,
340 log_output: true, debug_pty: true,
342 ..Default::default()
343 },
344 ..Default::default()
345 }
346 }
347
348 pub fn production() -> Self {
349 Self {
350 default_timeout: Duration::from_secs(30), strip_ansi_codes: true, security: ShellSecurityConfig {
353 sandbox_mode: true,
354 allow_interactive_commands: false,
355 allowed_commands: Some(vec![
356 "ls".to_string(),
358 "cat".to_string(),
359 "grep".to_string(),
360 "awk".to_string(),
361 "sed".to_string(),
362 "sort".to_string(),
363 "uniq".to_string(),
364 "cut".to_string(),
365 "head".to_string(),
366 "tail".to_string(),
367 "wc".to_string(),
368 "find".to_string(),
370 "mkdir".to_string(),
371 "touch".to_string(),
372 "cp".to_string(),
373 "mv".to_string(),
374 "ps".to_string(),
376 "top".to_string(),
377 "df".to_string(),
378 "du".to_string(),
379 "free".to_string(),
380 "uptime".to_string(),
381 "uname".to_string(),
382 "ping".to_string(),
384 "dig".to_string(),
385 "host".to_string(),
386 "nslookup".to_string(),
387 "tr".to_string(),
389 "expand".to_string(),
390 "unexpand".to_string(),
391 "fold".to_string(),
392 "tar".to_string(),
394 "gzip".to_string(),
395 "gunzip".to_string(),
396 "zip".to_string(),
397 "unzip".to_string(),
398 ]),
399 block_dangerous_patterns: true,
400 max_command_length: 2048, ..Default::default()
402 },
403 environment: EnvironmentConfig {
404 capture_changes: false, snapshot_size_limit: 100, ..Default::default()
407 },
408 logging: ShellLoggingConfig {
409 log_commands: true,
410 log_output: false, log_environment_changes: false,
412 debug_pty: false,
413 max_log_line_length: 512, },
415 ..Default::default()
416 }
417 }
418
419 pub fn testing() -> Self {
420 Self {
421 default_timeout: Duration::from_secs(5), max_output_size: 64 * 1024, security: ShellSecurityConfig {
424 sandbox_mode: false, allow_interactive_commands: false, block_dangerous_patterns: false,
427 ..Default::default()
428 },
429 environment: EnvironmentConfig {
430 capture_changes: false,
431 snapshot_size_limit: 10,
432 ..Default::default()
433 },
434 logging: ShellLoggingConfig {
435 log_commands: false,
436 log_output: false,
437 log_environment_changes: false,
438 debug_pty: false,
439 max_log_line_length: 256,
440 },
441 ..Default::default()
442 }
443 }
444
445 pub fn is_command_allowed(&self, command: &str) -> bool {
447 if command.len() > self.security.max_command_length {
448 return false;
449 }
450
451 for blocked in &self.security.blocked_commands {
453 if command.contains(blocked) {
454 return false;
455 }
456 }
457
458 if self.security.block_dangerous_patterns && self.contains_dangerous_pattern(command) {
460 return false;
461 }
462
463 if let Some(ref allowed) = self.security.allowed_commands {
465 let command_name = command.split_whitespace().next().unwrap_or(command);
466 return allowed.iter().any(|allowed_cmd| {
467 command_name == allowed_cmd || command.starts_with(&format!("{} ", allowed_cmd))
468 });
469 }
470
471 true
472 }
473
474 fn contains_dangerous_pattern(&self, command: &str) -> bool {
475 let dangerous_patterns = [
476 ">/dev/",
478 ">>/dev/",
479 "rm -rf /",
481 "chmod -R /",
482 "chown -R /",
483 "| sh",
485 "| bash",
486 "| zsh",
487 "sudo rm",
489 "sudo dd",
490 "sudo mkfs",
491 "ping -f",
493 "ping -i 0",
494 "while true",
496 "for i in $(seq 1 1000000)",
497 "> /dev/null &",
498 ];
499
500 dangerous_patterns
501 .iter()
502 .any(|pattern| command.contains(pattern))
503 }
504}
505
506impl PtyConfig {
507 pub fn validate(&self) -> Result<()> {
508 if let Some(rows) = self.rows {
509 if rows == 0 || rows > 1000 {
510 return Err(anyhow::anyhow!(
511 "Invalid PTY rows: {}. Must be between 1 and 1000",
512 rows
513 ));
514 }
515 }
516
517 if let Some(cols) = self.cols {
518 if cols == 0 || cols > 1000 {
519 return Err(anyhow::anyhow!(
520 "Invalid PTY cols: {}. Must be between 1 and 1000",
521 cols
522 ));
523 }
524 }
525
526 Ok(())
527 }
528}
529
530impl EnvironmentConfig {
531 pub fn validate(&self) -> Result<()> {
532 if self.snapshot_size_limit == 0 {
533 return Err(anyhow::anyhow!(
534 "Environment snapshot size limit must be > 0"
535 ));
536 }
537
538 if self.snapshot_size_limit > 10_000 {
539 return Err(anyhow::anyhow!(
540 "Environment snapshot size limit too large (max 10,000)"
541 ));
542 }
543
544 Ok(())
545 }
546}
547
548impl ShellSecurityConfig {
549 pub fn validate(&self) -> Result<()> {
550 if self.max_command_length == 0 {
551 return Err(anyhow::anyhow!("Max command length must be > 0"));
552 }
553
554 if self.max_command_length > 1024 * 1024 {
555 return Err(anyhow::anyhow!("Max command length too large (max 1MB)"));
556 }
557
558 Ok(())
559 }
560}
561
562impl ShellSpecificConfig {
563 pub fn validate(&self) -> Result<()> {
564 if let Some(timeout) = self.timeout {
565 if timeout.as_millis() == 0 {
566 return Err(anyhow::anyhow!("Shell-specific timeout cannot be zero"));
567 }
568 }
569
570 Ok(())
571 }
572}
573
574impl WorkingDirectoryConfig {
575 pub fn validate(&self) -> Result<()> {
576 if let Some(ref default_cwd) = self.default_cwd {
577 if !default_cwd.is_absolute() {
578 return Err(anyhow::anyhow!("Default CWD must be an absolute path"));
579 }
580 }
581
582 if let Some(ref allowed_dirs) = self.allowed_directories {
583 for dir in allowed_dirs {
584 if !dir.is_absolute() {
585 return Err(anyhow::anyhow!(
586 "Allowed directories must be absolute paths"
587 ));
588 }
589 }
590 }
591
592 Ok(())
593 }
594}
595
596impl ShellLoggingConfig {
597 pub fn validate(&self) -> Result<()> {
598 if self.max_log_line_length == 0 {
599 return Err(anyhow::anyhow!("Max log line length must be > 0"));
600 }
601
602 if self.max_log_line_length > 64 * 1024 {
603 return Err(anyhow::anyhow!("Max log line length too large (max 64KB)"));
604 }
605
606 Ok(())
607 }
608}
609
610mod duration_serde {
612 use serde::{Deserialize, Deserializer, Serializer};
613 use std::time::Duration;
614
615 pub fn serialize<S>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error>
616 where
617 S: Serializer,
618 {
619 serializer.serialize_u64(duration.as_millis() as u64)
620 }
621
622 pub fn deserialize<'de, D>(deserializer: D) -> Result<Duration, D::Error>
623 where
624 D: Deserializer<'de>,
625 {
626 let millis = u64::deserialize(deserializer)?;
627 Ok(Duration::from_millis(millis))
628 }
629}
630
631mod duration_serde_opt {
632 use serde::{Deserialize, Deserializer, Serializer};
633 use std::time::Duration;
634
635 pub fn serialize<S>(duration: &Option<Duration>, serializer: S) -> Result<S::Ok, S::Error>
636 where
637 S: Serializer,
638 {
639 match duration {
640 Some(d) => serializer.serialize_some(&(d.as_millis() as u64)),
641 None => serializer.serialize_none(),
642 }
643 }
644
645 pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Duration>, D::Error>
646 where
647 D: Deserializer<'de>,
648 {
649 match Option::<u64>::deserialize(deserializer)? {
650 Some(millis) => Ok(Some(Duration::from_millis(millis))),
651 None => Ok(None),
652 }
653 }
654}
655
656#[cfg(test)]
657mod tests {
658 use super::*;
659 use std::collections::HashMap;
660 use std::time::Duration;
661
662 #[test]
663 fn test_shell_config_default() {
664 let config = ShellConfig::default();
665
666 assert_eq!(config.default_shell, "/bin/sh");
667 assert_eq!(config.default_timeout, Duration::from_secs(120));
668 assert_eq!(config.max_output_size, 2 * 1024 * 1024);
669 assert!(!config.strip_ansi_codes);
670 assert!(config.shell_specific.is_empty());
671 }
672
673 #[test]
674 fn test_shell_config_validation() {
675 let mut config = ShellConfig::default();
676 assert!(config.validate().is_ok());
677
678 config.default_shell = "".to_string();
680 assert!(config.validate().is_err());
681
682 config.default_shell = "/bin/bash".to_string();
683 assert!(config.validate().is_ok());
684 }
685
686 #[test]
687 fn test_shell_security_config() {
688 let security = ShellSecurityConfig::default();
689 assert!(security.allow_interactive_commands);
690 assert!(!security.sandbox_mode);
691 assert!(security.allowed_commands.is_none());
692 assert_eq!(security.max_command_length, 8192);
693 assert!(security.block_dangerous_patterns); assert!(security.validate().is_ok());
696 }
697
698 #[test]
699 fn test_shell_security_config_validation() {
700 let invalid_security = ShellSecurityConfig {
701 max_command_length: 0,
702 ..ShellSecurityConfig::default()
703 };
704 assert!(invalid_security.validate().is_err());
705
706 let valid_security = ShellSecurityConfig {
707 max_command_length: 1024,
708 ..ShellSecurityConfig::default()
709 };
710 assert!(valid_security.validate().is_ok());
711 }
712
713 #[test]
714 fn test_pty_config() {
715 let pty = PtyConfig::default();
716 assert_eq!(pty.rows, Some(24));
717 assert_eq!(pty.cols, Some(80));
718 assert_eq!(pty.terminal_type, Some("xterm-256color".to_string())); }
720
721 #[test]
722 fn test_environment_config() {
723 let env = EnvironmentConfig::default();
724 assert!(env.capture_changes); assert_eq!(env.snapshot_size_limit, 1000); assert!(env.preserve_vars.contains(&"PATH".to_string())); assert!(env.preserve_vars.contains(&"HOME".to_string()));
728 assert!(env.preserve_vars.contains(&"USER".to_string()));
729 assert!(env.remove_vars.contains(&"SSH_AUTH_SOCK".to_string())); }
731
732 #[test]
733 fn test_working_directory_config() {
734 let wd = WorkingDirectoryConfig::default();
735 assert!(wd.preserve_cwd); assert!(wd.default_cwd.is_none()); assert!(wd.allowed_directories.is_none()); }
739
740 #[test]
741 fn test_shell_features() {
742 let features = ShellFeatures::default();
743 assert!(features.job_control);
744 assert!(features.history);
745 assert!(features.completion);
746 assert!(features.color); }
748
749 #[test]
750 fn test_shell_specific_config_validation() {
751 let mut shell_config = ShellSpecificConfig {
752 timeout: Some(Duration::from_secs(60)),
753 environment: HashMap::new(), init_commands: vec!["-i".to_string()], login_args: Some(vec!["--login".to_string()]),
756 non_login_args: Some(vec!["--norc".to_string()]),
757 features: ShellFeatures::default(), };
759
760 assert!(shell_config.validate().is_ok());
761
762 shell_config.timeout = Some(Duration::from_millis(0));
764 assert!(shell_config.validate().is_err());
765 }
766
767 #[test]
768 fn test_shell_logging_config() {
769 let logging = ShellLoggingConfig::default();
770 assert!(logging.log_commands);
771 assert!(!logging.log_output);
772 assert!(!logging.log_environment_changes);
773 assert!(!logging.debug_pty); assert_eq!(logging.max_log_line_length, 1024);
775
776 assert!(logging.validate().is_ok());
777 }
778
779 #[test]
780 fn test_shell_logging_config_validation() {
781 let zero_len_logging = ShellLoggingConfig {
782 max_log_line_length: 0,
783 ..ShellLoggingConfig::default()
784 };
785 assert!(zero_len_logging.validate().is_err());
786
787 let too_large_logging = ShellLoggingConfig {
788 max_log_line_length: 128 * 1024, ..ShellLoggingConfig::default()
790 };
791 assert!(too_large_logging.validate().is_err());
792
793 let valid_logging = ShellLoggingConfig {
794 max_log_line_length: 2048,
795 ..ShellLoggingConfig::default()
796 };
797 assert!(valid_logging.validate().is_ok());
798 }
799
800 #[test]
801 fn test_duration_serialization() {
802 let config = ShellConfig {
803 default_timeout: Duration::from_millis(5000),
804 ..Default::default()
805 };
806
807 let serialized = serde_json::to_string(&config).unwrap();
808 assert!(serialized.contains("5000"));
809
810 let deserialized: ShellConfig = serde_json::from_str(&serialized).unwrap();
811 assert_eq!(deserialized.default_timeout, Duration::from_millis(5000));
812 }
813
814 #[test]
815 fn test_shell_config_serialization_roundtrip() {
816 let original_config = ShellConfig::default();
817
818 let json = serde_json::to_string(&original_config).unwrap();
819 let deserialized_config: ShellConfig = serde_json::from_str(&json).unwrap();
820
821 assert_eq!(
822 original_config.default_shell,
823 deserialized_config.default_shell
824 );
825 assert_eq!(
826 original_config.default_timeout,
827 deserialized_config.default_timeout
828 );
829 assert_eq!(
830 original_config.max_output_size,
831 deserialized_config.max_output_size
832 );
833 assert_eq!(
834 original_config.strip_ansi_codes,
835 deserialized_config.strip_ansi_codes
836 );
837 }
838
839 #[test]
840 fn test_security_config_with_allowed_commands() {
841 let security = ShellSecurityConfig {
842 allow_interactive_commands: false,
843 sandbox_mode: true,
844 allowed_commands: Some(vec![
845 "ls".to_string(),
846 "cat".to_string(),
847 "grep".to_string(),
848 "find".to_string(),
849 ]),
850 blocked_commands: vec!["rm".to_string(), "sudo".to_string()], max_command_length: 2048,
852 block_dangerous_patterns: true, };
854
855 assert!(security.validate().is_ok());
856 assert!(security.allowed_commands.is_some());
857 assert_eq!(security.allowed_commands.as_ref().unwrap().len(), 4);
858 assert!(!security.allow_interactive_commands);
859 assert!(security.sandbox_mode);
860 assert_eq!(security.blocked_commands.len(), 2);
861 }
862
863 #[test]
864 fn test_working_directory_config_with_restrictions() {
865 let wd_config = WorkingDirectoryConfig {
866 preserve_cwd: false, default_cwd: Some(PathBuf::from("/home/user")),
868 allowed_directories: Some(vec![
869 PathBuf::from("/home"),
870 PathBuf::from("/tmp"),
871 PathBuf::from("/var/log"),
872 ]),
873 };
874
875 assert_eq!(wd_config.default_cwd, Some(PathBuf::from("/home/user")));
876 assert!(!wd_config.preserve_cwd);
877 assert!(wd_config.allowed_directories.is_some());
878 assert_eq!(wd_config.allowed_directories.as_ref().unwrap().len(), 3);
879 }
880
881 #[test]
882 fn test_pty_config_custom() {
883 let pty = PtyConfig {
884 rows: Some(50),
885 cols: Some(150),
886 terminal_type: Some("screen-256color".to_string()), };
888
889 assert_eq!(pty.rows, Some(50));
890 assert_eq!(pty.cols, Some(150));
891 assert_eq!(pty.terminal_type, Some("screen-256color".to_string()));
892 }
893
894 #[test]
895 fn test_environment_config_with_custom_settings() {
896 let env_config = EnvironmentConfig {
897 capture_changes: true, snapshot_size_limit: 2000,
899 preserve_vars: vec![
900 "PATH".to_string(),
901 "HOME".to_string(),
902 "USER".to_string(),
903 "SHELL".to_string(),
904 ],
905 remove_vars: vec!["TEMP_VAR".to_string(), "OLD_VAR".to_string()],
906 };
907
908 assert!(env_config.capture_changes);
909 assert_eq!(env_config.snapshot_size_limit, 2000);
910 assert_eq!(env_config.preserve_vars.len(), 4);
911 assert_eq!(env_config.remove_vars.len(), 2);
912 assert!(env_config.preserve_vars.contains(&"PATH".to_string()));
913 assert!(env_config.remove_vars.contains(&"TEMP_VAR".to_string()));
914 }
915}