Skip to main content

lean_ctx/core/
shell_allowlist.rs

1/// Checks if a command is allowed by the shell allowlist.
2/// Returns Ok(()) if allowed, Err(message) if blocked.
3///
4/// When the allowlist is empty, all commands pass (blocklist-only mode).
5/// When non-empty, only commands whose base binary matches are allowed.
6pub 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    // Split on && | || ; and take the first command
39    let first = trimmed
40        .split(&['&', '|', ';'][..])
41        .next()
42        .unwrap_or(trimmed)
43        .trim();
44    // Skip env var assignments (KEY=VALUE patterns)
45    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    // Strip path: /usr/bin/git -> git
52    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}