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