Skip to main content

zeph_config/
tools.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Pure-data tool configuration types.
5//!
6//! Contains all TOML-deserializable configuration structs for tool execution. Runtime
7//! types (executors, permission policy enforcement) remain in `zeph-tools`. That crate
8//! re-exports the types here so existing import paths continue to resolve.
9
10use std::collections::HashMap;
11use std::path::PathBuf;
12
13use serde::{Deserialize, Serialize};
14
15use crate::providers::ProviderName;
16use zeph_common::SkillTrustLevel;
17
18// ── Permissions ──────────────────────────────────────────────────────────────
19
20/// Tool access level controlling agent autonomy.
21#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
22#[serde(rename_all = "lowercase")]
23pub enum AutonomyLevel {
24    /// Read-only tools: `read`, `find_path`, `grep`, `list_directory`, `web_scrape`, `fetch`
25    ReadOnly,
26    /// Default: rule-based permissions with confirmations.
27    #[default]
28    Supervised,
29    /// All tools allowed, no confirmations.
30    Full,
31}
32
33/// Action a permission rule resolves to.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
35#[serde(rename_all = "lowercase")]
36pub enum PermissionAction {
37    /// Allow the tool call unconditionally.
38    Allow,
39    /// Prompt the user before allowing.
40    Ask,
41    /// Deny the tool call.
42    Deny,
43}
44
45/// Single permission rule: glob `pattern` + action.
46#[derive(Debug, Clone, Deserialize, Serialize)]
47pub struct PermissionRule {
48    /// Glob pattern matched against the tool input string.
49    pub pattern: String,
50    /// Action to take when the pattern matches.
51    pub action: PermissionAction,
52}
53
54/// TOML-deserializable permissions config section.
55#[derive(Debug, Clone, Deserialize, Serialize, Default)]
56pub struct PermissionsConfig {
57    /// Per-tool permission rules. Key is `tool_id`.
58    #[serde(flatten)]
59    pub tools: HashMap<String, Vec<PermissionRule>>,
60}
61
62// ── Verifiers ────────────────────────────────────────────────────────────────
63
64fn default_true() -> bool {
65    true
66}
67
68fn default_shell_tools() -> Vec<String> {
69    vec![
70        "bash".to_string(),
71        "shell".to_string(),
72        "terminal".to_string(),
73    ]
74}
75
76fn default_guarded_tools() -> Vec<String> {
77    vec!["fetch".to_string(), "web_scrape".to_string()]
78}
79
80/// Configuration for the destructive command verifier.
81#[derive(Debug, Clone, Deserialize, Serialize)]
82pub struct DestructiveVerifierConfig {
83    /// Enable the verifier. Default: `true`.
84    #[serde(default = "default_true")]
85    pub enabled: bool,
86    /// Explicit path prefixes under which destructive commands are permitted.
87    #[serde(default)]
88    pub allowed_paths: Vec<String>,
89    /// Additional command patterns to treat as destructive (substring match).
90    #[serde(default)]
91    pub extra_patterns: Vec<String>,
92    /// Tool names to treat as shell executors (case-insensitive).
93    #[serde(default = "default_shell_tools")]
94    pub shell_tools: Vec<String>,
95}
96
97impl Default for DestructiveVerifierConfig {
98    fn default() -> Self {
99        Self {
100            enabled: true,
101            allowed_paths: Vec::new(),
102            extra_patterns: Vec::new(),
103            shell_tools: default_shell_tools(),
104        }
105    }
106}
107
108/// Configuration for the injection pattern verifier.
109#[derive(Debug, Clone, Deserialize, Serialize)]
110pub struct InjectionVerifierConfig {
111    /// Enable the verifier. Default: `true`.
112    #[serde(default = "default_true")]
113    pub enabled: bool,
114    /// Additional injection patterns to block (regex strings).
115    #[serde(default)]
116    pub extra_patterns: Vec<String>,
117    /// URLs explicitly permitted even if they match SSRF patterns.
118    #[serde(default)]
119    pub allowlisted_urls: Vec<String>,
120}
121
122impl Default for InjectionVerifierConfig {
123    fn default() -> Self {
124        Self {
125            enabled: true,
126            extra_patterns: Vec::new(),
127            allowlisted_urls: Vec::new(),
128        }
129    }
130}
131
132/// Configuration for the URL grounding verifier.
133#[derive(Debug, Clone, Deserialize, Serialize)]
134pub struct UrlGroundingVerifierConfig {
135    /// Enable the verifier. Default: `true`.
136    #[serde(default = "default_true")]
137    pub enabled: bool,
138    /// Tool IDs subject to URL grounding checks.
139    #[serde(default = "default_guarded_tools")]
140    pub guarded_tools: Vec<String>,
141}
142
143impl Default for UrlGroundingVerifierConfig {
144    fn default() -> Self {
145        Self {
146            enabled: true,
147            guarded_tools: default_guarded_tools(),
148        }
149    }
150}
151
152/// Configuration for the firewall verifier.
153#[derive(Debug, Clone, Deserialize, Serialize)]
154pub struct FirewallVerifierConfig {
155    /// Enable the verifier. Default: `true`.
156    #[serde(default = "default_true")]
157    pub enabled: bool,
158    /// Glob patterns for additional paths to block.
159    #[serde(default)]
160    pub blocked_paths: Vec<String>,
161    /// Additional environment variable names to block from tool arguments.
162    #[serde(default)]
163    pub blocked_env_vars: Vec<String>,
164    /// Tool IDs exempt from firewall scanning.
165    #[serde(default)]
166    pub exempt_tools: Vec<String>,
167}
168
169impl Default for FirewallVerifierConfig {
170    fn default() -> Self {
171        Self {
172            enabled: true,
173            blocked_paths: Vec::new(),
174            blocked_env_vars: Vec::new(),
175            exempt_tools: Vec::new(),
176        }
177    }
178}
179
180/// Top-level configuration for all pre-execution verifiers.
181#[derive(Debug, Clone, Deserialize, Serialize)]
182pub struct PreExecutionVerifierConfig {
183    /// Enable all verifiers globally. Default: `true`.
184    #[serde(default = "default_true")]
185    pub enabled: bool,
186    /// Destructive command verifier settings.
187    #[serde(default)]
188    pub destructive_commands: DestructiveVerifierConfig,
189    /// Injection pattern verifier settings.
190    #[serde(default)]
191    pub injection_patterns: InjectionVerifierConfig,
192    /// URL grounding verifier settings.
193    #[serde(default)]
194    pub url_grounding: UrlGroundingVerifierConfig,
195    /// Firewall verifier settings.
196    #[serde(default)]
197    pub firewall: FirewallVerifierConfig,
198}
199
200impl Default for PreExecutionVerifierConfig {
201    fn default() -> Self {
202        Self {
203            enabled: true,
204            destructive_commands: DestructiveVerifierConfig::default(),
205            injection_patterns: InjectionVerifierConfig::default(),
206            url_grounding: UrlGroundingVerifierConfig::default(),
207            firewall: FirewallVerifierConfig::default(),
208        }
209    }
210}
211
212// ── Policy ───────────────────────────────────────────────────────────────────
213
214/// Effect applied when a policy rule matches.
215#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
216#[serde(rename_all = "snake_case")]
217pub enum PolicyEffect {
218    /// Allow the tool call.
219    Allow,
220    /// Deny the tool call.
221    Deny,
222}
223
224/// Default effect when no policy rule matches.
225#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
226#[serde(rename_all = "lowercase")]
227pub enum DefaultEffect {
228    /// Allow the call when no rule matches.
229    Allow,
230    /// Deny the call when no rule matches (default, fail-closed).
231    #[default]
232    Deny,
233}
234
235fn default_deny() -> DefaultEffect {
236    DefaultEffect::Deny
237}
238
239/// TOML-deserializable policy configuration.
240#[derive(Debug, Clone, Deserialize, Serialize, Default)]
241pub struct PolicyConfig {
242    /// Whether to enforce policy rules. When false, all calls are allowed.
243    #[serde(default)]
244    pub enabled: bool,
245    /// Fallback effect when no rule matches.
246    #[serde(default = "default_deny")]
247    pub default_effect: DefaultEffect,
248    /// Inline policy rules.
249    #[serde(default)]
250    pub rules: Vec<PolicyRuleConfig>,
251    /// Optional external policy file (TOML). When set, overrides inline rules.
252    pub policy_file: Option<String>,
253}
254
255/// A single policy rule as read from TOML.
256#[derive(Debug, Clone, Deserialize, Serialize)]
257pub struct PolicyRuleConfig {
258    /// Effect when the rule matches.
259    pub effect: PolicyEffect,
260    /// Glob pattern matching the tool id. Required.
261    pub tool: String,
262    /// Path globs matched against path-like params. Rule fires if ANY path matches.
263    #[serde(default)]
264    pub paths: Vec<String>,
265    /// Env var names that must all be present in the policy context.
266    #[serde(default)]
267    pub env: Vec<String>,
268    /// Minimum required trust level (rule fires only when context trust <= threshold).
269    pub trust_level: Option<SkillTrustLevel>,
270    /// Regex matched against individual string param values.
271    pub args_match: Option<String>,
272    /// Named capabilities associated with this rule.
273    #[serde(default)]
274    pub capabilities: Vec<String>,
275}
276
277// ── Sandbox ──────────────────────────────────────────────────────────────────
278
279/// Baseline restriction profile for the OS-level sandbox.
280#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
281#[serde(rename_all = "kebab-case")]
282pub enum SandboxProfile {
283    /// Read-only to `allow_read` paths, no writes, no network.
284    ReadOnly,
285    /// Read/write to configured paths; network egress blocked.
286    #[default]
287    Workspace,
288    /// Workspace-level filesystem access plus unrestricted network egress.
289    #[serde(rename = "network-allow-all", alias = "network")]
290    NetworkAllowAll,
291    /// Sandbox disabled. The subprocess inherits the parent's full capabilities.
292    Off,
293}
294
295fn default_sandbox_profile() -> SandboxProfile {
296    SandboxProfile::Workspace
297}
298
299/// Backend used to enforce OS-level sandboxing.
300///
301/// Serialises with `kebab-case` names so TOML values match the original string convention
302/// (`"auto"`, `"seatbelt"`, `"landlock-bwrap"`, `"noop"`).
303#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
304#[serde(rename_all = "kebab-case")]
305pub enum SandboxBackend {
306    /// Automatically select the best available backend for the current OS.
307    #[default]
308    Auto,
309    /// macOS `sandbox-exec` (Seatbelt) profile.
310    Seatbelt,
311    /// Linux Landlock + bubblewrap combination.
312    LandlockBwrap,
313    /// Disable sandboxing (testing / unsupported platforms).
314    Noop,
315}
316
317/// OS-level subprocess sandbox configuration (`[tools.sandbox]` TOML section).
318#[derive(Debug, Clone, Deserialize, Serialize)]
319pub struct SandboxConfig {
320    /// Enable OS-level sandbox. Default: `false`.
321    #[serde(default)]
322    pub enabled: bool,
323    /// Enforcement profile controlling the baseline restrictions.
324    #[serde(default = "default_sandbox_profile")]
325    pub profile: SandboxProfile,
326    /// Additional paths granted read access.
327    #[serde(default)]
328    pub allow_read: Vec<PathBuf>,
329    /// Additional paths granted write access.
330    #[serde(default)]
331    pub allow_write: Vec<PathBuf>,
332    /// When `true`, sandbox initialization failure aborts startup (fail-closed). Default: `true`.
333    #[serde(default = "default_true")]
334    pub strict: bool,
335    /// OS backend used to enforce sandboxing.
336    ///
337    /// Accepts `"auto"`, `"seatbelt"`, `"landlock-bwrap"`, or `"noop"` in TOML.
338    #[serde(default)]
339    pub backend: SandboxBackend,
340    /// Hostnames denied network egress from sandboxed subprocesses.
341    #[serde(default)]
342    pub denied_domains: Vec<String>,
343    /// When `true`, failure to activate an effective OS sandbox aborts startup.
344    #[serde(default)]
345    pub fail_if_unavailable: bool,
346}
347
348impl Default for SandboxConfig {
349    fn default() -> Self {
350        Self {
351            enabled: false,
352            profile: default_sandbox_profile(),
353            allow_read: Vec::new(),
354            allow_write: Vec::new(),
355            strict: true,
356            backend: SandboxBackend::Auto,
357            denied_domains: Vec::new(),
358            fail_if_unavailable: false,
359        }
360    }
361}
362
363// ── Output filter config ─────────────────────────────────────────────────────
364
365/// Configuration for tool output security filter.
366#[derive(Debug, Clone, Deserialize, Serialize)]
367pub struct SecurityFilterConfig {
368    /// Enable security filtering. Default: `true`.
369    #[serde(default = "default_true")]
370    pub enabled: bool,
371    /// Additional regex patterns to block in tool output.
372    #[serde(default)]
373    pub extra_patterns: Vec<String>,
374}
375
376impl Default for SecurityFilterConfig {
377    fn default() -> Self {
378        Self {
379            enabled: true,
380            extra_patterns: Vec::new(),
381        }
382    }
383}
384
385/// Configuration for output filters.
386#[derive(Debug, Clone, Deserialize, Serialize)]
387pub struct FilterConfig {
388    /// Master switch for output filtering. Default: `true`.
389    #[serde(default = "default_true")]
390    pub enabled: bool,
391    /// Security filter settings.
392    #[serde(default)]
393    pub security: SecurityFilterConfig,
394    /// Directory containing a `filters.toml` override file.
395    #[serde(default, skip_serializing_if = "Option::is_none")]
396    pub filters_path: Option<PathBuf>,
397}
398
399impl Default for FilterConfig {
400    fn default() -> Self {
401        Self {
402            enabled: true,
403            security: SecurityFilterConfig::default(),
404            filters_path: None,
405        }
406    }
407}
408
409// ── ToolsConfig sub-types ────────────────────────────────────────────────────
410
411fn default_overflow_threshold() -> usize {
412    50_000
413}
414
415fn default_retention_days() -> u64 {
416    7
417}
418
419fn default_max_overflow_bytes() -> usize {
420    10 * 1024 * 1024
421}
422
423/// Configuration for large tool response offload to `SQLite`.
424#[derive(Debug, Clone, Deserialize, Serialize)]
425pub struct OverflowConfig {
426    /// Character threshold above which tool output is offloaded. Default: `50000`.
427    #[serde(default = "default_overflow_threshold")]
428    pub threshold: usize,
429    /// Days to retain offloaded entries. Default: `7`.
430    #[serde(default = "default_retention_days")]
431    pub retention_days: u64,
432    /// Maximum bytes per overflow entry. `0` means unlimited. Default: `10 MiB`.
433    #[serde(default = "default_max_overflow_bytes")]
434    pub max_overflow_bytes: usize,
435}
436
437impl Default for OverflowConfig {
438    fn default() -> Self {
439        Self {
440            threshold: default_overflow_threshold(),
441            retention_days: default_retention_days(),
442            max_overflow_bytes: default_max_overflow_bytes(),
443        }
444    }
445}
446
447fn default_anomaly_window() -> usize {
448    10
449}
450
451fn default_anomaly_error_threshold() -> f64 {
452    0.5
453}
454
455fn default_anomaly_critical_threshold() -> f64 {
456    0.8
457}
458
459/// Configuration for the sliding-window anomaly detector.
460#[derive(Debug, Clone, Deserialize, Serialize)]
461pub struct AnomalyConfig {
462    /// Enable the anomaly detector. Default: `true`.
463    #[serde(default = "default_true")]
464    pub enabled: bool,
465    /// Number of recent tool calls in the sliding window. Default: `10`.
466    #[serde(default = "default_anomaly_window")]
467    pub window_size: usize,
468    /// Error-rate fraction triggering a WARN. Default: `0.5`.
469    #[serde(default = "default_anomaly_error_threshold")]
470    pub error_threshold: f64,
471    /// Error-rate fraction triggering a CRIT. Default: `0.8`.
472    #[serde(default = "default_anomaly_critical_threshold")]
473    pub critical_threshold: f64,
474    /// Emit a WARN when a reasoning model produces a quality failure. Default: `true`.
475    #[serde(default = "default_true")]
476    pub reasoning_model_warning: bool,
477}
478
479impl Default for AnomalyConfig {
480    fn default() -> Self {
481        Self {
482            enabled: true,
483            window_size: default_anomaly_window(),
484            error_threshold: default_anomaly_error_threshold(),
485            critical_threshold: default_anomaly_critical_threshold(),
486            reasoning_model_warning: true,
487        }
488    }
489}
490
491fn default_cache_ttl_secs() -> u64 {
492    300
493}
494
495/// Configuration for the tool result cache.
496#[derive(Debug, Clone, Deserialize, Serialize)]
497pub struct ResultCacheConfig {
498    /// Whether caching is enabled. Default: `true`.
499    #[serde(default = "default_true")]
500    pub enabled: bool,
501    /// Time-to-live in seconds. `0` means entries never expire. Default: `300`.
502    #[serde(default = "default_cache_ttl_secs")]
503    pub ttl_secs: u64,
504}
505
506impl Default for ResultCacheConfig {
507    fn default() -> Self {
508        Self {
509            enabled: true,
510            ttl_secs: default_cache_ttl_secs(),
511        }
512    }
513}
514
515fn default_tafc_complexity_threshold() -> f64 {
516    0.6
517}
518
519/// Configuration for Think-Augmented Function Calling (TAFC).
520#[derive(Debug, Clone, Deserialize, Serialize)]
521pub struct TafcConfig {
522    /// Enable TAFC schema augmentation. Default: `false`.
523    #[serde(default)]
524    pub enabled: bool,
525    /// Complexity threshold tau in [0.0, 1.0]; tools >= tau are augmented. Default: `0.6`.
526    #[serde(default = "default_tafc_complexity_threshold")]
527    pub complexity_threshold: f64,
528}
529
530impl Default for TafcConfig {
531    fn default() -> Self {
532        Self {
533            enabled: false,
534            complexity_threshold: default_tafc_complexity_threshold(),
535        }
536    }
537}
538
539impl TafcConfig {
540    /// Validate and clamp `complexity_threshold` to [0.0, 1.0]. Resets NaN/Infinity to 0.6.
541    #[must_use]
542    pub fn validated(mut self) -> Self {
543        if self.complexity_threshold.is_finite() {
544            self.complexity_threshold = self.complexity_threshold.clamp(0.0, 1.0);
545        } else {
546            self.complexity_threshold = 0.6;
547        }
548        self
549    }
550}
551
552fn default_utility_exempt_tools() -> Vec<String> {
553    vec!["invoke_skill".to_string(), "load_skill".to_string()]
554}
555
556fn default_utility_threshold() -> f32 {
557    0.1
558}
559
560fn default_utility_gain_weight() -> f32 {
561    1.0
562}
563
564fn default_utility_cost_weight() -> f32 {
565    0.5
566}
567
568fn default_utility_redundancy_weight() -> f32 {
569    0.3
570}
571
572fn default_utility_uncertainty_bonus() -> f32 {
573    0.2
574}
575
576/// Configuration for utility-guided tool dispatch.
577#[derive(Debug, Clone, Deserialize, Serialize)]
578#[serde(default)]
579pub struct UtilityScoringConfig {
580    /// Enable utility-guided gating. Default: `false`.
581    pub enabled: bool,
582    /// Minimum utility score required to execute a tool call. Default: `0.1`.
583    #[serde(default = "default_utility_threshold")]
584    pub threshold: f32,
585    /// Weight for the estimated gain component. Must be >= 0. Default: `1.0`.
586    #[serde(default = "default_utility_gain_weight")]
587    pub gain_weight: f32,
588    /// Weight for the step cost component. Must be >= 0. Default: `0.5`.
589    #[serde(default = "default_utility_cost_weight")]
590    pub cost_weight: f32,
591    /// Weight for the redundancy penalty. Must be >= 0. Default: `0.3`.
592    #[serde(default = "default_utility_redundancy_weight")]
593    pub redundancy_weight: f32,
594    /// Weight for the exploration bonus. Must be >= 0. Default: `0.2`.
595    #[serde(default = "default_utility_uncertainty_bonus")]
596    pub uncertainty_bonus: f32,
597    /// Tool names that bypass the utility gate unconditionally.
598    #[serde(default = "default_utility_exempt_tools")]
599    pub exempt_tools: Vec<String>,
600}
601
602impl Default for UtilityScoringConfig {
603    fn default() -> Self {
604        Self {
605            enabled: false,
606            threshold: default_utility_threshold(),
607            gain_weight: default_utility_gain_weight(),
608            cost_weight: default_utility_cost_weight(),
609            redundancy_weight: default_utility_redundancy_weight(),
610            uncertainty_bonus: default_utility_uncertainty_bonus(),
611            exempt_tools: default_utility_exempt_tools(),
612        }
613    }
614}
615
616impl UtilityScoringConfig {
617    /// Validate that all weights and threshold are non-negative and finite.
618    ///
619    /// # Errors
620    ///
621    /// Returns a description of the first invalid field found.
622    pub fn validate(&self) -> Result<(), String> {
623        let fields = [
624            ("threshold", self.threshold),
625            ("gain_weight", self.gain_weight),
626            ("cost_weight", self.cost_weight),
627            ("redundancy_weight", self.redundancy_weight),
628            ("uncertainty_bonus", self.uncertainty_bonus),
629        ];
630        for (name, val) in fields {
631            if !val.is_finite() {
632                return Err(format!("[tools.utility] {name} must be finite, got {val}"));
633            }
634            if val < 0.0 {
635                return Err(format!("[tools.utility] {name} must be >= 0, got {val}"));
636            }
637        }
638        Ok(())
639    }
640}
641
642/// Dependency specification for a single tool.
643#[derive(Debug, Clone, Default, Deserialize, Serialize)]
644pub struct ToolDependency {
645    /// Hard prerequisites: tool is hidden until ALL of these have completed successfully.
646    #[serde(default, skip_serializing_if = "Vec::is_empty")]
647    pub requires: Vec<String>,
648    /// Soft prerequisites: tool gets a similarity boost when these have completed.
649    #[serde(default, skip_serializing_if = "Vec::is_empty")]
650    pub prefers: Vec<String>,
651}
652
653fn default_boost_per_dep() -> f32 {
654    0.15
655}
656
657fn default_max_total_boost() -> f32 {
658    0.2
659}
660
661/// Configuration for the tool dependency graph feature.
662#[derive(Debug, Clone, Deserialize, Serialize)]
663pub struct DependencyConfig {
664    /// Whether dependency gating is enabled. Default: `false`.
665    #[serde(default)]
666    pub enabled: bool,
667    /// Similarity boost added per satisfied `prefers` dependency. Default: `0.15`.
668    #[serde(default = "default_boost_per_dep")]
669    pub boost_per_dep: f32,
670    /// Maximum total boost applied regardless of how many `prefers` deps are met. Default: `0.2`.
671    #[serde(default = "default_max_total_boost")]
672    pub max_total_boost: f32,
673    /// Per-tool dependency rules. Key is `tool_id`.
674    #[serde(default)]
675    pub rules: HashMap<String, ToolDependency>,
676}
677
678impl Default for DependencyConfig {
679    fn default() -> Self {
680        Self {
681            enabled: false,
682            boost_per_dep: default_boost_per_dep(),
683            max_total_boost: default_max_total_boost(),
684            rules: HashMap::new(),
685        }
686    }
687}
688
689fn default_retry_max_attempts() -> usize {
690    2
691}
692
693fn default_retry_base_ms() -> u64 {
694    500
695}
696
697fn default_retry_max_ms() -> u64 {
698    5_000
699}
700
701fn default_retry_budget_secs() -> u64 {
702    30
703}
704
705/// Configuration for tool error retry behavior.
706#[derive(Debug, Clone, Deserialize, Serialize)]
707pub struct RetryConfig {
708    /// Maximum retry attempts for transient errors per tool call. `0` = disabled.
709    #[serde(default = "default_retry_max_attempts")]
710    pub max_attempts: usize,
711    /// Base delay (ms) for exponential backoff.
712    #[serde(default = "default_retry_base_ms")]
713    pub base_ms: u64,
714    /// Maximum delay cap (ms) for exponential backoff.
715    #[serde(default = "default_retry_max_ms")]
716    pub max_ms: u64,
717    /// Maximum wall-clock time (seconds) for all retries of a single tool call. `0` = unlimited.
718    #[serde(default = "default_retry_budget_secs")]
719    pub budget_secs: u64,
720    /// Provider name for LLM-based parameter reformatting on `InvalidParameters`/`TypeMismatch`.
721    /// Empty string = disabled.
722    #[serde(default)]
723    pub parameter_reformat_provider: ProviderName,
724}
725
726impl Default for RetryConfig {
727    fn default() -> Self {
728        Self {
729            max_attempts: default_retry_max_attempts(),
730            base_ms: default_retry_base_ms(),
731            max_ms: default_retry_max_ms(),
732            budget_secs: default_retry_budget_secs(),
733            parameter_reformat_provider: ProviderName::default(),
734        }
735    }
736}
737
738fn default_adversarial_timeout_ms() -> u64 {
739    3_000
740}
741
742/// Configuration for the LLM-based adversarial policy agent.
743#[derive(Debug, Clone, Deserialize, Serialize)]
744pub struct AdversarialPolicyConfig {
745    /// Enable the adversarial policy agent. Default: `false`.
746    #[serde(default)]
747    pub enabled: bool,
748    /// Provider name for the policy validation LLM.
749    #[serde(default)]
750    pub policy_provider: ProviderName,
751    /// Path to a plain-text policy file.
752    pub policy_file: Option<String>,
753    /// Whether to allow tool calls when the policy LLM fails. Default: `false` (fail-closed).
754    #[serde(default)]
755    pub fail_open: bool,
756    /// Timeout in milliseconds for a single policy LLM call. Default: `3000`.
757    #[serde(default = "default_adversarial_timeout_ms")]
758    pub timeout_ms: u64,
759    /// Tool names always allowed through the adversarial policy gate.
760    #[serde(default = "AdversarialPolicyConfig::default_exempt_tools")]
761    pub exempt_tools: Vec<String>,
762}
763
764impl Default for AdversarialPolicyConfig {
765    fn default() -> Self {
766        Self {
767            enabled: false,
768            policy_provider: ProviderName::default(),
769            policy_file: None,
770            fail_open: false,
771            timeout_ms: default_adversarial_timeout_ms(),
772            exempt_tools: Self::default_exempt_tools(),
773        }
774    }
775}
776
777impl AdversarialPolicyConfig {
778    #[must_use]
779    pub fn default_exempt_tools() -> Vec<String> {
780        vec![
781            "memory_save".into(),
782            "memory_search".into(),
783            "read_overflow".into(),
784            "load_skill".into(),
785            "invoke_skill".into(),
786            "schedule_deferred".into(),
787        ]
788    }
789}
790
791/// Per-path read allow/deny sandbox for the file tool.
792///
793/// Evaluation order: deny-then-allow. If a path matches `deny_read` and does NOT
794/// match `allow_read`, access is denied. Empty `deny_read` means no read restrictions.
795#[derive(Debug, Clone, Default, Deserialize, Serialize)]
796pub struct FileConfig {
797    /// Glob patterns for paths denied for reading. Evaluated first.
798    #[serde(default)]
799    pub deny_read: Vec<String>,
800    /// Glob patterns for paths allowed for reading. Evaluated second (overrides deny).
801    #[serde(default)]
802    pub allow_read: Vec<String>,
803}
804
805/// OAP-style declarative authorization config.
806#[derive(Debug, Clone, Default, Deserialize, Serialize)]
807pub struct AuthorizationConfig {
808    /// Enable OAP authorization checks. Default: `false`.
809    #[serde(default)]
810    pub enabled: bool,
811    /// Per-tool authorization rules appended after `[tools.policy]` rules at startup.
812    #[serde(default)]
813    pub rules: Vec<PolicyRuleConfig>,
814}
815
816/// Audit log destination.
817///
818/// Deserializes from a string in TOML: `"stdout"`, `"stderr"`, or a file path.
819#[derive(Debug, Clone, PartialEq, Eq, Default)]
820pub enum AuditDestination {
821    /// Write audit entries to standard output.
822    #[default]
823    Stdout,
824    /// Write audit entries to standard error.
825    Stderr,
826    /// Write audit entries to the given file path (appended, mode 0o600).
827    File(std::path::PathBuf),
828}
829
830impl AuditDestination {
831    /// Returns `true` if the destination is `stdout`.
832    #[must_use]
833    pub fn is_stdout(&self) -> bool {
834        matches!(self, Self::Stdout)
835    }
836}
837
838impl serde::Serialize for AuditDestination {
839    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
840        match self {
841            Self::Stdout => s.serialize_str("stdout"),
842            Self::Stderr => s.serialize_str("stderr"),
843            Self::File(p) => s.serialize_str(&p.display().to_string()),
844        }
845    }
846}
847
848impl<'de> serde::Deserialize<'de> for AuditDestination {
849    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
850        let s = String::deserialize(d)?;
851        Ok(match s.as_str() {
852            "stdout" => Self::Stdout,
853            "stderr" => Self::Stderr,
854            path => Self::File(std::path::PathBuf::from(path)),
855        })
856    }
857}
858
859/// Configuration for audit logging of tool executions.
860#[derive(Debug, Deserialize, Serialize)]
861pub struct AuditConfig {
862    /// Enable audit logging. Default: `true`.
863    #[serde(default = "default_true")]
864    pub enabled: bool,
865    /// Log destination. Default: [`AuditDestination::Stdout`].
866    #[serde(default)]
867    pub destination: AuditDestination,
868    /// When `true`, log a per-tool risk summary at startup. Default: `false`.
869    #[serde(default)]
870    pub tool_risk_summary: bool,
871}
872
873impl Default for AuditConfig {
874    fn default() -> Self {
875        Self {
876            enabled: true,
877            destination: AuditDestination::default(),
878            tool_risk_summary: false,
879        }
880    }
881}
882
883fn default_timeout() -> u64 {
884    30
885}
886
887fn default_confirm_patterns() -> Vec<String> {
888    vec![
889        "rm ".into(),
890        "git push -f".into(),
891        "git push --force".into(),
892        "drop table".into(),
893        "drop database".into(),
894        "truncate ".into(),
895        "$(".into(),
896        "`".into(),
897        "<(".into(),
898        ">(".into(),
899        "<<<".into(),
900        "eval ".into(),
901    ]
902}
903
904fn default_max_background_runs() -> usize {
905    8
906}
907
908fn default_background_timeout_secs() -> u64 {
909    1800
910}
911
912/// Shell-specific configuration: timeout, command blocklist, and allowlist overrides.
913#[derive(Debug, Deserialize, Serialize)]
914#[allow(clippy::struct_excessive_bools)]
915pub struct ShellConfig {
916    /// Shell command timeout in seconds. Default: `30`.
917    #[serde(default = "default_timeout")]
918    pub timeout: u64,
919    /// Commands blocked from execution.
920    #[serde(default)]
921    pub blocked_commands: Vec<String>,
922    /// Commands explicitly allowed (overrides blocklist).
923    #[serde(default)]
924    pub allowed_commands: Vec<String>,
925    /// Filesystem paths the shell is permitted to access.
926    #[serde(default)]
927    pub allowed_paths: Vec<String>,
928    /// Allow outbound network from shell. Default: `true`.
929    #[serde(default = "default_true")]
930    pub allow_network: bool,
931    /// Patterns that trigger a confirmation prompt before execution.
932    #[serde(default = "default_confirm_patterns")]
933    pub confirm_patterns: Vec<String>,
934    /// Environment variable name prefixes to strip from subprocess environment.
935    #[serde(default = "ShellConfig::default_env_blocklist")]
936    pub env_blocklist: Vec<String>,
937    /// Enable transactional mode: snapshot files before write commands. Default: `false`.
938    #[serde(default)]
939    pub transactional: bool,
940    /// Glob patterns for paths eligible for snapshotting.
941    #[serde(default)]
942    pub transaction_scope: Vec<String>,
943    /// Automatically rollback when exit code >= 2. Default: `false`.
944    #[serde(default)]
945    pub auto_rollback: bool,
946    /// Exit codes that trigger auto-rollback.
947    #[serde(default)]
948    pub auto_rollback_exit_codes: Vec<i32>,
949    /// When `true`, snapshot failure aborts execution. Default: `false`.
950    #[serde(default)]
951    pub snapshot_required: bool,
952    /// Maximum cumulative bytes for transaction snapshots. `0` = unlimited.
953    #[serde(default)]
954    pub max_snapshot_bytes: u64,
955    /// Maximum concurrent background shell runs. Default: `8`.
956    #[serde(default = "default_max_background_runs")]
957    pub max_background_runs: usize,
958    /// Timeout in seconds for each background shell run. Default: `1800`.
959    #[serde(default = "default_background_timeout_secs")]
960    pub background_timeout_secs: u64,
961    /// Cumulative risk score threshold for multi-step chain blocking. Default: `0.7`.
962    ///
963    /// When the `RiskChainAccumulator` (zeph-tools) exceeds this score within a single turn,
964    /// the command is blocked. Set to `None` to use the built-in default of `0.7`.
965    #[serde(default)]
966    pub risk_chain_threshold: Option<f32>,
967}
968
969impl Default for ShellConfig {
970    fn default() -> Self {
971        Self {
972            timeout: default_timeout(),
973            blocked_commands: Vec::new(),
974            allowed_commands: Vec::new(),
975            allowed_paths: Vec::new(),
976            allow_network: true,
977            confirm_patterns: default_confirm_patterns(),
978            env_blocklist: Self::default_env_blocklist(),
979            transactional: false,
980            transaction_scope: Vec::new(),
981            auto_rollback: false,
982            auto_rollback_exit_codes: Vec::new(),
983            snapshot_required: false,
984            max_snapshot_bytes: 0,
985            max_background_runs: default_max_background_runs(),
986            background_timeout_secs: default_background_timeout_secs(),
987            risk_chain_threshold: None,
988        }
989    }
990}
991
992impl ShellConfig {
993    /// Default environment variable prefixes to strip from subprocess environment.
994    #[must_use]
995    pub fn default_env_blocklist() -> Vec<String> {
996        vec![
997            "ZEPH_".into(),
998            "AWS_".into(),
999            "AZURE_".into(),
1000            "GCP_".into(),
1001            "GOOGLE_".into(),
1002            "OPENAI_".into(),
1003            "ANTHROPIC_".into(),
1004            "HF_".into(),
1005            "HUGGING".into(),
1006        ]
1007    }
1008}
1009
1010fn default_scrape_timeout() -> u64 {
1011    15
1012}
1013
1014fn default_max_body_bytes() -> usize {
1015    4_194_304
1016}
1017
1018fn default_ipi_filter_threshold() -> f32 {
1019    0.6
1020}
1021
1022/// Configuration for the web scrape tool.
1023#[derive(Debug, Deserialize, Serialize)]
1024pub struct ScrapeConfig {
1025    /// Timeout in seconds for scrape requests. Default: `15`.
1026    #[serde(default = "default_scrape_timeout")]
1027    pub timeout: u64,
1028    /// Maximum response body bytes. Default: `4 MiB`.
1029    #[serde(default = "default_max_body_bytes")]
1030    pub max_body_bytes: usize,
1031    /// Domain allowlist. Empty = all public domains allowed.
1032    #[serde(default)]
1033    pub allowed_domains: Vec<String>,
1034    /// Domain denylist. Always enforced, regardless of allowlist state.
1035    #[serde(default)]
1036    pub denied_domains: Vec<String>,
1037    /// IPI filter score threshold. Responses with score >= this value get a warning
1038    /// prepended and injection fragments replaced with `[FILTERED]`. Default: `0.6`.
1039    #[serde(default = "default_ipi_filter_threshold")]
1040    pub ipi_filter_threshold: f32,
1041}
1042
1043impl Default for ScrapeConfig {
1044    fn default() -> Self {
1045        Self {
1046            timeout: default_scrape_timeout(),
1047            max_body_bytes: default_max_body_bytes(),
1048            allowed_domains: Vec::new(),
1049            denied_domains: Vec::new(),
1050            ipi_filter_threshold: default_ipi_filter_threshold(),
1051        }
1052    }
1053}
1054
1055/// Speculative tool execution mode.
1056#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
1057#[serde(rename_all = "kebab-case")]
1058pub enum SpeculationMode {
1059    /// No speculation; uses existing synchronous path.
1060    #[default]
1061    Off,
1062    /// LLM-decoding level: fires tools when streaming partial JSON has all required fields.
1063    Decoding,
1064    /// Application-level pattern (PASTE): predicts top-K calls from `SQLite` history.
1065    Pattern,
1066    /// Both decoding and pattern speculation active.
1067    Both,
1068}
1069
1070/// Pattern-based (PASTE) speculative execution config.
1071#[derive(Debug, Clone, Deserialize, Serialize)]
1072pub struct SpeculativePatternConfig {
1073    /// Enable PASTE pattern learning and prediction. Default: `false`.
1074    #[serde(default)]
1075    pub enabled: bool,
1076    /// Minimum observed occurrences before a prediction is issued.
1077    #[serde(default = "default_min_observations")]
1078    pub min_observations: u32,
1079    /// Exponential decay half-life in days for pattern scoring.
1080    #[serde(default = "default_half_life_days")]
1081    pub half_life_days: f64,
1082    /// LLM provider name for optional reranking. Empty = disabled.
1083    #[serde(default)]
1084    pub rerank_provider: ProviderName,
1085}
1086
1087fn default_min_observations() -> u32 {
1088    5
1089}
1090
1091fn default_half_life_days() -> f64 {
1092    14.0
1093}
1094
1095impl Default for SpeculativePatternConfig {
1096    fn default() -> Self {
1097        Self {
1098            enabled: false,
1099            min_observations: default_min_observations(),
1100            half_life_days: default_half_life_days(),
1101            rerank_provider: ProviderName::default(),
1102        }
1103    }
1104}
1105
1106/// Shell command regex allowlist for speculative execution.
1107#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1108pub struct SpeculativeAllowlistConfig {
1109    /// Regexes matched against the full `bash` command string.
1110    #[serde(default)]
1111    pub shell: Vec<String>,
1112}
1113
1114fn default_max_in_flight() -> usize {
1115    4
1116}
1117
1118fn default_confidence_threshold() -> f32 {
1119    0.55
1120}
1121
1122fn default_max_wasted_per_minute() -> u64 {
1123    100
1124}
1125
1126fn default_ttl_seconds() -> u64 {
1127    30
1128}
1129
1130/// Top-level configuration for speculative tool execution.
1131#[derive(Debug, Clone, Deserialize, Serialize)]
1132pub struct SpeculativeConfig {
1133    /// Speculation mode. Default: `off`.
1134    #[serde(default)]
1135    pub mode: SpeculationMode,
1136    /// Maximum concurrent in-flight speculative tasks.
1137    #[serde(default = "default_max_in_flight")]
1138    pub max_in_flight: usize,
1139    /// Minimum confidence score [0, 1] to dispatch a speculative task.
1140    #[serde(default = "default_confidence_threshold")]
1141    pub confidence_threshold: f32,
1142    /// Circuit-breaker: disable speculation for 60 s when wasted ms exceeds this per minute.
1143    #[serde(default = "default_max_wasted_per_minute")]
1144    pub max_wasted_per_minute: u64,
1145    /// Per-handle wall-clock TTL in seconds before the handle is cancelled.
1146    #[serde(default = "default_ttl_seconds")]
1147    pub ttl_seconds: u64,
1148    /// Emit `AuditEntry` for speculative dispatches. Default: `true`.
1149    #[serde(default = "default_true")]
1150    pub audit: bool,
1151    /// PASTE pattern learning config.
1152    #[serde(default)]
1153    pub pattern: SpeculativePatternConfig,
1154    /// Per-executor command allowlists.
1155    #[serde(default)]
1156    pub allowlist: SpeculativeAllowlistConfig,
1157}
1158
1159impl Default for SpeculativeConfig {
1160    fn default() -> Self {
1161        Self {
1162            mode: SpeculationMode::Off,
1163            max_in_flight: default_max_in_flight(),
1164            confidence_threshold: default_confidence_threshold(),
1165            max_wasted_per_minute: default_max_wasted_per_minute(),
1166            ttl_seconds: default_ttl_seconds(),
1167            audit: true,
1168            pattern: SpeculativePatternConfig::default(),
1169            allowlist: SpeculativeAllowlistConfig::default(),
1170        }
1171    }
1172}
1173
1174/// Configuration for egress network event logging.
1175#[derive(Debug, Clone, Deserialize, Serialize)]
1176#[serde(default)]
1177#[allow(clippy::struct_excessive_bools)]
1178pub struct EgressConfig {
1179    /// Master switch for egress event emission. Default: `true`.
1180    pub enabled: bool,
1181    /// Emit events for requests blocked by SSRF/domain/scheme checks. Default: `true`.
1182    pub log_blocked: bool,
1183    /// Include `response_bytes` in the JSONL record. Default: `true`.
1184    pub log_response_bytes: bool,
1185    /// Show real hostname in TUI egress panel. Default: `true`.
1186    pub log_hosts_to_tui: bool,
1187}
1188
1189impl Default for EgressConfig {
1190    fn default() -> Self {
1191        Self {
1192            enabled: true,
1193            log_blocked: true,
1194            log_response_bytes: true,
1195            log_hosts_to_tui: true,
1196        }
1197    }
1198}
1199
1200// ── ToolCompressionConfig ─────────────────────────────────────────────────────
1201
1202fn default_compression_min_lines() -> usize {
1203    10
1204}
1205
1206fn default_compression_max_rules() -> u32 {
1207    200
1208}
1209
1210fn default_regex_compile_timeout_ms() -> u64 {
1211    500
1212}
1213
1214fn default_evolution_min_interval_secs() -> u64 {
1215    3600
1216}
1217
1218/// TACO self-evolving tool output compression configuration (`[tools.compression]` TOML section).
1219///
1220/// When enabled, a `RuleBasedCompressor` is wrapped around the root tool executor.
1221/// Rules are loaded from the `compression_rules` `SQLite` table and optionally evolved by an
1222/// LLM provider specified in `evolution_provider`.
1223///
1224/// # Example (TOML)
1225///
1226/// ```toml
1227/// [tools.compression]
1228/// enabled = true
1229/// evolution_provider = "fast"
1230/// min_lines_to_compress = 15
1231/// ```
1232#[derive(Debug, Clone, Deserialize, Serialize)]
1233#[serde(default)]
1234pub struct ToolCompressionConfig {
1235    /// Enable rule-based tool output compression. Default: `false`.
1236    pub enabled: bool,
1237    /// Minimum output line count before compression is attempted. Default: `10`.
1238    #[serde(default = "default_compression_min_lines")]
1239    pub min_lines_to_compress: usize,
1240    /// LLM provider name for self-evolution. Empty string = evolution disabled. Default: `""`.
1241    #[serde(default)]
1242    pub evolution_provider: ProviderName,
1243    /// Minimum interval in seconds between self-evolution runs. Default: `3600`.
1244    #[serde(default = "default_evolution_min_interval_secs")]
1245    pub evolution_min_interval_secs: u64,
1246    /// Maximum number of rules to keep in the DB (prune lowest-hit rules above this). Default: `200`.
1247    #[serde(default = "default_compression_max_rules")]
1248    pub max_rules: u32,
1249    /// Timeout in milliseconds for safe regex compilation. Default: `500`.
1250    #[serde(default = "default_regex_compile_timeout_ms")]
1251    pub regex_compile_timeout_ms: u64,
1252}
1253
1254impl Default for ToolCompressionConfig {
1255    fn default() -> Self {
1256        Self {
1257            enabled: false,
1258            min_lines_to_compress: default_compression_min_lines(),
1259            evolution_provider: ProviderName::default(),
1260            evolution_min_interval_secs: default_evolution_min_interval_secs(),
1261            max_rules: default_compression_max_rules(),
1262            regex_compile_timeout_ms: default_regex_compile_timeout_ms(),
1263        }
1264    }
1265}
1266
1267// ── ToolsConfig ───────────────────────────────────────────────────────────────
1268
1269/// Top-level configuration for tool execution.
1270///
1271/// Deserialized from `[tools]` in TOML. The `permission_policy()` method (which constructs
1272/// a runtime `PermissionPolicy`) lives in `zeph-tools` as a free function to avoid
1273/// importing runtime types into this leaf crate.
1274#[derive(Debug, Deserialize, Serialize)]
1275pub struct ToolsConfig {
1276    /// Enable all tools. Default: `true`.
1277    #[serde(default = "default_true")]
1278    pub enabled: bool,
1279    /// Summarize long tool output before injection into context. Default: `true`.
1280    #[serde(default = "default_true")]
1281    pub summarize_output: bool,
1282    /// Shell tool configuration.
1283    #[serde(default)]
1284    pub shell: ShellConfig,
1285    /// Web scrape tool configuration.
1286    #[serde(default)]
1287    pub scrape: ScrapeConfig,
1288    /// Audit log configuration.
1289    #[serde(default)]
1290    pub audit: AuditConfig,
1291    /// Declarative permissions. Overrides legacy `shell.blocked_commands` when set.
1292    #[serde(default)]
1293    pub permissions: Option<PermissionsConfig>,
1294    /// Output filter configuration.
1295    #[serde(default)]
1296    pub filters: FilterConfig,
1297    /// Large response offload configuration.
1298    #[serde(default)]
1299    pub overflow: OverflowConfig,
1300    /// Sliding-window anomaly detector.
1301    #[serde(default)]
1302    pub anomaly: AnomalyConfig,
1303    /// Tool result cache.
1304    #[serde(default)]
1305    pub result_cache: ResultCacheConfig,
1306    /// Think-Augmented Function Calling.
1307    #[serde(default)]
1308    pub tafc: TafcConfig,
1309    /// Tool dependency graph.
1310    #[serde(default)]
1311    pub dependencies: DependencyConfig,
1312    /// Error retry configuration.
1313    #[serde(default)]
1314    pub retry: RetryConfig,
1315    /// Declarative policy compiler for tool call authorization.
1316    #[serde(default)]
1317    pub policy: PolicyConfig,
1318    /// LLM-based adversarial policy agent.
1319    #[serde(default)]
1320    pub adversarial_policy: AdversarialPolicyConfig,
1321    /// Utility-guided tool dispatch gate.
1322    #[serde(default)]
1323    pub utility: UtilityScoringConfig,
1324    /// Per-path read allow/deny sandbox for the file tool.
1325    #[serde(default)]
1326    pub file: FileConfig,
1327    /// OAP declarative pre-action authorization.
1328    #[serde(default)]
1329    pub authorization: AuthorizationConfig,
1330    /// Maximum tool calls allowed per agent session. `None` = unlimited.
1331    #[serde(default)]
1332    pub max_tool_calls_per_session: Option<u32>,
1333    /// Speculative tool execution configuration.
1334    #[serde(default)]
1335    pub speculative: SpeculativeConfig,
1336    /// OS-level subprocess sandbox configuration.
1337    #[serde(default)]
1338    pub sandbox: SandboxConfig,
1339    /// Egress network event logging configuration.
1340    #[serde(default)]
1341    pub egress: EgressConfig,
1342    /// TACO self-evolving tool output compression configuration.
1343    #[serde(default)]
1344    pub compression: ToolCompressionConfig,
1345}
1346
1347impl Default for ToolsConfig {
1348    fn default() -> Self {
1349        Self {
1350            enabled: true,
1351            summarize_output: true,
1352            shell: ShellConfig::default(),
1353            scrape: ScrapeConfig::default(),
1354            audit: AuditConfig::default(),
1355            permissions: None,
1356            filters: FilterConfig::default(),
1357            overflow: OverflowConfig::default(),
1358            anomaly: AnomalyConfig::default(),
1359            result_cache: ResultCacheConfig::default(),
1360            tafc: TafcConfig::default(),
1361            dependencies: DependencyConfig::default(),
1362            retry: RetryConfig::default(),
1363            policy: PolicyConfig::default(),
1364            adversarial_policy: AdversarialPolicyConfig::default(),
1365            utility: UtilityScoringConfig::default(),
1366            file: FileConfig::default(),
1367            authorization: AuthorizationConfig::default(),
1368            max_tool_calls_per_session: None,
1369            speculative: SpeculativeConfig::default(),
1370            sandbox: SandboxConfig::default(),
1371            egress: EgressConfig::default(),
1372            compression: ToolCompressionConfig::default(),
1373        }
1374    }
1375}
1376
1377#[cfg(test)]
1378mod tests {
1379    use super::*;
1380
1381    #[test]
1382    fn deserialize_default_config() {
1383        let toml_str = r#"
1384            enabled = true
1385
1386            [shell]
1387            timeout = 60
1388            blocked_commands = ["rm -rf /", "sudo"]
1389        "#;
1390
1391        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
1392        assert!(config.enabled);
1393        assert_eq!(config.shell.timeout, 60);
1394        assert_eq!(config.shell.blocked_commands.len(), 2);
1395    }
1396
1397    #[test]
1398    fn empty_blocked_commands() {
1399        let config: ToolsConfig = toml::from_str(r"[shell]\ntimeout = 30\n").unwrap_or_default();
1400        assert!(config.enabled);
1401    }
1402
1403    #[test]
1404    fn default_tools_config() {
1405        let config = ToolsConfig::default();
1406        assert!(config.enabled);
1407        assert!(config.summarize_output);
1408        assert_eq!(config.shell.timeout, 30);
1409        assert!(config.shell.blocked_commands.is_empty());
1410        assert!(config.audit.enabled);
1411    }
1412
1413    #[test]
1414    fn audit_destination_serde_roundtrip() {
1415        let cases = [
1416            ("\"stdout\"", AuditDestination::Stdout),
1417            ("\"stderr\"", AuditDestination::Stderr),
1418            (
1419                "\"/var/log/audit.log\"",
1420                AuditDestination::File("/var/log/audit.log".into()),
1421            ),
1422        ];
1423        for (json_str, expected) in cases {
1424            let got: AuditDestination = serde_json::from_str(json_str).unwrap();
1425            assert_eq!(got, expected);
1426            let serialized = serde_json::to_string(&got).unwrap();
1427            let roundtrip: AuditDestination = serde_json::from_str(&serialized).unwrap();
1428            assert_eq!(roundtrip, expected);
1429        }
1430    }
1431
1432    #[test]
1433    fn audit_destination_toml_in_config() {
1434        let cases = [
1435            (
1436                r#"[audit]
1437destination = "stdout""#,
1438                AuditDestination::Stdout,
1439            ),
1440            (
1441                r#"[audit]
1442destination = "stderr""#,
1443                AuditDestination::Stderr,
1444            ),
1445            (
1446                r#"[audit]
1447destination = "/var/log/zeph-audit.log""#,
1448                AuditDestination::File("/var/log/zeph-audit.log".into()),
1449            ),
1450        ];
1451        for (toml_str, expected) in cases {
1452            let config: ToolsConfig = toml::from_str(toml_str).unwrap();
1453            assert_eq!(config.audit.destination, expected);
1454        }
1455    }
1456}