Skip to main content

zeph_tools/
config.rs

1use serde::{Deserialize, Serialize};
2
3use crate::permissions::{AutonomyLevel, PermissionPolicy, PermissionsConfig};
4
5fn default_true() -> bool {
6    true
7}
8
9fn default_timeout() -> u64 {
10    30
11}
12
13fn default_confirm_patterns() -> Vec<String> {
14    vec![
15        "rm ".into(),
16        "git push -f".into(),
17        "git push --force".into(),
18        "drop table".into(),
19        "drop database".into(),
20        "truncate ".into(),
21        "$(".into(),
22        "`".into(),
23    ]
24}
25
26fn default_audit_destination() -> String {
27    "stdout".into()
28}
29
30/// Top-level configuration for tool execution.
31#[derive(Debug, Deserialize, Serialize)]
32pub struct ToolsConfig {
33    #[serde(default = "default_true")]
34    pub enabled: bool,
35    #[serde(default = "default_true")]
36    pub summarize_output: bool,
37    #[serde(default)]
38    pub shell: ShellConfig,
39    #[serde(default)]
40    pub scrape: ScrapeConfig,
41    #[serde(default)]
42    pub audit: AuditConfig,
43    #[serde(default)]
44    pub permissions: Option<PermissionsConfig>,
45    #[serde(default)]
46    pub filters: crate::filter::FilterConfig,
47}
48
49impl ToolsConfig {
50    /// Build a `PermissionPolicy` from explicit config or legacy shell fields.
51    #[must_use]
52    pub fn permission_policy(&self, autonomy_level: AutonomyLevel) -> PermissionPolicy {
53        let policy = if let Some(ref perms) = self.permissions {
54            PermissionPolicy::from(perms.clone())
55        } else {
56            PermissionPolicy::from_legacy(
57                &self.shell.blocked_commands,
58                &self.shell.confirm_patterns,
59            )
60        };
61        policy.with_autonomy(autonomy_level)
62    }
63}
64
65/// Shell-specific configuration: timeout, command blocklist, and allowlist overrides.
66#[derive(Debug, Deserialize, Serialize)]
67pub struct ShellConfig {
68    #[serde(default = "default_timeout")]
69    pub timeout: u64,
70    #[serde(default)]
71    pub blocked_commands: Vec<String>,
72    #[serde(default)]
73    pub allowed_commands: Vec<String>,
74    #[serde(default)]
75    pub allowed_paths: Vec<String>,
76    #[serde(default = "default_true")]
77    pub allow_network: bool,
78    #[serde(default = "default_confirm_patterns")]
79    pub confirm_patterns: Vec<String>,
80}
81
82/// Configuration for audit logging of tool executions.
83#[derive(Debug, Deserialize, Serialize)]
84pub struct AuditConfig {
85    #[serde(default)]
86    pub enabled: bool,
87    #[serde(default = "default_audit_destination")]
88    pub destination: String,
89}
90
91impl Default for ToolsConfig {
92    fn default() -> Self {
93        Self {
94            enabled: true,
95            summarize_output: true,
96            shell: ShellConfig::default(),
97            scrape: ScrapeConfig::default(),
98            audit: AuditConfig::default(),
99            permissions: None,
100            filters: crate::filter::FilterConfig::default(),
101        }
102    }
103}
104
105impl Default for ShellConfig {
106    fn default() -> Self {
107        Self {
108            timeout: default_timeout(),
109            blocked_commands: Vec::new(),
110            allowed_commands: Vec::new(),
111            allowed_paths: Vec::new(),
112            allow_network: true,
113            confirm_patterns: default_confirm_patterns(),
114        }
115    }
116}
117
118impl Default for AuditConfig {
119    fn default() -> Self {
120        Self {
121            enabled: false,
122            destination: default_audit_destination(),
123        }
124    }
125}
126
127fn default_scrape_timeout() -> u64 {
128    15
129}
130
131fn default_max_body_bytes() -> usize {
132    1_048_576
133}
134
135/// Configuration for the web scrape tool.
136#[derive(Debug, Deserialize, Serialize)]
137pub struct ScrapeConfig {
138    #[serde(default = "default_scrape_timeout")]
139    pub timeout: u64,
140    #[serde(default = "default_max_body_bytes")]
141    pub max_body_bytes: usize,
142}
143
144impl Default for ScrapeConfig {
145    fn default() -> Self {
146        Self {
147            timeout: default_scrape_timeout(),
148            max_body_bytes: default_max_body_bytes(),
149        }
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    #[test]
158    fn deserialize_default_config() {
159        let toml_str = r#"
160            enabled = true
161
162            [shell]
163            timeout = 60
164            blocked_commands = ["rm -rf /", "sudo"]
165        "#;
166
167        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
168        assert!(config.enabled);
169        assert_eq!(config.shell.timeout, 60);
170        assert_eq!(config.shell.blocked_commands.len(), 2);
171        assert_eq!(config.shell.blocked_commands[0], "rm -rf /");
172        assert_eq!(config.shell.blocked_commands[1], "sudo");
173    }
174
175    #[test]
176    fn empty_blocked_commands() {
177        let toml_str = r#"
178            [shell]
179            timeout = 30
180        "#;
181
182        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
183        assert!(config.enabled);
184        assert_eq!(config.shell.timeout, 30);
185        assert!(config.shell.blocked_commands.is_empty());
186    }
187
188    #[test]
189    fn default_tools_config() {
190        let config = ToolsConfig::default();
191        assert!(config.enabled);
192        assert!(config.summarize_output);
193        assert_eq!(config.shell.timeout, 30);
194        assert!(config.shell.blocked_commands.is_empty());
195        assert!(!config.audit.enabled);
196    }
197
198    #[test]
199    fn tools_summarize_output_default_true() {
200        let config = ToolsConfig::default();
201        assert!(config.summarize_output);
202    }
203
204    #[test]
205    fn tools_summarize_output_parsing() {
206        let toml_str = r#"
207            summarize_output = true
208        "#;
209        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
210        assert!(config.summarize_output);
211    }
212
213    #[test]
214    fn default_shell_config() {
215        let config = ShellConfig::default();
216        assert_eq!(config.timeout, 30);
217        assert!(config.blocked_commands.is_empty());
218        assert!(config.allowed_paths.is_empty());
219        assert!(config.allow_network);
220        assert!(!config.confirm_patterns.is_empty());
221    }
222
223    #[test]
224    fn deserialize_omitted_fields_use_defaults() {
225        let toml_str = "";
226        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
227        assert!(config.enabled);
228        assert_eq!(config.shell.timeout, 30);
229        assert!(config.shell.blocked_commands.is_empty());
230        assert!(config.shell.allow_network);
231        assert!(!config.shell.confirm_patterns.is_empty());
232        assert_eq!(config.scrape.timeout, 15);
233        assert_eq!(config.scrape.max_body_bytes, 1_048_576);
234        assert!(!config.audit.enabled);
235        assert_eq!(config.audit.destination, "stdout");
236        assert!(config.summarize_output);
237    }
238
239    #[test]
240    fn default_scrape_config() {
241        let config = ScrapeConfig::default();
242        assert_eq!(config.timeout, 15);
243        assert_eq!(config.max_body_bytes, 1_048_576);
244    }
245
246    #[test]
247    fn deserialize_scrape_config() {
248        let toml_str = r#"
249            [scrape]
250            timeout = 30
251            max_body_bytes = 2097152
252        "#;
253
254        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
255        assert_eq!(config.scrape.timeout, 30);
256        assert_eq!(config.scrape.max_body_bytes, 2_097_152);
257    }
258
259    #[test]
260    fn tools_config_default_includes_scrape() {
261        let config = ToolsConfig::default();
262        assert_eq!(config.scrape.timeout, 15);
263        assert_eq!(config.scrape.max_body_bytes, 1_048_576);
264    }
265
266    #[test]
267    fn deserialize_allowed_commands() {
268        let toml_str = r#"
269            [shell]
270            timeout = 30
271            allowed_commands = ["curl", "wget"]
272        "#;
273
274        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
275        assert_eq!(config.shell.allowed_commands, vec!["curl", "wget"]);
276    }
277
278    #[test]
279    fn default_allowed_commands_empty() {
280        let config = ShellConfig::default();
281        assert!(config.allowed_commands.is_empty());
282    }
283
284    #[test]
285    fn deserialize_shell_security_fields() {
286        let toml_str = r#"
287            [shell]
288            allowed_paths = ["/tmp", "/home/user"]
289            allow_network = false
290            confirm_patterns = ["rm ", "drop table"]
291        "#;
292
293        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
294        assert_eq!(config.shell.allowed_paths, vec!["/tmp", "/home/user"]);
295        assert!(!config.shell.allow_network);
296        assert_eq!(config.shell.confirm_patterns, vec!["rm ", "drop table"]);
297    }
298
299    #[test]
300    fn deserialize_audit_config() {
301        let toml_str = r#"
302            [audit]
303            enabled = true
304            destination = "/var/log/zeph-audit.log"
305        "#;
306
307        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
308        assert!(config.audit.enabled);
309        assert_eq!(config.audit.destination, "/var/log/zeph-audit.log");
310    }
311
312    #[test]
313    fn default_audit_config() {
314        let config = AuditConfig::default();
315        assert!(!config.enabled);
316        assert_eq!(config.destination, "stdout");
317    }
318
319    #[test]
320    fn permission_policy_from_legacy_fields() {
321        let config = ToolsConfig {
322            shell: ShellConfig {
323                blocked_commands: vec!["sudo".to_owned()],
324                confirm_patterns: vec!["rm ".to_owned()],
325                ..ShellConfig::default()
326            },
327            ..ToolsConfig::default()
328        };
329        let policy = config.permission_policy(AutonomyLevel::Supervised);
330        assert_eq!(
331            policy.check("bash", "sudo apt"),
332            crate::permissions::PermissionAction::Deny
333        );
334        assert_eq!(
335            policy.check("bash", "rm file"),
336            crate::permissions::PermissionAction::Ask
337        );
338    }
339
340    #[test]
341    fn permission_policy_from_explicit_config() {
342        let toml_str = r#"
343            [permissions]
344            [[permissions.bash]]
345            pattern = "*sudo*"
346            action = "deny"
347        "#;
348        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
349        let policy = config.permission_policy(AutonomyLevel::Supervised);
350        assert_eq!(
351            policy.check("bash", "sudo rm"),
352            crate::permissions::PermissionAction::Deny
353        );
354    }
355
356    #[test]
357    fn permission_policy_default_uses_legacy() {
358        let config = ToolsConfig::default();
359        assert!(config.permissions.is_none());
360        let policy = config.permission_policy(AutonomyLevel::Supervised);
361        // Default ShellConfig has confirm_patterns, so legacy rules are generated
362        assert!(!config.shell.confirm_patterns.is_empty());
363        assert!(policy.rules().contains_key("bash"));
364    }
365}