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
68/// Top-level configuration for tool execution.
69#[derive(Debug, Deserialize, Serialize)]
70pub struct ToolsConfig {
71    #[serde(default = "default_true")]
72    pub enabled: bool,
73    #[serde(default = "default_true")]
74    pub summarize_output: bool,
75    #[serde(default)]
76    pub shell: ShellConfig,
77    #[serde(default)]
78    pub scrape: ScrapeConfig,
79    #[serde(default)]
80    pub audit: AuditConfig,
81    #[serde(default)]
82    pub permissions: Option<PermissionsConfig>,
83    #[serde(default)]
84    pub filters: crate::filter::FilterConfig,
85    #[serde(default)]
86    pub overflow: OverflowConfig,
87}
88
89impl ToolsConfig {
90    /// Build a `PermissionPolicy` from explicit config or legacy shell fields.
91    #[must_use]
92    pub fn permission_policy(&self, autonomy_level: AutonomyLevel) -> PermissionPolicy {
93        let policy = if let Some(ref perms) = self.permissions {
94            PermissionPolicy::from(perms.clone())
95        } else {
96            PermissionPolicy::from_legacy(
97                &self.shell.blocked_commands,
98                &self.shell.confirm_patterns,
99            )
100        };
101        policy.with_autonomy(autonomy_level)
102    }
103}
104
105/// Shell-specific configuration: timeout, command blocklist, and allowlist overrides.
106#[derive(Debug, Deserialize, Serialize)]
107pub struct ShellConfig {
108    #[serde(default = "default_timeout")]
109    pub timeout: u64,
110    #[serde(default)]
111    pub blocked_commands: Vec<String>,
112    #[serde(default)]
113    pub allowed_commands: Vec<String>,
114    #[serde(default)]
115    pub allowed_paths: Vec<String>,
116    #[serde(default = "default_true")]
117    pub allow_network: bool,
118    #[serde(default = "default_confirm_patterns")]
119    pub confirm_patterns: Vec<String>,
120}
121
122/// Configuration for audit logging of tool executions.
123#[derive(Debug, Deserialize, Serialize)]
124pub struct AuditConfig {
125    #[serde(default)]
126    pub enabled: bool,
127    #[serde(default = "default_audit_destination")]
128    pub destination: String,
129}
130
131impl Default for ToolsConfig {
132    fn default() -> Self {
133        Self {
134            enabled: true,
135            summarize_output: true,
136            shell: ShellConfig::default(),
137            scrape: ScrapeConfig::default(),
138            audit: AuditConfig::default(),
139            permissions: None,
140            filters: crate::filter::FilterConfig::default(),
141            overflow: OverflowConfig::default(),
142        }
143    }
144}
145
146impl Default for ShellConfig {
147    fn default() -> Self {
148        Self {
149            timeout: default_timeout(),
150            blocked_commands: Vec::new(),
151            allowed_commands: Vec::new(),
152            allowed_paths: Vec::new(),
153            allow_network: true,
154            confirm_patterns: default_confirm_patterns(),
155        }
156    }
157}
158
159impl Default for AuditConfig {
160    fn default() -> Self {
161        Self {
162            enabled: false,
163            destination: default_audit_destination(),
164        }
165    }
166}
167
168fn default_scrape_timeout() -> u64 {
169    15
170}
171
172fn default_max_body_bytes() -> usize {
173    1_048_576
174}
175
176/// Configuration for the web scrape tool.
177#[derive(Debug, Deserialize, Serialize)]
178pub struct ScrapeConfig {
179    #[serde(default = "default_scrape_timeout")]
180    pub timeout: u64,
181    #[serde(default = "default_max_body_bytes")]
182    pub max_body_bytes: usize,
183}
184
185impl Default for ScrapeConfig {
186    fn default() -> Self {
187        Self {
188            timeout: default_scrape_timeout(),
189            max_body_bytes: default_max_body_bytes(),
190        }
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    #[test]
199    fn deserialize_default_config() {
200        let toml_str = r#"
201            enabled = true
202
203            [shell]
204            timeout = 60
205            blocked_commands = ["rm -rf /", "sudo"]
206        "#;
207
208        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
209        assert!(config.enabled);
210        assert_eq!(config.shell.timeout, 60);
211        assert_eq!(config.shell.blocked_commands.len(), 2);
212        assert_eq!(config.shell.blocked_commands[0], "rm -rf /");
213        assert_eq!(config.shell.blocked_commands[1], "sudo");
214    }
215
216    #[test]
217    fn empty_blocked_commands() {
218        let toml_str = r#"
219            [shell]
220            timeout = 30
221        "#;
222
223        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
224        assert!(config.enabled);
225        assert_eq!(config.shell.timeout, 30);
226        assert!(config.shell.blocked_commands.is_empty());
227    }
228
229    #[test]
230    fn default_tools_config() {
231        let config = ToolsConfig::default();
232        assert!(config.enabled);
233        assert!(config.summarize_output);
234        assert_eq!(config.shell.timeout, 30);
235        assert!(config.shell.blocked_commands.is_empty());
236        assert!(!config.audit.enabled);
237    }
238
239    #[test]
240    fn tools_summarize_output_default_true() {
241        let config = ToolsConfig::default();
242        assert!(config.summarize_output);
243    }
244
245    #[test]
246    fn tools_summarize_output_parsing() {
247        let toml_str = r#"
248            summarize_output = true
249        "#;
250        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
251        assert!(config.summarize_output);
252    }
253
254    #[test]
255    fn default_shell_config() {
256        let config = ShellConfig::default();
257        assert_eq!(config.timeout, 30);
258        assert!(config.blocked_commands.is_empty());
259        assert!(config.allowed_paths.is_empty());
260        assert!(config.allow_network);
261        assert!(!config.confirm_patterns.is_empty());
262    }
263
264    #[test]
265    fn deserialize_omitted_fields_use_defaults() {
266        let toml_str = "";
267        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
268        assert!(config.enabled);
269        assert_eq!(config.shell.timeout, 30);
270        assert!(config.shell.blocked_commands.is_empty());
271        assert!(config.shell.allow_network);
272        assert!(!config.shell.confirm_patterns.is_empty());
273        assert_eq!(config.scrape.timeout, 15);
274        assert_eq!(config.scrape.max_body_bytes, 1_048_576);
275        assert!(!config.audit.enabled);
276        assert_eq!(config.audit.destination, "stdout");
277        assert!(config.summarize_output);
278    }
279
280    #[test]
281    fn default_scrape_config() {
282        let config = ScrapeConfig::default();
283        assert_eq!(config.timeout, 15);
284        assert_eq!(config.max_body_bytes, 1_048_576);
285    }
286
287    #[test]
288    fn deserialize_scrape_config() {
289        let toml_str = r#"
290            [scrape]
291            timeout = 30
292            max_body_bytes = 2097152
293        "#;
294
295        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
296        assert_eq!(config.scrape.timeout, 30);
297        assert_eq!(config.scrape.max_body_bytes, 2_097_152);
298    }
299
300    #[test]
301    fn tools_config_default_includes_scrape() {
302        let config = ToolsConfig::default();
303        assert_eq!(config.scrape.timeout, 15);
304        assert_eq!(config.scrape.max_body_bytes, 1_048_576);
305    }
306
307    #[test]
308    fn deserialize_allowed_commands() {
309        let toml_str = r#"
310            [shell]
311            timeout = 30
312            allowed_commands = ["curl", "wget"]
313        "#;
314
315        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
316        assert_eq!(config.shell.allowed_commands, vec!["curl", "wget"]);
317    }
318
319    #[test]
320    fn default_allowed_commands_empty() {
321        let config = ShellConfig::default();
322        assert!(config.allowed_commands.is_empty());
323    }
324
325    #[test]
326    fn deserialize_shell_security_fields() {
327        let toml_str = r#"
328            [shell]
329            allowed_paths = ["/tmp", "/home/user"]
330            allow_network = false
331            confirm_patterns = ["rm ", "drop table"]
332        "#;
333
334        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
335        assert_eq!(config.shell.allowed_paths, vec!["/tmp", "/home/user"]);
336        assert!(!config.shell.allow_network);
337        assert_eq!(config.shell.confirm_patterns, vec!["rm ", "drop table"]);
338    }
339
340    #[test]
341    fn deserialize_audit_config() {
342        let toml_str = r#"
343            [audit]
344            enabled = true
345            destination = "/var/log/zeph-audit.log"
346        "#;
347
348        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
349        assert!(config.audit.enabled);
350        assert_eq!(config.audit.destination, "/var/log/zeph-audit.log");
351    }
352
353    #[test]
354    fn default_audit_config() {
355        let config = AuditConfig::default();
356        assert!(!config.enabled);
357        assert_eq!(config.destination, "stdout");
358    }
359
360    #[test]
361    fn permission_policy_from_legacy_fields() {
362        let config = ToolsConfig {
363            shell: ShellConfig {
364                blocked_commands: vec!["sudo".to_owned()],
365                confirm_patterns: vec!["rm ".to_owned()],
366                ..ShellConfig::default()
367            },
368            ..ToolsConfig::default()
369        };
370        let policy = config.permission_policy(AutonomyLevel::Supervised);
371        assert_eq!(
372            policy.check("bash", "sudo apt"),
373            crate::permissions::PermissionAction::Deny
374        );
375        assert_eq!(
376            policy.check("bash", "rm file"),
377            crate::permissions::PermissionAction::Ask
378        );
379    }
380
381    #[test]
382    fn permission_policy_from_explicit_config() {
383        let toml_str = r#"
384            [permissions]
385            [[permissions.bash]]
386            pattern = "*sudo*"
387            action = "deny"
388        "#;
389        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
390        let policy = config.permission_policy(AutonomyLevel::Supervised);
391        assert_eq!(
392            policy.check("bash", "sudo rm"),
393            crate::permissions::PermissionAction::Deny
394        );
395    }
396
397    #[test]
398    fn permission_policy_default_uses_legacy() {
399        let config = ToolsConfig::default();
400        assert!(config.permissions.is_none());
401        let policy = config.permission_policy(AutonomyLevel::Supervised);
402        // Default ShellConfig has confirm_patterns, so legacy rules are generated
403        assert!(!config.shell.confirm_patterns.is_empty());
404        assert!(policy.rules().contains_key("bash"));
405    }
406
407    #[test]
408    fn deserialize_overflow_config_full() {
409        let toml_str = r#"
410            [overflow]
411            threshold = 100000
412            retention_days = 14
413            dir = "/tmp/overflow"
414        "#;
415        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
416        assert_eq!(config.overflow.threshold, 100_000);
417        assert_eq!(config.overflow.retention_days, 14);
418        assert_eq!(
419            config.overflow.dir.unwrap().to_str().unwrap(),
420            "/tmp/overflow"
421        );
422    }
423
424    #[test]
425    fn deserialize_overflow_config_partial_uses_defaults() {
426        let toml_str = r#"
427            [overflow]
428            threshold = 75000
429        "#;
430        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
431        assert_eq!(config.overflow.threshold, 75_000);
432        assert_eq!(config.overflow.retention_days, 7);
433        assert!(config.overflow.dir.is_none());
434    }
435
436    #[test]
437    fn deserialize_overflow_config_omitted_uses_defaults() {
438        let config: ToolsConfig = toml::from_str("").unwrap();
439        assert_eq!(config.overflow.threshold, 50_000);
440        assert_eq!(config.overflow.retention_days, 7);
441        assert!(config.overflow.dir.is_none());
442    }
443}