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