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#[cfg(feature = "policy-enforcer")]
8use crate::policy::PolicyConfig;
9
10fn default_true() -> bool {
11    true
12}
13
14fn default_timeout() -> u64 {
15    30
16}
17
18fn default_cache_ttl_secs() -> u64 {
19    300
20}
21
22fn default_confirm_patterns() -> Vec<String> {
23    vec![
24        "rm ".into(),
25        "git push -f".into(),
26        "git push --force".into(),
27        "drop table".into(),
28        "drop database".into(),
29        "truncate ".into(),
30        "$(".into(),
31        "`".into(),
32        "<(".into(),
33        ">(".into(),
34        "<<<".into(),
35        "eval ".into(),
36    ]
37}
38
39fn default_audit_destination() -> String {
40    "stdout".into()
41}
42
43fn default_overflow_threshold() -> usize {
44    50_000
45}
46
47fn default_retention_days() -> u64 {
48    7
49}
50
51fn default_max_overflow_bytes() -> usize {
52    10 * 1024 * 1024 // 10 MiB
53}
54
55/// Configuration for large tool response offload to `SQLite`.
56#[derive(Debug, Clone, Deserialize, Serialize)]
57pub struct OverflowConfig {
58    #[serde(default = "default_overflow_threshold")]
59    pub threshold: usize,
60    #[serde(default = "default_retention_days")]
61    pub retention_days: u64,
62    /// Maximum bytes per overflow entry. `0` means unlimited.
63    #[serde(default = "default_max_overflow_bytes")]
64    pub max_overflow_bytes: usize,
65}
66
67impl Default for OverflowConfig {
68    fn default() -> Self {
69        Self {
70            threshold: default_overflow_threshold(),
71            retention_days: default_retention_days(),
72            max_overflow_bytes: default_max_overflow_bytes(),
73        }
74    }
75}
76
77fn default_anomaly_window() -> usize {
78    10
79}
80
81fn default_anomaly_error_threshold() -> f64 {
82    0.5
83}
84
85fn default_anomaly_critical_threshold() -> f64 {
86    0.8
87}
88
89/// Configuration for the sliding-window anomaly detector.
90#[derive(Debug, Clone, Deserialize, Serialize)]
91pub struct AnomalyConfig {
92    #[serde(default = "default_true")]
93    pub enabled: bool,
94    #[serde(default = "default_anomaly_window")]
95    pub window_size: usize,
96    #[serde(default = "default_anomaly_error_threshold")]
97    pub error_threshold: f64,
98    #[serde(default = "default_anomaly_critical_threshold")]
99    pub critical_threshold: f64,
100    /// Emit a WARN log when a reasoning-enhanced model (o1, o3, `QwQ`, etc.) produces
101    /// a quality failure (`ToolNotFound`, `InvalidParameters`, `TypeMismatch`). Default: `true`.
102    ///
103    /// Based on arXiv:2510.22977 — CoT/RL reasoning amplifies tool hallucination.
104    #[serde(default = "default_true")]
105    pub reasoning_model_warning: bool,
106}
107
108impl Default for AnomalyConfig {
109    fn default() -> Self {
110        Self {
111            enabled: true,
112            window_size: default_anomaly_window(),
113            error_threshold: default_anomaly_error_threshold(),
114            critical_threshold: default_anomaly_critical_threshold(),
115            reasoning_model_warning: true,
116        }
117    }
118}
119
120/// Configuration for the tool result cache.
121#[derive(Debug, Clone, Deserialize, Serialize)]
122pub struct ResultCacheConfig {
123    /// Whether caching is enabled. Default: `true`.
124    #[serde(default = "default_true")]
125    pub enabled: bool,
126    /// Time-to-live in seconds. `0` means entries never expire. Default: `300`.
127    #[serde(default = "default_cache_ttl_secs")]
128    pub ttl_secs: u64,
129}
130
131impl Default for ResultCacheConfig {
132    fn default() -> Self {
133        Self {
134            enabled: true,
135            ttl_secs: default_cache_ttl_secs(),
136        }
137    }
138}
139
140fn default_tafc_complexity_threshold() -> f64 {
141    0.6
142}
143
144/// Configuration for Think-Augmented Function Calling (TAFC).
145#[derive(Debug, Clone, Deserialize, Serialize)]
146pub struct TafcConfig {
147    /// Enable TAFC schema augmentation (default: false).
148    #[serde(default)]
149    pub enabled: bool,
150    /// Complexity threshold tau in [0.0, 1.0]; tools with complexity >= tau are augmented.
151    /// Default: 0.6
152    #[serde(default = "default_tafc_complexity_threshold")]
153    pub complexity_threshold: f64,
154}
155
156impl Default for TafcConfig {
157    fn default() -> Self {
158        Self {
159            enabled: false,
160            complexity_threshold: default_tafc_complexity_threshold(),
161        }
162    }
163}
164
165impl TafcConfig {
166    /// Validate and clamp `complexity_threshold` to \[0.0, 1.0\]. Reset NaN/Infinity to 0.6.
167    #[must_use]
168    pub fn validated(mut self) -> Self {
169        if self.complexity_threshold.is_finite() {
170            self.complexity_threshold = self.complexity_threshold.clamp(0.0, 1.0);
171        } else {
172            self.complexity_threshold = 0.6;
173        }
174        self
175    }
176}
177
178fn default_boost_per_dep() -> f32 {
179    0.15
180}
181
182fn default_max_total_boost() -> f32 {
183    0.2
184}
185
186/// Dependency specification for a single tool.
187#[derive(Debug, Clone, Default, Deserialize, Serialize)]
188pub struct ToolDependency {
189    /// Hard prerequisites: tool is hidden until ALL of these have completed successfully.
190    #[serde(default, skip_serializing_if = "Vec::is_empty")]
191    pub requires: Vec<String>,
192    /// Soft prerequisites: tool gets a similarity boost when these have completed.
193    #[serde(default, skip_serializing_if = "Vec::is_empty")]
194    pub prefers: Vec<String>,
195}
196
197/// Configuration for the tool dependency graph feature.
198#[derive(Debug, Clone, Deserialize, Serialize)]
199pub struct DependencyConfig {
200    /// Whether dependency gating is enabled. Default: false.
201    #[serde(default)]
202    pub enabled: bool,
203    /// Similarity boost added per satisfied `prefers` dependency. Default: 0.15.
204    #[serde(default = "default_boost_per_dep")]
205    pub boost_per_dep: f32,
206    /// Maximum total boost applied regardless of how many `prefers` deps are met. Default: 0.2.
207    #[serde(default = "default_max_total_boost")]
208    pub max_total_boost: f32,
209    /// Per-tool dependency rules. Key is `tool_id`.
210    #[serde(default)]
211    pub rules: std::collections::HashMap<String, ToolDependency>,
212}
213
214impl Default for DependencyConfig {
215    fn default() -> Self {
216        Self {
217            enabled: false,
218            boost_per_dep: default_boost_per_dep(),
219            max_total_boost: default_max_total_boost(),
220            rules: std::collections::HashMap::new(),
221        }
222    }
223}
224
225fn default_retry_max_attempts() -> usize {
226    2
227}
228
229fn default_retry_base_ms() -> u64 {
230    500
231}
232
233fn default_retry_max_ms() -> u64 {
234    5_000
235}
236
237fn default_retry_budget_secs() -> u64 {
238    30
239}
240
241/// Configuration for tool error retry behavior.
242#[derive(Debug, Clone, Deserialize, Serialize)]
243pub struct RetryConfig {
244    /// Maximum retry attempts for transient errors per tool call. 0 = disabled.
245    #[serde(default = "default_retry_max_attempts")]
246    pub max_attempts: usize,
247    /// Base delay (ms) for exponential backoff.
248    #[serde(default = "default_retry_base_ms")]
249    pub base_ms: u64,
250    /// Maximum delay cap (ms) for exponential backoff.
251    #[serde(default = "default_retry_max_ms")]
252    pub max_ms: u64,
253    /// Maximum wall-clock time (seconds) for all retries of a single tool call. 0 = unlimited.
254    #[serde(default = "default_retry_budget_secs")]
255    pub budget_secs: u64,
256    /// Provider name from `[[llm.providers]]` for LLM-based parameter reformatting on
257    /// `InvalidParameters`/`TypeMismatch` errors. Empty string = disabled.
258    #[serde(default)]
259    pub parameter_reformat_provider: String,
260}
261
262impl Default for RetryConfig {
263    fn default() -> Self {
264        Self {
265            max_attempts: default_retry_max_attempts(),
266            base_ms: default_retry_base_ms(),
267            max_ms: default_retry_max_ms(),
268            budget_secs: default_retry_budget_secs(),
269            parameter_reformat_provider: String::new(),
270        }
271    }
272}
273
274/// Top-level configuration for tool execution.
275#[derive(Debug, Deserialize, Serialize)]
276pub struct ToolsConfig {
277    #[serde(default = "default_true")]
278    pub enabled: bool,
279    #[serde(default = "default_true")]
280    pub summarize_output: bool,
281    #[serde(default)]
282    pub shell: ShellConfig,
283    #[serde(default)]
284    pub scrape: ScrapeConfig,
285    #[serde(default)]
286    pub audit: AuditConfig,
287    #[serde(default)]
288    pub permissions: Option<PermissionsConfig>,
289    #[serde(default)]
290    pub filters: crate::filter::FilterConfig,
291    #[serde(default)]
292    pub overflow: OverflowConfig,
293    #[serde(default)]
294    pub anomaly: AnomalyConfig,
295    #[serde(default)]
296    pub result_cache: ResultCacheConfig,
297    #[serde(default)]
298    pub tafc: TafcConfig,
299    #[serde(default)]
300    pub dependencies: DependencyConfig,
301    #[serde(default)]
302    pub retry: RetryConfig,
303    /// Declarative policy compiler for tool call authorization.
304    #[cfg(feature = "policy-enforcer")]
305    #[serde(default)]
306    pub policy: PolicyConfig,
307}
308
309impl ToolsConfig {
310    /// Build a `PermissionPolicy` from explicit config or legacy shell fields.
311    #[must_use]
312    pub fn permission_policy(&self, autonomy_level: AutonomyLevel) -> PermissionPolicy {
313        let policy = if let Some(ref perms) = self.permissions {
314            PermissionPolicy::from(perms.clone())
315        } else {
316            PermissionPolicy::from_legacy(
317                &self.shell.blocked_commands,
318                &self.shell.confirm_patterns,
319            )
320        };
321        policy.with_autonomy(autonomy_level)
322    }
323}
324
325/// Shell-specific configuration: timeout, command blocklist, and allowlist overrides.
326#[derive(Debug, Deserialize, Serialize)]
327pub struct ShellConfig {
328    #[serde(default = "default_timeout")]
329    pub timeout: u64,
330    #[serde(default)]
331    pub blocked_commands: Vec<String>,
332    #[serde(default)]
333    pub allowed_commands: Vec<String>,
334    #[serde(default)]
335    pub allowed_paths: Vec<String>,
336    #[serde(default = "default_true")]
337    pub allow_network: bool,
338    #[serde(default = "default_confirm_patterns")]
339    pub confirm_patterns: Vec<String>,
340}
341
342/// Configuration for audit logging of tool executions.
343#[derive(Debug, Deserialize, Serialize)]
344pub struct AuditConfig {
345    #[serde(default = "default_true")]
346    pub enabled: bool,
347    #[serde(default = "default_audit_destination")]
348    pub destination: String,
349}
350
351impl Default for ToolsConfig {
352    fn default() -> Self {
353        Self {
354            enabled: true,
355            summarize_output: true,
356            shell: ShellConfig::default(),
357            scrape: ScrapeConfig::default(),
358            audit: AuditConfig::default(),
359            permissions: None,
360            filters: crate::filter::FilterConfig::default(),
361            overflow: OverflowConfig::default(),
362            anomaly: AnomalyConfig::default(),
363            result_cache: ResultCacheConfig::default(),
364            tafc: TafcConfig::default(),
365            dependencies: DependencyConfig::default(),
366            retry: RetryConfig::default(),
367            #[cfg(feature = "policy-enforcer")]
368            policy: PolicyConfig::default(),
369        }
370    }
371}
372
373impl Default for ShellConfig {
374    fn default() -> Self {
375        Self {
376            timeout: default_timeout(),
377            blocked_commands: Vec::new(),
378            allowed_commands: Vec::new(),
379            allowed_paths: Vec::new(),
380            allow_network: true,
381            confirm_patterns: default_confirm_patterns(),
382        }
383    }
384}
385
386impl Default for AuditConfig {
387    fn default() -> Self {
388        Self {
389            enabled: true,
390            destination: default_audit_destination(),
391        }
392    }
393}
394
395fn default_scrape_timeout() -> u64 {
396    15
397}
398
399fn default_max_body_bytes() -> usize {
400    4_194_304
401}
402
403/// Configuration for the web scrape tool.
404#[derive(Debug, Deserialize, Serialize)]
405pub struct ScrapeConfig {
406    #[serde(default = "default_scrape_timeout")]
407    pub timeout: u64,
408    #[serde(default = "default_max_body_bytes")]
409    pub max_body_bytes: usize,
410}
411
412impl Default for ScrapeConfig {
413    fn default() -> Self {
414        Self {
415            timeout: default_scrape_timeout(),
416            max_body_bytes: default_max_body_bytes(),
417        }
418    }
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424
425    #[test]
426    fn deserialize_default_config() {
427        let toml_str = r#"
428            enabled = true
429
430            [shell]
431            timeout = 60
432            blocked_commands = ["rm -rf /", "sudo"]
433        "#;
434
435        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
436        assert!(config.enabled);
437        assert_eq!(config.shell.timeout, 60);
438        assert_eq!(config.shell.blocked_commands.len(), 2);
439        assert_eq!(config.shell.blocked_commands[0], "rm -rf /");
440        assert_eq!(config.shell.blocked_commands[1], "sudo");
441    }
442
443    #[test]
444    fn empty_blocked_commands() {
445        let toml_str = r"
446            [shell]
447            timeout = 30
448        ";
449
450        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
451        assert!(config.enabled);
452        assert_eq!(config.shell.timeout, 30);
453        assert!(config.shell.blocked_commands.is_empty());
454    }
455
456    #[test]
457    fn default_tools_config() {
458        let config = ToolsConfig::default();
459        assert!(config.enabled);
460        assert!(config.summarize_output);
461        assert_eq!(config.shell.timeout, 30);
462        assert!(config.shell.blocked_commands.is_empty());
463        assert!(config.audit.enabled);
464    }
465
466    #[test]
467    fn tools_summarize_output_default_true() {
468        let config = ToolsConfig::default();
469        assert!(config.summarize_output);
470    }
471
472    #[test]
473    fn tools_summarize_output_parsing() {
474        let toml_str = r"
475            summarize_output = true
476        ";
477        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
478        assert!(config.summarize_output);
479    }
480
481    #[test]
482    fn default_shell_config() {
483        let config = ShellConfig::default();
484        assert_eq!(config.timeout, 30);
485        assert!(config.blocked_commands.is_empty());
486        assert!(config.allowed_paths.is_empty());
487        assert!(config.allow_network);
488        assert!(!config.confirm_patterns.is_empty());
489    }
490
491    #[test]
492    fn deserialize_omitted_fields_use_defaults() {
493        let toml_str = "";
494        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
495        assert!(config.enabled);
496        assert_eq!(config.shell.timeout, 30);
497        assert!(config.shell.blocked_commands.is_empty());
498        assert!(config.shell.allow_network);
499        assert!(!config.shell.confirm_patterns.is_empty());
500        assert_eq!(config.scrape.timeout, 15);
501        assert_eq!(config.scrape.max_body_bytes, 4_194_304);
502        assert!(config.audit.enabled);
503        assert_eq!(config.audit.destination, "stdout");
504        assert!(config.summarize_output);
505    }
506
507    #[test]
508    fn default_scrape_config() {
509        let config = ScrapeConfig::default();
510        assert_eq!(config.timeout, 15);
511        assert_eq!(config.max_body_bytes, 4_194_304);
512    }
513
514    #[test]
515    fn deserialize_scrape_config() {
516        let toml_str = r"
517            [scrape]
518            timeout = 30
519            max_body_bytes = 2097152
520        ";
521
522        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
523        assert_eq!(config.scrape.timeout, 30);
524        assert_eq!(config.scrape.max_body_bytes, 2_097_152);
525    }
526
527    #[test]
528    fn tools_config_default_includes_scrape() {
529        let config = ToolsConfig::default();
530        assert_eq!(config.scrape.timeout, 15);
531        assert_eq!(config.scrape.max_body_bytes, 4_194_304);
532    }
533
534    #[test]
535    fn deserialize_allowed_commands() {
536        let toml_str = r#"
537            [shell]
538            timeout = 30
539            allowed_commands = ["curl", "wget"]
540        "#;
541
542        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
543        assert_eq!(config.shell.allowed_commands, vec!["curl", "wget"]);
544    }
545
546    #[test]
547    fn default_allowed_commands_empty() {
548        let config = ShellConfig::default();
549        assert!(config.allowed_commands.is_empty());
550    }
551
552    #[test]
553    fn deserialize_shell_security_fields() {
554        let toml_str = r#"
555            [shell]
556            allowed_paths = ["/tmp", "/home/user"]
557            allow_network = false
558            confirm_patterns = ["rm ", "drop table"]
559        "#;
560
561        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
562        assert_eq!(config.shell.allowed_paths, vec!["/tmp", "/home/user"]);
563        assert!(!config.shell.allow_network);
564        assert_eq!(config.shell.confirm_patterns, vec!["rm ", "drop table"]);
565    }
566
567    #[test]
568    fn deserialize_audit_config() {
569        let toml_str = r#"
570            [audit]
571            enabled = true
572            destination = "/var/log/zeph-audit.log"
573        "#;
574
575        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
576        assert!(config.audit.enabled);
577        assert_eq!(config.audit.destination, "/var/log/zeph-audit.log");
578    }
579
580    #[test]
581    fn default_audit_config() {
582        let config = AuditConfig::default();
583        assert!(config.enabled);
584        assert_eq!(config.destination, "stdout");
585    }
586
587    #[test]
588    fn permission_policy_from_legacy_fields() {
589        let config = ToolsConfig {
590            shell: ShellConfig {
591                blocked_commands: vec!["sudo".to_owned()],
592                confirm_patterns: vec!["rm ".to_owned()],
593                ..ShellConfig::default()
594            },
595            ..ToolsConfig::default()
596        };
597        let policy = config.permission_policy(AutonomyLevel::Supervised);
598        assert_eq!(
599            policy.check("bash", "sudo apt"),
600            crate::permissions::PermissionAction::Deny
601        );
602        assert_eq!(
603            policy.check("bash", "rm file"),
604            crate::permissions::PermissionAction::Ask
605        );
606    }
607
608    #[test]
609    fn permission_policy_from_explicit_config() {
610        let toml_str = r#"
611            [permissions]
612            [[permissions.bash]]
613            pattern = "*sudo*"
614            action = "deny"
615        "#;
616        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
617        let policy = config.permission_policy(AutonomyLevel::Supervised);
618        assert_eq!(
619            policy.check("bash", "sudo rm"),
620            crate::permissions::PermissionAction::Deny
621        );
622    }
623
624    #[test]
625    fn permission_policy_default_uses_legacy() {
626        let config = ToolsConfig::default();
627        assert!(config.permissions.is_none());
628        let policy = config.permission_policy(AutonomyLevel::Supervised);
629        // Default ShellConfig has confirm_patterns, so legacy rules are generated
630        assert!(!config.shell.confirm_patterns.is_empty());
631        assert!(policy.rules().contains_key("bash"));
632    }
633
634    #[test]
635    fn deserialize_overflow_config_full() {
636        let toml_str = r"
637            [overflow]
638            threshold = 100000
639            retention_days = 14
640            max_overflow_bytes = 5242880
641        ";
642        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
643        assert_eq!(config.overflow.threshold, 100_000);
644        assert_eq!(config.overflow.retention_days, 14);
645        assert_eq!(config.overflow.max_overflow_bytes, 5_242_880);
646    }
647
648    #[test]
649    fn deserialize_overflow_config_unknown_dir_field_is_ignored() {
650        // Old configs with `dir = "..."` must not fail deserialization.
651        let toml_str = r#"
652            [overflow]
653            threshold = 75000
654            dir = "/tmp/overflow"
655        "#;
656        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
657        assert_eq!(config.overflow.threshold, 75_000);
658    }
659
660    #[test]
661    fn deserialize_overflow_config_partial_uses_defaults() {
662        let toml_str = r"
663            [overflow]
664            threshold = 75000
665        ";
666        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
667        assert_eq!(config.overflow.threshold, 75_000);
668        assert_eq!(config.overflow.retention_days, 7);
669    }
670
671    #[test]
672    fn deserialize_overflow_config_omitted_uses_defaults() {
673        let config: ToolsConfig = toml::from_str("").unwrap();
674        assert_eq!(config.overflow.threshold, 50_000);
675        assert_eq!(config.overflow.retention_days, 7);
676        assert_eq!(config.overflow.max_overflow_bytes, 10 * 1024 * 1024);
677    }
678
679    #[test]
680    fn result_cache_config_defaults() {
681        let config = ResultCacheConfig::default();
682        assert!(config.enabled);
683        assert_eq!(config.ttl_secs, 300);
684    }
685
686    #[test]
687    fn deserialize_result_cache_config() {
688        let toml_str = r"
689            [result_cache]
690            enabled = false
691            ttl_secs = 60
692        ";
693        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
694        assert!(!config.result_cache.enabled);
695        assert_eq!(config.result_cache.ttl_secs, 60);
696    }
697
698    #[test]
699    fn result_cache_omitted_uses_defaults() {
700        let config: ToolsConfig = toml::from_str("").unwrap();
701        assert!(config.result_cache.enabled);
702        assert_eq!(config.result_cache.ttl_secs, 300);
703    }
704
705    #[test]
706    fn result_cache_ttl_zero_is_valid() {
707        let toml_str = r"
708            [result_cache]
709            ttl_secs = 0
710        ";
711        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
712        assert_eq!(config.result_cache.ttl_secs, 0);
713    }
714}