Skip to main content

tl_errors/
security.rs

1// ThinkingLanguage — Security Policy
2// Licensed under Apache-2.0
3//
4// Phase 23: Connector permissions, path restrictions, sandbox mode.
5// Phase C3: Moved from tl-compiler to tl-errors for shared access.
6
7use std::collections::HashSet;
8
9/// Security policy controlling access to files, network, connectors, and subprocesses.
10#[derive(Debug, Clone)]
11pub struct SecurityPolicy {
12    pub allowed_connectors: HashSet<String>,
13    pub denied_paths: Vec<String>,
14    pub allow_network: bool,
15    pub allow_file_read: bool,
16    pub allow_file_write: bool,
17    pub sandbox_mode: bool,
18    pub allow_subprocess: bool,
19    pub allowed_commands: Vec<String>,
20}
21
22impl SecurityPolicy {
23    pub fn permissive() -> Self {
24        SecurityPolicy {
25            allowed_connectors: HashSet::new(),
26            denied_paths: Vec::new(),
27            allow_network: true,
28            allow_file_read: true,
29            allow_file_write: true,
30            sandbox_mode: false,
31            allow_subprocess: true,
32            allowed_commands: vec![],
33        }
34    }
35
36    pub fn sandbox() -> Self {
37        SecurityPolicy {
38            allowed_connectors: HashSet::new(),
39            denied_paths: Vec::new(),
40            allow_network: false,
41            allow_file_read: true,
42            allow_file_write: false,
43            sandbox_mode: true,
44            allow_subprocess: false,
45            allowed_commands: vec![],
46        }
47    }
48
49    /// Check if a permission is allowed.
50    pub fn check(&self, permission: &str) -> bool {
51        if !self.sandbox_mode {
52            return true;
53        }
54        match permission {
55            "network" => self.allow_network,
56            "file_read" => self.allow_file_read,
57            "file_write" => self.allow_file_write,
58            "python" => false,
59            "env_write" => false,
60            "subprocess" => self.allow_subprocess,
61            p if p.starts_with("command:") => {
62                let cmd = &p["command:".len()..];
63                self.check_command(cmd)
64            }
65            p if p.starts_with("connector:") => {
66                let conn_type = &p["connector:".len()..];
67                self.allowed_connectors.is_empty() || self.allowed_connectors.contains(conn_type)
68            }
69            _ => true,
70        }
71    }
72
73    /// Check if a specific command is allowed for subprocess execution.
74    pub fn check_command(&self, command: &str) -> bool {
75        if !self.allow_subprocess {
76            return false;
77        }
78        if self.allowed_commands.is_empty() {
79            return true;
80        }
81        self.allowed_commands.iter().any(|c| c == command)
82    }
83
84    /// Check if a file path is allowed.
85    pub fn check_path(&self, path: &str) -> bool {
86        if !self.sandbox_mode {
87            return true;
88        }
89        !self
90            .denied_paths
91            .iter()
92            .any(|denied| path.starts_with(denied))
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99
100    #[test]
101    fn test_permissive_allows_all() {
102        let policy = SecurityPolicy::permissive();
103        assert!(policy.check("network"));
104        assert!(policy.check("file_read"));
105        assert!(policy.check("file_write"));
106        assert!(policy.check("connector:postgres"));
107    }
108
109    #[test]
110    fn test_sandbox_restricts() {
111        let policy = SecurityPolicy::sandbox();
112        assert!(!policy.check("network"));
113        assert!(policy.check("file_read"));
114        assert!(!policy.check("file_write"));
115    }
116
117    #[test]
118    fn test_connector_whitelist() {
119        let mut policy = SecurityPolicy::sandbox();
120        policy.allowed_connectors.insert("postgres".to_string());
121        assert!(policy.check("connector:postgres"));
122        assert!(!policy.check("connector:mysql"));
123    }
124
125    #[test]
126    fn test_denied_paths() {
127        let mut policy = SecurityPolicy::sandbox();
128        policy.denied_paths.push("/etc/".to_string());
129        assert!(!policy.check_path("/etc/passwd"));
130        assert!(policy.check_path("/home/user/file.txt"));
131    }
132
133    #[test]
134    fn test_sandbox_denies_subprocess() {
135        let policy = SecurityPolicy::sandbox();
136        assert!(!policy.check("subprocess"));
137        assert!(!policy.check_command("npx"));
138        assert!(!policy.check("command:npx"));
139    }
140
141    #[test]
142    fn test_permissive_allows_subprocess() {
143        let policy = SecurityPolicy::permissive();
144        assert!(policy.check("subprocess"));
145        assert!(policy.check_command("npx"));
146        assert!(policy.check("command:npx"));
147    }
148
149    #[test]
150    fn test_command_whitelist() {
151        let mut policy = SecurityPolicy::sandbox();
152        policy.allow_subprocess = true;
153        policy.allowed_commands = vec!["npx".to_string(), "node".to_string()];
154        assert!(policy.check_command("npx"));
155        assert!(policy.check_command("node"));
156        assert!(!policy.check_command("bash"));
157        assert!(policy.check("command:npx"));
158        assert!(!policy.check("command:bash"));
159    }
160
161    #[test]
162    fn test_empty_whitelist_allows_all() {
163        let mut policy = SecurityPolicy::sandbox();
164        policy.allow_subprocess = true;
165        // allowed_commands is empty by default
166        assert!(policy.check_command("npx"));
167        assert!(policy.check_command("anything"));
168        assert!(policy.check("command:whatever"));
169    }
170}