lean_ctx/core/
shell_allowlist.rs1pub fn check_shell_allowlist(command: &str) -> Result<(), String> {
7 check_against_allowlist(command, &effective_allowlist())
8}
9
10fn check_against_allowlist(command: &str, allowlist: &[String]) -> Result<(), String> {
11 if allowlist.is_empty() {
12 return Ok(());
13 }
14 let base = extract_base_command(command);
15 if allowlist.iter().any(|a| a == &base) {
16 Ok(())
17 } else {
18 Err(format!(
19 "[SHELL ALLOWLIST] Command '{}' (base: '{}') is not in the allowed commands list. Allowed: {}",
20 command, base, allowlist.join(", ")
21 ))
22 }
23}
24
25fn effective_allowlist() -> Vec<String> {
26 if let Ok(env_val) = std::env::var("LEAN_CTX_SHELL_ALLOWLIST") {
27 return env_val
28 .split(',')
29 .map(|s| s.trim().to_string())
30 .filter(|s| !s.is_empty())
31 .collect();
32 }
33 crate::core::config::Config::load().shell_allowlist
34}
35
36fn extract_base_command(command: &str) -> String {
37 let trimmed = command.trim();
38 let first = trimmed
40 .split(&['&', '|', ';'][..])
41 .next()
42 .unwrap_or(trimmed)
43 .trim();
44 let parts: Vec<&str> = first.split_whitespace().collect();
46 let cmd_part = parts
47 .iter()
48 .find(|p| !p.contains('='))
49 .copied()
50 .unwrap_or(parts.first().copied().unwrap_or(""));
51 cmd_part.rsplit('/').next().unwrap_or(cmd_part).to_string()
53}
54
55#[cfg(test)]
56mod tests {
57 use super::*;
58
59 #[test]
60 fn extract_simple_command() {
61 assert_eq!(extract_base_command("git status"), "git");
62 }
63
64 #[test]
65 fn extract_with_path() {
66 assert_eq!(extract_base_command("/usr/bin/git log"), "git");
67 }
68
69 #[test]
70 fn extract_with_env_assignment() {
71 assert_eq!(extract_base_command("LANG=en_US git log"), "git");
72 }
73
74 #[test]
75 fn extract_chained_commands() {
76 assert_eq!(extract_base_command("cd /tmp && ls -la"), "cd");
77 }
78
79 #[test]
80 fn extract_piped_command() {
81 assert_eq!(extract_base_command("grep foo | wc -l"), "grep");
82 }
83
84 #[test]
85 fn extract_semicolon_chain() {
86 assert_eq!(extract_base_command("echo hello; rm -rf /"), "echo");
87 }
88
89 #[test]
90 fn extract_empty_command() {
91 assert_eq!(extract_base_command(""), "");
92 }
93
94 #[test]
95 fn extract_whitespace_only() {
96 assert_eq!(extract_base_command(" "), "");
97 }
98
99 #[test]
100 fn extract_multiple_env_vars() {
101 assert_eq!(extract_base_command("FOO=bar BAZ=qux cargo test"), "cargo");
102 }
103
104 fn allow(cmds: &[&str]) -> Vec<String> {
105 cmds.iter().map(std::string::ToString::to_string).collect()
106 }
107
108 #[test]
109 fn allowlist_empty_always_passes() {
110 assert!(check_against_allowlist("anything", &[]).is_ok());
111 }
112
113 #[test]
114 fn allowlist_blocks_unlisted() {
115 let list = allow(&["git", "cargo"]);
116 let result = check_against_allowlist("npm install", &list);
117 assert!(result.is_err());
118 let msg = result.unwrap_err();
119 assert!(msg.contains("npm"));
120 assert!(msg.contains("SHELL ALLOWLIST"));
121 }
122
123 #[test]
124 fn allowlist_allows_listed() {
125 let list = allow(&["git", "cargo", "npm"]);
126 assert!(check_against_allowlist("git status", &list).is_ok());
127 assert!(check_against_allowlist("cargo test --release", &list).is_ok());
128 assert!(check_against_allowlist("npm run build", &list).is_ok());
129 }
130
131 #[test]
132 fn allowlist_allows_full_path() {
133 let list = allow(&["git"]);
134 assert!(check_against_allowlist("/usr/bin/git status", &list).is_ok());
135 }
136
137 #[test]
138 fn allowlist_allows_with_env_prefix() {
139 let list = allow(&["git"]);
140 assert!(check_against_allowlist("LANG=C git log", &list).is_ok());
141 }
142
143 #[test]
144 fn allowlist_blocks_similar_names() {
145 let list = allow(&["git"]);
146 assert!(check_against_allowlist("gitk --all", &list).is_err());
147 }
148}