Skip to main content

zeph_tools/
config.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use std::path::PathBuf;
5
6use serde::{Deserialize, Serialize};
7
8use crate::permissions::{AutonomyLevel, PermissionPolicy, PermissionsConfig};
9
10fn default_true() -> bool {
11    true
12}
13
14fn default_timeout() -> u64 {
15    30
16}
17
18fn default_confirm_patterns() -> Vec<String> {
19    vec![
20        "rm ".into(),
21        "git push -f".into(),
22        "git push --force".into(),
23        "drop table".into(),
24        "drop database".into(),
25        "truncate ".into(),
26        "$(".into(),
27        "`".into(),
28        "<(".into(),
29        ">(".into(),
30        "<<<".into(),
31        "eval ".into(),
32    ]
33}
34
35fn default_audit_destination() -> String {
36    "stdout".into()
37}
38
39fn default_overflow_threshold() -> usize {
40    50_000
41}
42
43fn default_retention_days() -> u64 {
44    7
45}
46
47/// Configuration for large tool response offload to filesystem.
48#[derive(Debug, Clone, Deserialize, Serialize)]
49pub struct OverflowConfig {
50    #[serde(default = "default_overflow_threshold")]
51    pub threshold: usize,
52    #[serde(default = "default_retention_days")]
53    pub retention_days: u64,
54    #[serde(default)]
55    pub dir: Option<PathBuf>,
56}
57
58impl Default for OverflowConfig {
59    fn default() -> Self {
60        Self {
61            threshold: default_overflow_threshold(),
62            retention_days: default_retention_days(),
63            dir: None,
64        }
65    }
66}
67
68fn default_anomaly_window() -> usize {
69    10
70}
71
72fn default_anomaly_error_threshold() -> f64 {
73    0.5
74}
75
76fn default_anomaly_critical_threshold() -> f64 {
77    0.8
78}
79
80/// Configuration for the sliding-window anomaly detector.
81#[derive(Debug, Clone, Deserialize, Serialize)]
82pub struct AnomalyConfig {
83    #[serde(default)]
84    pub enabled: bool,
85    #[serde(default = "default_anomaly_window")]
86    pub window_size: usize,
87    #[serde(default = "default_anomaly_error_threshold")]
88    pub error_threshold: f64,
89    #[serde(default = "default_anomaly_critical_threshold")]
90    pub critical_threshold: f64,
91}
92
93impl Default for AnomalyConfig {
94    fn default() -> Self {
95        Self {
96            enabled: false,
97            window_size: default_anomaly_window(),
98            error_threshold: default_anomaly_error_threshold(),
99            critical_threshold: default_anomaly_critical_threshold(),
100        }
101    }
102}
103
104/// Top-level configuration for tool execution.
105#[derive(Debug, Deserialize, Serialize)]
106pub struct ToolsConfig {
107    #[serde(default = "default_true")]
108    pub enabled: bool,
109    #[serde(default = "default_true")]
110    pub summarize_output: bool,
111    #[serde(default)]
112    pub shell: ShellConfig,
113    #[serde(default)]
114    pub scrape: ScrapeConfig,
115    #[serde(default)]
116    pub audit: AuditConfig,
117    #[serde(default)]
118    pub permissions: Option<PermissionsConfig>,
119    #[serde(default)]
120    pub filters: crate::filter::FilterConfig,
121    #[serde(default)]
122    pub overflow: OverflowConfig,
123    #[serde(default)]
124    pub anomaly: AnomalyConfig,
125}
126
127impl ToolsConfig {
128    /// Build a `PermissionPolicy` from explicit config or legacy shell fields.
129    #[must_use]
130    pub fn permission_policy(&self, autonomy_level: AutonomyLevel) -> PermissionPolicy {
131        let policy = if let Some(ref perms) = self.permissions {
132            PermissionPolicy::from(perms.clone())
133        } else {
134            PermissionPolicy::from_legacy(
135                &self.shell.blocked_commands,
136                &self.shell.confirm_patterns,
137            )
138        };
139        policy.with_autonomy(autonomy_level)
140    }
141}
142
143/// Shell-specific configuration: timeout, command blocklist, and allowlist overrides.
144#[derive(Debug, Deserialize, Serialize)]
145pub struct ShellConfig {
146    #[serde(default = "default_timeout")]
147    pub timeout: u64,
148    #[serde(default)]
149    pub blocked_commands: Vec<String>,
150    #[serde(default)]
151    pub allowed_commands: Vec<String>,
152    #[serde(default)]
153    pub allowed_paths: Vec<String>,
154    #[serde(default = "default_true")]
155    pub allow_network: bool,
156    #[serde(default = "default_confirm_patterns")]
157    pub confirm_patterns: Vec<String>,
158}
159
160/// Configuration for audit logging of tool executions.
161#[derive(Debug, Deserialize, Serialize)]
162pub struct AuditConfig {
163    #[serde(default)]
164    pub enabled: bool,
165    #[serde(default = "default_audit_destination")]
166    pub destination: String,
167}
168
169impl Default for ToolsConfig {
170    fn default() -> Self {
171        Self {
172            enabled: true,
173            summarize_output: true,
174            shell: ShellConfig::default(),
175            scrape: ScrapeConfig::default(),
176            audit: AuditConfig::default(),
177            permissions: None,
178            filters: crate::filter::FilterConfig::default(),
179            overflow: OverflowConfig::default(),
180            anomaly: AnomalyConfig::default(),
181        }
182    }
183}
184
185impl Default for ShellConfig {
186    fn default() -> Self {
187        Self {
188            timeout: default_timeout(),
189            blocked_commands: Vec::new(),
190            allowed_commands: Vec::new(),
191            allowed_paths: Vec::new(),
192            allow_network: true,
193            confirm_patterns: default_confirm_patterns(),
194        }
195    }
196}
197
198impl Default for AuditConfig {
199    fn default() -> Self {
200        Self {
201            enabled: false,
202            destination: default_audit_destination(),
203        }
204    }
205}
206
207fn default_scrape_timeout() -> u64 {
208    15
209}
210
211fn default_max_body_bytes() -> usize {
212    4_194_304
213}
214
215/// Configuration for the web scrape tool.
216#[derive(Debug, Deserialize, Serialize)]
217pub struct ScrapeConfig {
218    #[serde(default = "default_scrape_timeout")]
219    pub timeout: u64,
220    #[serde(default = "default_max_body_bytes")]
221    pub max_body_bytes: usize,
222}
223
224impl Default for ScrapeConfig {
225    fn default() -> Self {
226        Self {
227            timeout: default_scrape_timeout(),
228            max_body_bytes: default_max_body_bytes(),
229        }
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    #[test]
238    fn deserialize_default_config() {
239        let toml_str = r#"
240            enabled = true
241
242            [shell]
243            timeout = 60
244            blocked_commands = ["rm -rf /", "sudo"]
245        "#;
246
247        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
248        assert!(config.enabled);
249        assert_eq!(config.shell.timeout, 60);
250        assert_eq!(config.shell.blocked_commands.len(), 2);
251        assert_eq!(config.shell.blocked_commands[0], "rm -rf /");
252        assert_eq!(config.shell.blocked_commands[1], "sudo");
253    }
254
255    #[test]
256    fn empty_blocked_commands() {
257        let toml_str = r"
258            [shell]
259            timeout = 30
260        ";
261
262        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
263        assert!(config.enabled);
264        assert_eq!(config.shell.timeout, 30);
265        assert!(config.shell.blocked_commands.is_empty());
266    }
267
268    #[test]
269    fn default_tools_config() {
270        let config = ToolsConfig::default();
271        assert!(config.enabled);
272        assert!(config.summarize_output);
273        assert_eq!(config.shell.timeout, 30);
274        assert!(config.shell.blocked_commands.is_empty());
275        assert!(!config.audit.enabled);
276    }
277
278    #[test]
279    fn tools_summarize_output_default_true() {
280        let config = ToolsConfig::default();
281        assert!(config.summarize_output);
282    }
283
284    #[test]
285    fn tools_summarize_output_parsing() {
286        let toml_str = r"
287            summarize_output = true
288        ";
289        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
290        assert!(config.summarize_output);
291    }
292
293    #[test]
294    fn default_shell_config() {
295        let config = ShellConfig::default();
296        assert_eq!(config.timeout, 30);
297        assert!(config.blocked_commands.is_empty());
298        assert!(config.allowed_paths.is_empty());
299        assert!(config.allow_network);
300        assert!(!config.confirm_patterns.is_empty());
301    }
302
303    #[test]
304    fn deserialize_omitted_fields_use_defaults() {
305        let toml_str = "";
306        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
307        assert!(config.enabled);
308        assert_eq!(config.shell.timeout, 30);
309        assert!(config.shell.blocked_commands.is_empty());
310        assert!(config.shell.allow_network);
311        assert!(!config.shell.confirm_patterns.is_empty());
312        assert_eq!(config.scrape.timeout, 15);
313        assert_eq!(config.scrape.max_body_bytes, 4_194_304);
314        assert!(!config.audit.enabled);
315        assert_eq!(config.audit.destination, "stdout");
316        assert!(config.summarize_output);
317    }
318
319    #[test]
320    fn default_scrape_config() {
321        let config = ScrapeConfig::default();
322        assert_eq!(config.timeout, 15);
323        assert_eq!(config.max_body_bytes, 4_194_304);
324    }
325
326    #[test]
327    fn deserialize_scrape_config() {
328        let toml_str = r"
329            [scrape]
330            timeout = 30
331            max_body_bytes = 2097152
332        ";
333
334        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
335        assert_eq!(config.scrape.timeout, 30);
336        assert_eq!(config.scrape.max_body_bytes, 2_097_152);
337    }
338
339    #[test]
340    fn tools_config_default_includes_scrape() {
341        let config = ToolsConfig::default();
342        assert_eq!(config.scrape.timeout, 15);
343        assert_eq!(config.scrape.max_body_bytes, 4_194_304);
344    }
345
346    #[test]
347    fn deserialize_allowed_commands() {
348        let toml_str = r#"
349            [shell]
350            timeout = 30
351            allowed_commands = ["curl", "wget"]
352        "#;
353
354        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
355        assert_eq!(config.shell.allowed_commands, vec!["curl", "wget"]);
356    }
357
358    #[test]
359    fn default_allowed_commands_empty() {
360        let config = ShellConfig::default();
361        assert!(config.allowed_commands.is_empty());
362    }
363
364    #[test]
365    fn deserialize_shell_security_fields() {
366        let toml_str = r#"
367            [shell]
368            allowed_paths = ["/tmp", "/home/user"]
369            allow_network = false
370            confirm_patterns = ["rm ", "drop table"]
371        "#;
372
373        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
374        assert_eq!(config.shell.allowed_paths, vec!["/tmp", "/home/user"]);
375        assert!(!config.shell.allow_network);
376        assert_eq!(config.shell.confirm_patterns, vec!["rm ", "drop table"]);
377    }
378
379    #[test]
380    fn deserialize_audit_config() {
381        let toml_str = r#"
382            [audit]
383            enabled = true
384            destination = "/var/log/zeph-audit.log"
385        "#;
386
387        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
388        assert!(config.audit.enabled);
389        assert_eq!(config.audit.destination, "/var/log/zeph-audit.log");
390    }
391
392    #[test]
393    fn default_audit_config() {
394        let config = AuditConfig::default();
395        assert!(!config.enabled);
396        assert_eq!(config.destination, "stdout");
397    }
398
399    #[test]
400    fn permission_policy_from_legacy_fields() {
401        let config = ToolsConfig {
402            shell: ShellConfig {
403                blocked_commands: vec!["sudo".to_owned()],
404                confirm_patterns: vec!["rm ".to_owned()],
405                ..ShellConfig::default()
406            },
407            ..ToolsConfig::default()
408        };
409        let policy = config.permission_policy(AutonomyLevel::Supervised);
410        assert_eq!(
411            policy.check("bash", "sudo apt"),
412            crate::permissions::PermissionAction::Deny
413        );
414        assert_eq!(
415            policy.check("bash", "rm file"),
416            crate::permissions::PermissionAction::Ask
417        );
418    }
419
420    #[test]
421    fn permission_policy_from_explicit_config() {
422        let toml_str = r#"
423            [permissions]
424            [[permissions.bash]]
425            pattern = "*sudo*"
426            action = "deny"
427        "#;
428        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
429        let policy = config.permission_policy(AutonomyLevel::Supervised);
430        assert_eq!(
431            policy.check("bash", "sudo rm"),
432            crate::permissions::PermissionAction::Deny
433        );
434    }
435
436    #[test]
437    fn permission_policy_default_uses_legacy() {
438        let config = ToolsConfig::default();
439        assert!(config.permissions.is_none());
440        let policy = config.permission_policy(AutonomyLevel::Supervised);
441        // Default ShellConfig has confirm_patterns, so legacy rules are generated
442        assert!(!config.shell.confirm_patterns.is_empty());
443        assert!(policy.rules().contains_key("bash"));
444    }
445
446    #[test]
447    fn deserialize_overflow_config_full() {
448        let toml_str = r#"
449            [overflow]
450            threshold = 100000
451            retention_days = 14
452            dir = "/tmp/overflow"
453        "#;
454        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
455        assert_eq!(config.overflow.threshold, 100_000);
456        assert_eq!(config.overflow.retention_days, 14);
457        assert_eq!(
458            config.overflow.dir.unwrap().to_str().unwrap(),
459            "/tmp/overflow"
460        );
461    }
462
463    #[test]
464    fn deserialize_overflow_config_partial_uses_defaults() {
465        let toml_str = r"
466            [overflow]
467            threshold = 75000
468        ";
469        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
470        assert_eq!(config.overflow.threshold, 75_000);
471        assert_eq!(config.overflow.retention_days, 7);
472        assert!(config.overflow.dir.is_none());
473    }
474
475    #[test]
476    fn deserialize_overflow_config_omitted_uses_defaults() {
477        let config: ToolsConfig = toml::from_str("").unwrap();
478        assert_eq!(config.overflow.threshold, 50_000);
479        assert_eq!(config.overflow.retention_days, 7);
480        assert!(config.overflow.dir.is_none());
481    }
482}