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
432fn default_max_per_call_override() -> usize {
433    131_072
434}
435
436/// Configuration for large tool response offload to `SQLite`.
437#[derive(Debug, Clone, Deserialize, Serialize)]
438pub struct OverflowConfig {
439    /// Character threshold above which tool output is offloaded. Default: `50000`.
440    #[serde(default = "default_overflow_threshold")]
441    pub threshold: usize,
442    /// Days to retain offloaded entries. Default: `7`.
443    #[serde(default = "default_retention_days")]
444    pub retention_days: u64,
445    /// Maximum bytes per overflow entry. `0` means unlimited. Default: `10 MiB`.
446    #[serde(default = "default_max_overflow_bytes")]
447    pub max_overflow_bytes: usize,
448    /// Hard ceiling (in chars) on a per-call result-size override that an MCP
449    /// server may request via `_meta["zeph/maxResultSizeChars"]`. The server's
450    /// request is clamped to `min(requested, max_per_call_override)` and may only
451    /// raise the effective limit above [`threshold`](Self::threshold), never lower
452    /// it. A value at or below `threshold` disables per-call overrides entirely.
453    /// Raising the effective limit also enlarges how much of that call's output
454    /// counts against the turn's context budget, not just summarizer cost.
455    /// Default: `131072` (128 KiB).
456    ///
457    /// Note: unlike [`max_overflow_bytes`](Self::max_overflow_bytes), `0` here
458    /// does NOT mean "unlimited" — it disables overrides (the clamp floors at
459    /// `threshold`).
460    ///
461    /// Note: for sanitized (external/untrusted, `:`-qualified) MCP output, the effective
462    /// in-context ceiling under default config is actually `zeph-sanitizer`'s
463    /// `ContentIsolationConfig::max_content_size` (65536 bytes by default), which is
464    /// smaller than this field's 131072-char default and caps what reaches the LLM
465    /// downstream of this clamp — a fail-safe (lower, not higher) bound, not a hole.
466    #[serde(default = "default_max_per_call_override")]
467    pub max_per_call_override: usize,
468}
469
470impl Default for OverflowConfig {
471    fn default() -> Self {
472        Self {
473            threshold: default_overflow_threshold(),
474            retention_days: default_retention_days(),
475            max_overflow_bytes: default_max_overflow_bytes(),
476            max_per_call_override: default_max_per_call_override(),
477        }
478    }
479}
480
481fn default_anomaly_window() -> usize {
482    10
483}
484
485fn default_anomaly_error_threshold() -> f64 {
486    0.5
487}
488
489fn default_anomaly_critical_threshold() -> f64 {
490    0.8
491}
492
493/// Configuration for the sliding-window anomaly detector.
494#[derive(Debug, Clone, Deserialize, Serialize)]
495pub struct AnomalyConfig {
496    /// Enable the anomaly detector. Default: `true`.
497    #[serde(default = "default_true")]
498    pub enabled: bool,
499    /// Number of recent tool calls in the sliding window. Default: `10`.
500    #[serde(default = "default_anomaly_window")]
501    pub window_size: usize,
502    /// Error-rate fraction triggering a WARN. Default: `0.5`.
503    #[serde(default = "default_anomaly_error_threshold")]
504    pub error_threshold: f64,
505    /// Error-rate fraction triggering a CRIT. Default: `0.8`.
506    #[serde(default = "default_anomaly_critical_threshold")]
507    pub critical_threshold: f64,
508    /// Emit a WARN when a reasoning model produces a quality failure. Default: `true`.
509    #[serde(default = "default_true")]
510    pub reasoning_model_warning: bool,
511}
512
513impl Default for AnomalyConfig {
514    fn default() -> Self {
515        Self {
516            enabled: true,
517            window_size: default_anomaly_window(),
518            error_threshold: default_anomaly_error_threshold(),
519            critical_threshold: default_anomaly_critical_threshold(),
520            reasoning_model_warning: true,
521        }
522    }
523}
524
525fn default_cache_ttl_secs() -> u64 {
526    300
527}
528
529/// Configuration for the tool result cache.
530#[derive(Debug, Clone, Deserialize, Serialize)]
531pub struct ResultCacheConfig {
532    /// Whether caching is enabled. Default: `true`.
533    #[serde(default = "default_true")]
534    pub enabled: bool,
535    /// Time-to-live in seconds. `0` means entries never expire. Default: `300`.
536    #[serde(default = "default_cache_ttl_secs")]
537    pub ttl_secs: u64,
538}
539
540impl Default for ResultCacheConfig {
541    fn default() -> Self {
542        Self {
543            enabled: true,
544            ttl_secs: default_cache_ttl_secs(),
545        }
546    }
547}
548
549fn default_tafc_complexity_threshold() -> f64 {
550    0.6
551}
552
553/// Configuration for Think-Augmented Function Calling (TAFC).
554#[derive(Debug, Clone, Deserialize, Serialize)]
555pub struct TafcConfig {
556    /// Enable TAFC schema augmentation. Default: `false`.
557    #[serde(default)]
558    pub enabled: bool,
559    /// Complexity threshold tau in [0.0, 1.0]; tools >= tau are augmented. Default: `0.6`.
560    #[serde(default = "default_tafc_complexity_threshold")]
561    pub complexity_threshold: f64,
562}
563
564impl Default for TafcConfig {
565    fn default() -> Self {
566        Self {
567            enabled: false,
568            complexity_threshold: default_tafc_complexity_threshold(),
569        }
570    }
571}
572
573impl TafcConfig {
574    /// Validate and clamp `complexity_threshold` to [0.0, 1.0]. Resets NaN/Infinity to 0.6.
575    #[must_use]
576    pub fn validated(mut self) -> Self {
577        if self.complexity_threshold.is_finite() {
578            self.complexity_threshold = self.complexity_threshold.clamp(0.0, 1.0);
579        } else {
580            self.complexity_threshold = 0.6;
581        }
582        self
583    }
584}
585
586fn default_utility_exempt_tools() -> Vec<String> {
587    vec!["invoke_skill".to_string(), "load_skill".to_string()]
588}
589
590fn default_utility_threshold() -> f32 {
591    0.1
592}
593
594fn default_utility_gain_weight() -> f32 {
595    1.0
596}
597
598fn default_utility_cost_weight() -> f32 {
599    0.5
600}
601
602fn default_utility_redundancy_weight() -> f32 {
603    0.3
604}
605
606fn default_utility_uncertainty_bonus() -> f32 {
607    0.2
608}
609
610/// Configuration for utility-guided tool dispatch.
611#[derive(Debug, Clone, Deserialize, Serialize)]
612#[serde(default)]
613pub struct UtilityScoringConfig {
614    /// Enable utility-guided gating. Default: `false`.
615    pub enabled: bool,
616    /// Minimum utility score required to execute a tool call. Default: `0.1`.
617    #[serde(default = "default_utility_threshold")]
618    pub threshold: f32,
619    /// Weight for the estimated gain component. Must be >= 0. Default: `1.0`.
620    #[serde(default = "default_utility_gain_weight")]
621    pub gain_weight: f32,
622    /// Weight for the step cost component. Must be >= 0. Default: `0.5`.
623    #[serde(default = "default_utility_cost_weight")]
624    pub cost_weight: f32,
625    /// Weight for the redundancy penalty. Must be >= 0. Default: `0.3`.
626    #[serde(default = "default_utility_redundancy_weight")]
627    pub redundancy_weight: f32,
628    /// Weight for the exploration bonus. Must be >= 0. Default: `0.2`.
629    #[serde(default = "default_utility_uncertainty_bonus")]
630    pub uncertainty_bonus: f32,
631    /// Tool names that bypass the utility gate unconditionally.
632    #[serde(default = "default_utility_exempt_tools")]
633    pub exempt_tools: Vec<String>,
634    /// Consecutive low-utility calls before early-stopping the loop. 0 = disabled.
635    ///
636    /// Exempt tools (`invoke_skill`, `load_skill`) do not count toward this window.
637    /// The counter resets between outer loop iterations.
638    #[serde(default)]
639    pub utility_window: usize,
640    /// Tool names that always receive the `0.75` "direct action" gain tier, matching
641    /// `diagnostics`/`edit`/etc in the built-in `default_gain` table.
642    ///
643    /// Opt-in override for tool ids the built-in table has no entry for — most notably
644    /// MCP-registered tools, whose ids are `{server_id}_{name}` (see
645    /// `McpTool::sanitized_id`) and therefore never match a hardcoded name. Without an
646    /// entry here, such a tool falls to the generic `0.5` bucket and can stall behind a
647    /// `Retrieve -> redundant retry -> vetoed` cycle on its first call (#5659). Default:
648    /// empty (no behavior change for existing configs).
649    #[serde(default)]
650    pub high_gain_tools: Vec<String>,
651}
652
653impl Default for UtilityScoringConfig {
654    fn default() -> Self {
655        Self {
656            enabled: false,
657            threshold: default_utility_threshold(),
658            gain_weight: default_utility_gain_weight(),
659            cost_weight: default_utility_cost_weight(),
660            redundancy_weight: default_utility_redundancy_weight(),
661            uncertainty_bonus: default_utility_uncertainty_bonus(),
662            exempt_tools: default_utility_exempt_tools(),
663            utility_window: 0,
664            high_gain_tools: Vec::new(),
665        }
666    }
667}
668
669impl UtilityScoringConfig {
670    /// Validate that all weights and threshold are non-negative and finite.
671    ///
672    /// # Errors
673    ///
674    /// Returns a description of the first invalid field found.
675    #[must_use = "validation result must be checked"]
676    pub fn validate(&self) -> Result<(), String> {
677        let fields = [
678            ("threshold", self.threshold),
679            ("gain_weight", self.gain_weight),
680            ("cost_weight", self.cost_weight),
681            ("redundancy_weight", self.redundancy_weight),
682            ("uncertainty_bonus", self.uncertainty_bonus),
683        ];
684        for (name, val) in fields {
685            if !val.is_finite() {
686                return Err(format!("[tools.utility] {name} must be finite, got {val}"));
687            }
688            if val < 0.0 {
689                return Err(format!("[tools.utility] {name} must be >= 0, got {val}"));
690            }
691        }
692        Ok(())
693    }
694}
695
696/// Dependency specification for a single tool.
697#[derive(Debug, Clone, Default, Deserialize, Serialize)]
698pub struct ToolDependency {
699    /// Hard prerequisites: tool is hidden until ALL of these have completed successfully.
700    #[serde(default, skip_serializing_if = "Vec::is_empty")]
701    pub requires: Vec<String>,
702    /// Soft prerequisites: tool gets a similarity boost when these have completed.
703    #[serde(default, skip_serializing_if = "Vec::is_empty")]
704    pub prefers: Vec<String>,
705}
706
707fn default_boost_per_dep() -> f32 {
708    0.15
709}
710
711fn default_max_total_boost() -> f32 {
712    0.2
713}
714
715/// Configuration for the tool dependency graph feature.
716#[derive(Debug, Clone, Deserialize, Serialize)]
717pub struct DependencyConfig {
718    /// Whether dependency gating is enabled. Default: `false`.
719    #[serde(default)]
720    pub enabled: bool,
721    /// Similarity boost added per satisfied `prefers` dependency. Default: `0.15`.
722    #[serde(default = "default_boost_per_dep")]
723    pub boost_per_dep: f32,
724    /// Maximum total boost applied regardless of how many `prefers` deps are met. Default: `0.2`.
725    #[serde(default = "default_max_total_boost")]
726    pub max_total_boost: f32,
727    /// Per-tool dependency rules. Key is `tool_id`.
728    #[serde(default)]
729    pub rules: HashMap<String, ToolDependency>,
730}
731
732impl Default for DependencyConfig {
733    fn default() -> Self {
734        Self {
735            enabled: false,
736            boost_per_dep: default_boost_per_dep(),
737            max_total_boost: default_max_total_boost(),
738            rules: HashMap::new(),
739        }
740    }
741}
742
743fn default_retry_max_attempts() -> usize {
744    2
745}
746
747fn default_retry_base_ms() -> u64 {
748    500
749}
750
751fn default_retry_max_ms() -> u64 {
752    5_000
753}
754
755fn default_retry_budget_secs() -> u64 {
756    30
757}
758
759/// Configuration for tool error retry behavior.
760#[derive(Debug, Clone, Deserialize, Serialize)]
761pub struct RetryConfig {
762    /// Maximum retry attempts for transient errors per tool call. `0` = disabled.
763    #[serde(default = "default_retry_max_attempts")]
764    pub max_attempts: usize,
765    /// Base delay (ms) for exponential backoff.
766    #[serde(default = "default_retry_base_ms")]
767    pub base_ms: u64,
768    /// Maximum delay cap (ms) for exponential backoff.
769    #[serde(default = "default_retry_max_ms")]
770    pub max_ms: u64,
771    /// Maximum wall-clock time (seconds) for all retries of a single tool call. `0` = unlimited.
772    #[serde(default = "default_retry_budget_secs")]
773    pub budget_secs: u64,
774    /// Provider name for LLM-based parameter reformatting on `InvalidParameters`/`TypeMismatch`.
775    /// Empty string = disabled.
776    #[serde(default)]
777    pub parameter_reformat_provider: ProviderName,
778}
779
780impl Default for RetryConfig {
781    fn default() -> Self {
782        Self {
783            max_attempts: default_retry_max_attempts(),
784            base_ms: default_retry_base_ms(),
785            max_ms: default_retry_max_ms(),
786            budget_secs: default_retry_budget_secs(),
787            parameter_reformat_provider: ProviderName::default(),
788        }
789    }
790}
791
792/// Fixed fallback timeout (ms) used for cloud policy providers, and whenever the
793/// provider kind cannot be determined.
794fn default_adversarial_timeout_ms() -> u64 {
795    3_000
796}
797
798/// Timeout (ms) used for local policy providers (Ollama, Candle, or any other
799/// locally-hosted model). Local inference routinely takes 10-30s+ per completion,
800/// far above the fixed default that was tuned for cloud APIs — see #5870. Set with
801/// margin above the worst-case latency observed in #5870's own reproduction
802/// (`qwen2.5:7b` took up to `31928` ms): 45s clears that by ~13s (~41%) so the
803/// fix closes the failure window instead of merely narrowing it.
804const LOCAL_PROVIDER_ADVERSARIAL_TIMEOUT_MS: u64 = 45_000;
805
806/// Resolve the effective adversarial policy timeout for a resolved provider kind.
807///
808/// `provider_kind` is the value returned by `AnyProvider::provider_kind_str()`:
809/// `"ollama"` / `"candle"` / `"local"` for locally-hosted inference, `"cloud"` for
810/// metered API providers. Local providers get a much longer fail-closed budget,
811/// since a fixed 3s timeout made the fail-closed adversarial gate deny effectively
812/// every tool call when `policy_provider` pointed at a local Ollama model (#5870).
813///
814/// This classification is keyed on the provider's configured **type**
815/// (`[[llm.providers]].type`), not on whether its endpoint happens to be local.
816/// A `type = "openai"`/`"claude"` entry pointed at a self-hosted or localhost
817/// `base_url` (e.g. an OpenAI-compatible proxy in front of a local model) still
818/// resolves to `"cloud"` here and gets the short 3s budget. Operators running a
819/// cloud-provider-typed client against a slow self-hosted endpoint should set
820/// [`AdversarialPolicyConfig::timeout_ms`] explicitly rather than relying on
821/// auto-scaling.
822///
823/// Only used when [`AdversarialPolicyConfig::timeout_ms`] is left unset — an
824/// explicit value always takes precedence over provider-based scaling.
825///
826/// # Examples
827///
828/// ```
829/// use zeph_config::tools::adversarial_timeout_for_provider_kind;
830///
831/// assert_eq!(adversarial_timeout_for_provider_kind("ollama"), 45_000);
832/// assert_eq!(adversarial_timeout_for_provider_kind("cloud"), 3_000);
833/// ```
834#[must_use]
835pub fn adversarial_timeout_for_provider_kind(provider_kind: &str) -> u64 {
836    if matches!(provider_kind, "ollama" | "candle" | "local") {
837        LOCAL_PROVIDER_ADVERSARIAL_TIMEOUT_MS
838    } else {
839        default_adversarial_timeout_ms()
840    }
841}
842
843/// Configuration for the LLM-based adversarial policy agent.
844#[derive(Debug, Clone, Deserialize, Serialize)]
845pub struct AdversarialPolicyConfig {
846    /// Enable the adversarial policy agent. Default: `false`.
847    #[serde(default)]
848    pub enabled: bool,
849    /// Provider name for the policy validation LLM.
850    #[serde(default)]
851    pub policy_provider: ProviderName,
852    /// Path to a plain-text policy file.
853    pub policy_file: Option<String>,
854    /// Whether to allow tool calls when the policy LLM fails. Default: `false` (fail-closed).
855    #[serde(default)]
856    pub fail_open: bool,
857    /// Timeout in milliseconds for a single policy LLM call.
858    ///
859    /// When unset (the default), the effective timeout is derived at startup from the
860    /// resolved `policy_provider`'s kind via [`adversarial_timeout_for_provider_kind`]:
861    /// local providers get a much longer fail-closed budget than cloud providers. Set
862    /// this explicitly to override auto-scaling with a fixed value.
863    #[serde(default)]
864    pub timeout_ms: Option<u64>,
865    /// Tool names always allowed through the adversarial policy gate.
866    #[serde(default = "AdversarialPolicyConfig::default_exempt_tools")]
867    pub exempt_tools: Vec<String>,
868}
869
870impl Default for AdversarialPolicyConfig {
871    fn default() -> Self {
872        Self {
873            enabled: false,
874            policy_provider: ProviderName::default(),
875            policy_file: None,
876            fail_open: false,
877            timeout_ms: None,
878            exempt_tools: Self::default_exempt_tools(),
879        }
880    }
881}
882
883impl AdversarialPolicyConfig {
884    #[must_use]
885    pub fn default_exempt_tools() -> Vec<String> {
886        vec![
887            "memory_save".into(),
888            "memory_search".into(),
889            "read_overflow".into(),
890            "load_skill".into(),
891            "invoke_skill".into(),
892            "schedule_deferred".into(),
893            // Read-only scheduler intrinsic must never be blocked by the adversarial
894            // probe: it carries no side-effects and the embed provider may be unavailable.
895            "list_tasks".into(),
896        ]
897    }
898}
899
900/// Per-path read allow/deny sandbox for the file tool.
901///
902/// Evaluation order: deny-then-allow. If a path matches `deny_read` and does NOT
903/// match `allow_read`, access is denied. Empty `deny_read` means no read restrictions.
904#[derive(Debug, Clone, Default, Deserialize, Serialize)]
905pub struct FileConfig {
906    /// Glob patterns for paths denied for reading. Evaluated first.
907    #[serde(default)]
908    pub deny_read: Vec<String>,
909    /// Glob patterns for paths allowed for reading. Evaluated second (overrides deny).
910    #[serde(default)]
911    pub allow_read: Vec<String>,
912}
913
914/// OAP-style declarative authorization config.
915#[derive(Debug, Clone, Default, Deserialize, Serialize)]
916pub struct AuthorizationConfig {
917    /// Enable OAP authorization checks. Default: `false`.
918    #[serde(default)]
919    pub enabled: bool,
920    /// Per-tool authorization rules appended after `[tools.policy]` rules at startup.
921    #[serde(default)]
922    pub rules: Vec<PolicyRuleConfig>,
923}
924
925/// Audit log destination.
926///
927/// Deserializes from a string in TOML: `"stdout"`, `"stderr"`, or a file path.
928#[derive(Debug, Clone, PartialEq, Eq, Default)]
929#[non_exhaustive]
930pub enum AuditDestination {
931    /// Write audit entries to standard output.
932    #[default]
933    Stdout,
934    /// Write audit entries to standard error.
935    Stderr,
936    /// Write audit entries to the given file path (appended, mode 0o600).
937    File(std::path::PathBuf),
938}
939
940impl AuditDestination {
941    /// Returns `true` if the destination is `stdout`.
942    #[must_use]
943    pub fn is_stdout(&self) -> bool {
944        matches!(self, Self::Stdout)
945    }
946}
947
948impl serde::Serialize for AuditDestination {
949    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
950        match self {
951            Self::Stdout => s.serialize_str("stdout"),
952            Self::Stderr => s.serialize_str("stderr"),
953            Self::File(p) => s.serialize_str(&p.display().to_string()),
954        }
955    }
956}
957
958impl<'de> serde::Deserialize<'de> for AuditDestination {
959    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
960        let s = String::deserialize(d)?;
961        Ok(match s.as_str() {
962            "stdout" => Self::Stdout,
963            "stderr" => Self::Stderr,
964            path => Self::File(std::path::PathBuf::from(path)),
965        })
966    }
967}
968
969/// Configuration for audit logging of tool executions.
970#[derive(Debug, Deserialize, Serialize)]
971pub struct AuditConfig {
972    /// Enable audit logging. Default: `true`.
973    #[serde(default = "default_true")]
974    pub enabled: bool,
975    /// Log destination. Default: [`AuditDestination::Stdout`].
976    #[serde(default)]
977    pub destination: AuditDestination,
978    /// When `true`, log a per-tool risk summary at startup. Default: `false`.
979    #[serde(default)]
980    pub tool_risk_summary: bool,
981}
982
983impl Default for AuditConfig {
984    fn default() -> Self {
985        Self {
986            enabled: true,
987            destination: AuditDestination::default(),
988            tool_risk_summary: false,
989        }
990    }
991}
992
993fn default_timeout() -> u64 {
994    30
995}
996
997fn default_confirm_patterns() -> Vec<String> {
998    vec![
999        "rm ".into(),
1000        "git push -f".into(),
1001        "git push --force".into(),
1002        "drop table".into(),
1003        "drop database".into(),
1004        "truncate ".into(),
1005        "$(".into(),
1006        "`".into(),
1007        "<(".into(),
1008        ">(".into(),
1009        "<<<".into(),
1010        "eval ".into(),
1011    ]
1012}
1013
1014fn default_max_background_runs() -> usize {
1015    8
1016}
1017
1018fn default_background_timeout_secs() -> u64 {
1019    1800
1020}
1021
1022fn default_max_checkpoints() -> usize {
1023    20
1024}
1025
1026/// Shell-specific configuration: timeout, command blocklist, and allowlist overrides.
1027#[derive(Debug, Deserialize, Serialize)]
1028#[allow(clippy::struct_excessive_bools)]
1029pub struct ShellConfig {
1030    /// Shell command timeout in seconds. Default: `30`.
1031    #[serde(default = "default_timeout")]
1032    pub timeout: u64,
1033    /// Commands blocked from execution.
1034    #[serde(default)]
1035    pub blocked_commands: Vec<String>,
1036    /// Commands explicitly allowed (overrides blocklist).
1037    #[serde(default)]
1038    pub allowed_commands: Vec<String>,
1039    /// Filesystem paths the shell is permitted to access.
1040    #[serde(default)]
1041    pub allowed_paths: Vec<String>,
1042    /// Allow outbound network from shell. Default: `true`.
1043    #[serde(default = "default_true")]
1044    pub allow_network: bool,
1045    /// Patterns that trigger a confirmation prompt before execution.
1046    #[serde(default = "default_confirm_patterns")]
1047    pub confirm_patterns: Vec<String>,
1048    /// Environment variable name prefixes to strip from subprocess environment.
1049    #[serde(default = "ShellConfig::default_env_blocklist")]
1050    pub env_blocklist: Vec<String>,
1051    /// Enable transactional mode: snapshot files before write commands. Default: `false`.
1052    #[serde(default)]
1053    pub transactional: bool,
1054    /// Glob patterns for paths eligible for snapshotting.
1055    #[serde(default)]
1056    pub transaction_scope: Vec<String>,
1057    /// Automatically rollback when exit code >= 2. Default: `false`.
1058    #[serde(default)]
1059    pub auto_rollback: bool,
1060    /// Exit codes that trigger auto-rollback.
1061    #[serde(default)]
1062    pub auto_rollback_exit_codes: Vec<i32>,
1063    /// When `true`, snapshot failure aborts execution. Default: `false`.
1064    #[serde(default)]
1065    pub snapshot_required: bool,
1066    /// Maximum cumulative bytes for transaction snapshots. `0` = unlimited.
1067    #[serde(default)]
1068    pub max_snapshot_bytes: u64,
1069    /// Maximum concurrent background shell runs. Default: `8`.
1070    #[serde(default = "default_max_background_runs")]
1071    pub max_background_runs: usize,
1072    /// Timeout in seconds for each background shell run. Default: `1800`.
1073    #[serde(default = "default_background_timeout_secs")]
1074    pub background_timeout_secs: u64,
1075    /// Cumulative risk score threshold for multi-step chain blocking. Default: `0.7`.
1076    ///
1077    /// When the `RiskChainAccumulator` (zeph-tools) exceeds this score within a single turn,
1078    /// the command is blocked. Set to `None` to use the built-in default of `0.7`.
1079    #[serde(default)]
1080    pub risk_chain_threshold: Option<f32>,
1081    /// Enable session-scoped checkpoint history for `/undo` and `/redo`. Default: `false`.
1082    ///
1083    /// When `true`, file snapshots are captured before each write command and stored
1084    /// in an in-memory stack for the duration of the session. Checkpoints are lost
1085    /// when the agent process exits.
1086    #[serde(default)]
1087    pub checkpoints_enabled: bool,
1088    /// Maximum number of checkpoints retained in the undo stack. Default: `20`.
1089    ///
1090    /// When the stack reaches this limit, the oldest entry is evicted to make room.
1091    /// Set to `0` for no limit (not recommended for long-running sessions).
1092    #[serde(default = "default_max_checkpoints")]
1093    pub max_checkpoints: usize,
1094}
1095
1096impl Default for ShellConfig {
1097    fn default() -> Self {
1098        Self {
1099            timeout: default_timeout(),
1100            blocked_commands: Vec::new(),
1101            allowed_commands: Vec::new(),
1102            allowed_paths: Vec::new(),
1103            allow_network: true,
1104            confirm_patterns: default_confirm_patterns(),
1105            env_blocklist: Self::default_env_blocklist(),
1106            transactional: false,
1107            transaction_scope: Vec::new(),
1108            auto_rollback: false,
1109            auto_rollback_exit_codes: Vec::new(),
1110            snapshot_required: false,
1111            max_snapshot_bytes: 0,
1112            max_background_runs: default_max_background_runs(),
1113            background_timeout_secs: default_background_timeout_secs(),
1114            risk_chain_threshold: None,
1115            checkpoints_enabled: false,
1116            max_checkpoints: default_max_checkpoints(),
1117        }
1118    }
1119}
1120
1121impl ShellConfig {
1122    /// Default environment variable prefixes to strip from subprocess environment.
1123    #[must_use]
1124    pub fn default_env_blocklist() -> Vec<String> {
1125        vec![
1126            "ZEPH_".into(),
1127            "AWS_".into(),
1128            "AZURE_".into(),
1129            "GCP_".into(),
1130            "GOOGLE_".into(),
1131            "OPENAI_".into(),
1132            "ANTHROPIC_".into(),
1133            "HF_".into(),
1134            "HUGGING".into(),
1135        ]
1136    }
1137}
1138
1139fn default_scrape_timeout() -> u64 {
1140    15
1141}
1142
1143fn default_max_body_bytes() -> usize {
1144    4_194_304
1145}
1146
1147fn default_ipi_filter_threshold() -> f32 {
1148    0.6
1149}
1150
1151/// Configuration for the web scrape tool.
1152#[derive(Debug, Deserialize, Serialize)]
1153pub struct ScrapeConfig {
1154    /// Timeout in seconds for scrape requests. Default: `15`.
1155    #[serde(default = "default_scrape_timeout")]
1156    pub timeout: u64,
1157    /// Maximum response body bytes. Default: `4 MiB`.
1158    #[serde(default = "default_max_body_bytes")]
1159    pub max_body_bytes: usize,
1160    /// Domain allowlist. Empty = all public domains allowed.
1161    #[serde(default)]
1162    pub allowed_domains: Vec<String>,
1163    /// Domain denylist. Always enforced, regardless of allowlist state.
1164    #[serde(default)]
1165    pub denied_domains: Vec<String>,
1166    /// IPI filter score threshold. Responses with score >= this value get a warning
1167    /// prepended and injection fragments replaced with `[FILTERED]`. Default: `0.6`.
1168    #[serde(default = "default_ipi_filter_threshold")]
1169    pub ipi_filter_threshold: f32,
1170}
1171
1172impl Default for ScrapeConfig {
1173    fn default() -> Self {
1174        Self {
1175            timeout: default_scrape_timeout(),
1176            max_body_bytes: default_max_body_bytes(),
1177            allowed_domains: Vec::new(),
1178            denied_domains: Vec::new(),
1179            ipi_filter_threshold: default_ipi_filter_threshold(),
1180        }
1181    }
1182}
1183
1184fn default_search_backend() -> String {
1185    "brave".to_owned()
1186}
1187
1188fn default_search_vault_key() -> String {
1189    "ZEPH_WEB_SEARCH_API_KEY".to_owned()
1190}
1191
1192fn default_search_endpoint() -> String {
1193    "https://api.search.brave.com/res/v1/web/search".to_owned()
1194}
1195
1196fn default_search_max_results() -> usize {
1197    10
1198}
1199
1200fn default_search_timeout() -> u64 {
1201    15
1202}
1203
1204/// Configuration for the native query-based `web_search` tool (`[tools.search]`).
1205///
1206/// Runtime-gated, not cargo-feature-gated: the tool compiles unconditionally but is only
1207/// advertised to the LLM when `enabled` is `true` AND a backend resolves (see
1208/// `zeph_tools::search::SearchBackend::from_config`). Disabled by default. The API key is
1209/// resolved exclusively from the age vault under `api_key_vault_key` — never from an
1210/// environment variable or a literal in this struct. See
1211/// `specs/006-tools/006-1-web-search.md`.
1212#[derive(Debug, Clone, Deserialize, Serialize)]
1213pub struct SearchConfig {
1214    /// Runtime gate. When `false`, `web_search` is never advertised to the LLM. Default: `false`.
1215    #[serde(default)]
1216    pub enabled: bool,
1217    /// `SearchBackend` variant selector (`zeph-tools`). Default: `"brave"`.
1218    #[serde(default = "default_search_backend")]
1219    pub backend: String,
1220    /// Age-vault key name the API key is resolved from. Never an environment variable.
1221    /// Default: `"ZEPH_WEB_SEARCH_API_KEY"`.
1222    #[serde(default = "default_search_vault_key")]
1223    pub api_key_vault_key: String,
1224    /// Search API endpoint. Override for a self-hosted/proxy/alternate backend.
1225    /// Default: the Brave Search API endpoint.
1226    #[serde(default = "default_search_endpoint")]
1227    pub endpoint: String,
1228    /// Cap on returned results. Default: `10`.
1229    #[serde(default = "default_search_max_results")]
1230    pub max_results: usize,
1231    /// Request timeout in seconds. Default: `15`.
1232    #[serde(default = "default_search_timeout")]
1233    pub timeout: u64,
1234}
1235
1236impl Default for SearchConfig {
1237    fn default() -> Self {
1238        Self {
1239            enabled: false,
1240            backend: default_search_backend(),
1241            api_key_vault_key: default_search_vault_key(),
1242            endpoint: default_search_endpoint(),
1243            max_results: default_search_max_results(),
1244            timeout: default_search_timeout(),
1245        }
1246    }
1247}
1248
1249/// Speculative tool execution mode.
1250#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
1251#[serde(rename_all = "kebab-case")]
1252#[non_exhaustive]
1253pub enum SpeculationMode {
1254    /// No speculation; uses existing synchronous path.
1255    #[default]
1256    Off,
1257    /// LLM-decoding level: fires tools when streaming partial JSON has all required fields.
1258    Decoding,
1259    /// Application-level pattern (PASTE): predicts top-K calls from `SQLite` history.
1260    Pattern,
1261    /// Both decoding and pattern speculation active.
1262    Both,
1263}
1264
1265/// Pattern-based (PASTE) speculative execution config.
1266#[derive(Debug, Clone, Deserialize, Serialize)]
1267pub struct SpeculativePatternConfig {
1268    /// Enable PASTE pattern learning and prediction. Default: `false`.
1269    #[serde(default)]
1270    pub enabled: bool,
1271    /// Minimum observed occurrences before a prediction is issued.
1272    #[serde(default = "default_min_observations")]
1273    pub min_observations: u32,
1274    /// Exponential decay half-life in days for pattern scoring.
1275    #[serde(default = "default_half_life_days")]
1276    pub half_life_days: f64,
1277    /// LLM provider name for optional reranking. Empty = disabled.
1278    #[serde(default)]
1279    pub rerank_provider: ProviderName,
1280}
1281
1282fn default_min_observations() -> u32 {
1283    5
1284}
1285
1286fn default_half_life_days() -> f64 {
1287    14.0
1288}
1289
1290impl Default for SpeculativePatternConfig {
1291    fn default() -> Self {
1292        Self {
1293            enabled: false,
1294            min_observations: default_min_observations(),
1295            half_life_days: default_half_life_days(),
1296            rerank_provider: ProviderName::default(),
1297        }
1298    }
1299}
1300
1301/// Shell command regex allowlist for speculative execution.
1302#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1303pub struct SpeculativeAllowlistConfig {
1304    /// Regexes matched against the full `bash` command string.
1305    #[serde(default)]
1306    pub shell: Vec<String>,
1307}
1308
1309fn default_max_in_flight() -> usize {
1310    4
1311}
1312
1313fn default_confidence_threshold() -> f32 {
1314    0.55
1315}
1316
1317fn default_max_wasted_per_minute() -> u64 {
1318    100
1319}
1320
1321fn default_ttl_seconds() -> u64 {
1322    30
1323}
1324
1325/// Top-level configuration for speculative tool execution.
1326#[derive(Debug, Clone, Deserialize, Serialize)]
1327pub struct SpeculativeConfig {
1328    /// Speculation mode. Default: `off`.
1329    #[serde(default)]
1330    pub mode: SpeculationMode,
1331    /// Maximum concurrent in-flight speculative tasks.
1332    #[serde(default = "default_max_in_flight")]
1333    pub max_in_flight: usize,
1334    /// Minimum confidence score [0, 1] to dispatch a speculative task.
1335    #[serde(default = "default_confidence_threshold")]
1336    pub confidence_threshold: f32,
1337    /// Circuit-breaker: disable speculation for 60 s when wasted ms exceeds this per minute.
1338    #[serde(default = "default_max_wasted_per_minute")]
1339    pub max_wasted_per_minute: u64,
1340    /// Per-handle wall-clock TTL in seconds before the handle is cancelled.
1341    #[serde(default = "default_ttl_seconds")]
1342    pub ttl_seconds: u64,
1343    /// Emit `AuditEntry` for speculative dispatches. Default: `true`.
1344    #[serde(default = "default_true")]
1345    pub audit: bool,
1346    /// PASTE pattern learning config.
1347    #[serde(default)]
1348    pub pattern: SpeculativePatternConfig,
1349    /// Per-executor command allowlists.
1350    #[serde(default)]
1351    pub allowlist: SpeculativeAllowlistConfig,
1352}
1353
1354impl Default for SpeculativeConfig {
1355    fn default() -> Self {
1356        Self {
1357            mode: SpeculationMode::Off,
1358            max_in_flight: default_max_in_flight(),
1359            confidence_threshold: default_confidence_threshold(),
1360            max_wasted_per_minute: default_max_wasted_per_minute(),
1361            ttl_seconds: default_ttl_seconds(),
1362            audit: true,
1363            pattern: SpeculativePatternConfig::default(),
1364            allowlist: SpeculativeAllowlistConfig::default(),
1365        }
1366    }
1367}
1368
1369/// Configuration for egress network event logging.
1370#[derive(Debug, Clone, Deserialize, Serialize)]
1371#[serde(default)]
1372#[allow(clippy::struct_excessive_bools)]
1373pub struct EgressConfig {
1374    /// Master switch for egress event emission. Default: `true`.
1375    pub enabled: bool,
1376    /// Emit events for requests blocked by SSRF/domain/scheme checks. Default: `true`.
1377    pub log_blocked: bool,
1378    /// Include `response_bytes` in the JSONL record. Default: `true`.
1379    pub log_response_bytes: bool,
1380    /// Show real hostname in TUI egress panel. Default: `true`.
1381    pub log_hosts_to_tui: bool,
1382}
1383
1384impl Default for EgressConfig {
1385    fn default() -> Self {
1386        Self {
1387            enabled: true,
1388            log_blocked: true,
1389            log_response_bytes: true,
1390            log_hosts_to_tui: true,
1391        }
1392    }
1393}
1394
1395// ── ToolCompressionConfig ─────────────────────────────────────────────────────
1396
1397fn default_compression_min_lines() -> usize {
1398    10
1399}
1400
1401fn default_compression_max_rules() -> u32 {
1402    200
1403}
1404
1405fn default_regex_compile_timeout_ms() -> u64 {
1406    500
1407}
1408
1409fn default_evolution_min_interval_secs() -> u64 {
1410    3600
1411}
1412
1413/// TACO self-evolving tool output compression configuration (`[tools.compression]` TOML section).
1414///
1415/// When enabled, a `RuleBasedCompressor` is wrapped around the root tool executor.
1416/// Rules are loaded from the `compression_rules` `SQLite` table and optionally evolved by an
1417/// LLM provider specified in `evolution_provider`.
1418///
1419/// # Example (TOML)
1420///
1421/// ```toml
1422/// [tools.compression]
1423/// enabled = true
1424/// evolution_provider = "fast"
1425/// min_lines_to_compress = 15
1426/// ```
1427#[derive(Debug, Clone, Deserialize, Serialize)]
1428#[serde(default)]
1429pub struct ToolCompressionConfig {
1430    /// Enable rule-based tool output compression. Default: `false`.
1431    pub enabled: bool,
1432    /// Minimum output line count before compression is attempted. Default: `10`.
1433    #[serde(default = "default_compression_min_lines")]
1434    pub min_lines_to_compress: usize,
1435    /// LLM provider name for self-evolution. Empty string = evolution disabled. Default: `""`.
1436    #[serde(default)]
1437    pub evolution_provider: ProviderName,
1438    /// Minimum interval in seconds between self-evolution runs. Default: `3600`.
1439    #[serde(default = "default_evolution_min_interval_secs")]
1440    pub evolution_min_interval_secs: u64,
1441    /// Maximum number of rules to keep in the DB (prune lowest-hit rules above this). Default: `200`.
1442    #[serde(default = "default_compression_max_rules")]
1443    pub max_rules: u32,
1444    /// Timeout in milliseconds for safe regex compilation. Default: `500`.
1445    #[serde(default = "default_regex_compile_timeout_ms")]
1446    pub regex_compile_timeout_ms: u64,
1447}
1448
1449impl Default for ToolCompressionConfig {
1450    fn default() -> Self {
1451        Self {
1452            enabled: false,
1453            min_lines_to_compress: default_compression_min_lines(),
1454            evolution_provider: ProviderName::default(),
1455            evolution_min_interval_secs: default_evolution_min_interval_secs(),
1456            max_rules: default_compression_max_rules(),
1457            regex_compile_timeout_ms: default_regex_compile_timeout_ms(),
1458        }
1459    }
1460}
1461
1462// ── ToolsConfig ───────────────────────────────────────────────────────────────
1463
1464/// Top-level configuration for tool execution.
1465///
1466/// Deserialized from `[tools]` in TOML. The `permission_policy()` method (which constructs
1467/// a runtime `PermissionPolicy`) lives in `zeph-tools` as a free function to avoid
1468/// importing runtime types into this leaf crate.
1469#[derive(Debug, Deserialize, Serialize)]
1470pub struct ToolsConfig {
1471    /// Enable all tools. When `false`, no tool definitions are sent to the LLM and the model
1472    /// cannot attempt any tool call. Default: `true`.
1473    #[serde(default = "default_true")]
1474    pub enabled: bool,
1475    /// Summarize long tool output before injection into context. Default: `true`.
1476    #[serde(default = "default_true")]
1477    pub summarize_output: bool,
1478    /// Shell tool configuration.
1479    #[serde(default)]
1480    pub shell: ShellConfig,
1481    /// Web scrape tool configuration.
1482    #[serde(default)]
1483    pub scrape: ScrapeConfig,
1484    /// Native query-based web search tool configuration.
1485    #[serde(default)]
1486    pub search: SearchConfig,
1487    /// Audit log configuration.
1488    #[serde(default)]
1489    pub audit: AuditConfig,
1490    /// Declarative permissions. Overrides legacy `shell.blocked_commands` when set.
1491    #[serde(default)]
1492    pub permissions: Option<PermissionsConfig>,
1493    /// Output filter configuration.
1494    #[serde(default)]
1495    pub filters: FilterConfig,
1496    /// Large response offload configuration.
1497    #[serde(default)]
1498    pub overflow: OverflowConfig,
1499    /// Sliding-window anomaly detector.
1500    #[serde(default)]
1501    pub anomaly: AnomalyConfig,
1502    /// Tool result cache.
1503    #[serde(default)]
1504    pub result_cache: ResultCacheConfig,
1505    /// Think-Augmented Function Calling.
1506    #[serde(default)]
1507    pub tafc: TafcConfig,
1508    /// Tool dependency graph.
1509    #[serde(default)]
1510    pub dependencies: DependencyConfig,
1511    /// Error retry configuration.
1512    #[serde(default)]
1513    pub retry: RetryConfig,
1514    /// Declarative policy compiler for tool call authorization.
1515    #[serde(default)]
1516    pub policy: PolicyConfig,
1517    /// LLM-based adversarial policy agent.
1518    #[serde(default)]
1519    pub adversarial_policy: AdversarialPolicyConfig,
1520    /// Utility-guided tool dispatch gate.
1521    #[serde(default)]
1522    pub utility: UtilityScoringConfig,
1523    /// Per-path read allow/deny sandbox for the file tool.
1524    #[serde(default)]
1525    pub file: FileConfig,
1526    /// OAP declarative pre-action authorization.
1527    #[serde(default)]
1528    pub authorization: AuthorizationConfig,
1529    /// Maximum tool calls allowed per agent session. `None` = unlimited.
1530    #[serde(default)]
1531    pub max_tool_calls_per_session: Option<u32>,
1532    /// Speculative tool execution configuration.
1533    #[serde(default)]
1534    pub speculative: SpeculativeConfig,
1535    /// OS-level subprocess sandbox configuration.
1536    #[serde(default)]
1537    pub sandbox: SandboxConfig,
1538    /// Egress network event logging configuration.
1539    #[serde(default)]
1540    pub egress: EgressConfig,
1541    /// TACO self-evolving tool output compression configuration.
1542    #[serde(default)]
1543    pub compression: ToolCompressionConfig,
1544}
1545
1546impl Default for ToolsConfig {
1547    fn default() -> Self {
1548        Self {
1549            enabled: true,
1550            summarize_output: true,
1551            shell: ShellConfig::default(),
1552            scrape: ScrapeConfig::default(),
1553            search: SearchConfig::default(),
1554            audit: AuditConfig::default(),
1555            permissions: None,
1556            filters: FilterConfig::default(),
1557            overflow: OverflowConfig::default(),
1558            anomaly: AnomalyConfig::default(),
1559            result_cache: ResultCacheConfig::default(),
1560            tafc: TafcConfig::default(),
1561            dependencies: DependencyConfig::default(),
1562            retry: RetryConfig::default(),
1563            policy: PolicyConfig::default(),
1564            adversarial_policy: AdversarialPolicyConfig::default(),
1565            utility: UtilityScoringConfig::default(),
1566            file: FileConfig::default(),
1567            authorization: AuthorizationConfig::default(),
1568            max_tool_calls_per_session: None,
1569            speculative: SpeculativeConfig::default(),
1570            sandbox: SandboxConfig::default(),
1571            egress: EgressConfig::default(),
1572            compression: ToolCompressionConfig::default(),
1573        }
1574    }
1575}
1576
1577#[cfg(test)]
1578mod tests {
1579    use super::*;
1580
1581    #[test]
1582    fn deserialize_default_config() {
1583        let toml_str = r#"
1584            enabled = true
1585
1586            [shell]
1587            timeout = 60
1588            blocked_commands = ["rm -rf /", "sudo"]
1589        "#;
1590
1591        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
1592        assert!(config.enabled);
1593        assert_eq!(config.shell.timeout, 60);
1594        assert_eq!(config.shell.blocked_commands.len(), 2);
1595    }
1596
1597    #[test]
1598    fn empty_blocked_commands() {
1599        let config: ToolsConfig = toml::from_str(r"[shell]\ntimeout = 30\n").unwrap_or_default();
1600        assert!(config.enabled);
1601    }
1602
1603    #[test]
1604    fn default_tools_config() {
1605        let config = ToolsConfig::default();
1606        assert!(config.enabled);
1607        assert!(config.summarize_output);
1608        assert_eq!(config.shell.timeout, 30);
1609        assert!(config.shell.blocked_commands.is_empty());
1610        assert!(config.audit.enabled);
1611    }
1612
1613    #[test]
1614    fn audit_destination_serde_roundtrip() {
1615        let cases = [
1616            ("\"stdout\"", AuditDestination::Stdout),
1617            ("\"stderr\"", AuditDestination::Stderr),
1618            (
1619                "\"/var/log/audit.log\"",
1620                AuditDestination::File("/var/log/audit.log".into()),
1621            ),
1622        ];
1623        for (json_str, expected) in cases {
1624            let got: AuditDestination = serde_json::from_str(json_str).unwrap();
1625            assert_eq!(got, expected);
1626            let serialized = serde_json::to_string(&got).unwrap();
1627            let roundtrip: AuditDestination = serde_json::from_str(&serialized).unwrap();
1628            assert_eq!(roundtrip, expected);
1629        }
1630    }
1631
1632    #[test]
1633    fn audit_destination_toml_in_config() {
1634        let cases = [
1635            (
1636                r#"[audit]
1637destination = "stdout""#,
1638                AuditDestination::Stdout,
1639            ),
1640            (
1641                r#"[audit]
1642destination = "stderr""#,
1643                AuditDestination::Stderr,
1644            ),
1645            (
1646                r#"[audit]
1647destination = "/var/log/zeph-audit.log""#,
1648                AuditDestination::File("/var/log/zeph-audit.log".into()),
1649            ),
1650        ];
1651        for (toml_str, expected) in cases {
1652            let config: ToolsConfig = toml::from_str(toml_str).unwrap();
1653            assert_eq!(config.audit.destination, expected);
1654        }
1655    }
1656
1657    #[test]
1658    fn policy_provider_serde_roundtrip() {
1659        let toml_str = r#"
1660            [policy]
1661            enabled = true
1662            policy_provider = "my-llm"
1663        "#;
1664        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
1665        assert_eq!(config.policy.policy_provider.as_str(), "my-llm");
1666
1667        let json = serde_json::to_string(&config.policy).unwrap();
1668        let back: PolicyConfig = serde_json::from_str(&json).unwrap();
1669        assert_eq!(back.policy_provider.as_str(), "my-llm");
1670    }
1671
1672    #[test]
1673    fn policy_provider_default_is_empty() {
1674        let config = PolicyConfig::default();
1675        assert!(config.policy_provider.is_empty());
1676    }
1677
1678    #[test]
1679    fn utility_window_default_is_zero() {
1680        let config = UtilityScoringConfig::default();
1681        assert_eq!(config.utility_window, 0);
1682    }
1683
1684    #[test]
1685    fn utility_window_serde_roundtrip() {
1686        #[derive(serde::Deserialize)]
1687        struct Wrapper {
1688            utility_scoring: UtilityScoringConfig,
1689        }
1690
1691        let toml_str = "[utility_scoring]\nenabled = true\nutility_window = 3\n";
1692        let w: Wrapper = toml::from_str(toml_str).unwrap();
1693        assert_eq!(w.utility_scoring.utility_window, 3);
1694
1695        let json = serde_json::to_string(&w.utility_scoring).unwrap();
1696        let back: UtilityScoringConfig = serde_json::from_str(&json).unwrap();
1697        assert_eq!(back.utility_window, 3);
1698    }
1699
1700    #[test]
1701    fn high_gain_tools_default_is_empty() {
1702        let config = UtilityScoringConfig::default();
1703        assert!(config.high_gain_tools.is_empty());
1704    }
1705
1706    #[test]
1707    fn high_gain_tools_serde_roundtrip() {
1708        #[derive(serde::Deserialize)]
1709        struct Wrapper {
1710            utility_scoring: UtilityScoringConfig,
1711        }
1712
1713        let toml_str =
1714            "[utility_scoring]\nenabled = true\nhigh_gain_tools = [\"github_create_issue\"]\n";
1715        let w: Wrapper = toml::from_str(toml_str).unwrap();
1716        assert_eq!(
1717            w.utility_scoring.high_gain_tools,
1718            vec!["github_create_issue"]
1719        );
1720
1721        let json = serde_json::to_string(&w.utility_scoring).unwrap();
1722        let back: UtilityScoringConfig = serde_json::from_str(&json).unwrap();
1723        assert_eq!(back.high_gain_tools, vec!["github_create_issue"]);
1724    }
1725
1726    #[test]
1727    fn adversarial_policy_provider_default_is_empty() {
1728        let config = AdversarialPolicyConfig::default();
1729        assert!(config.policy_provider.is_empty());
1730    }
1731
1732    #[test]
1733    fn adversarial_policy_provider_serde_roundtrip() {
1734        #[derive(serde::Deserialize)]
1735        struct Wrapper {
1736            adversarial_policy: AdversarialPolicyConfig,
1737        }
1738        let toml_str = r#"
1739            [adversarial_policy]
1740            enabled = true
1741            policy_provider = "fast-llm"
1742        "#;
1743        let w: Wrapper = toml::from_str(toml_str).unwrap();
1744        assert_eq!(w.adversarial_policy.policy_provider.as_str(), "fast-llm");
1745
1746        let json = serde_json::to_string(&w.adversarial_policy).unwrap();
1747        let back: AdversarialPolicyConfig = serde_json::from_str(&json).unwrap();
1748        assert_eq!(back.policy_provider.as_str(), "fast-llm");
1749    }
1750
1751    #[test]
1752    fn adversarial_policy_provider_empty_when_omitted() {
1753        #[derive(serde::Deserialize)]
1754        struct Wrapper {
1755            adversarial_policy: AdversarialPolicyConfig,
1756        }
1757        let toml_str = r"
1758            [adversarial_policy]
1759            enabled = true
1760        ";
1761        let w: Wrapper = toml::from_str(toml_str).unwrap();
1762        assert!(
1763            w.adversarial_policy.policy_provider.is_empty(),
1764            "omitted policy_provider must default to empty (→ primary provider used)"
1765        );
1766    }
1767
1768    #[test]
1769    fn adversarial_policy_timeout_ms_defaults_to_none() {
1770        // None means "auto-scale by resolved policy_provider kind" — see #5870.
1771        let config = AdversarialPolicyConfig::default();
1772        assert_eq!(config.timeout_ms, None);
1773    }
1774
1775    #[test]
1776    fn adversarial_policy_timeout_ms_explicit_override_roundtrips() {
1777        #[derive(serde::Deserialize)]
1778        struct Wrapper {
1779            adversarial_policy: AdversarialPolicyConfig,
1780        }
1781        let toml_str = r"
1782            [adversarial_policy]
1783            enabled = true
1784            timeout_ms = 90000
1785        ";
1786        let w: Wrapper = toml::from_str(toml_str).unwrap();
1787        assert_eq!(w.adversarial_policy.timeout_ms, Some(90_000));
1788
1789        let json = serde_json::to_string(&w.adversarial_policy).unwrap();
1790        let back: AdversarialPolicyConfig = serde_json::from_str(&json).unwrap();
1791        assert_eq!(back.timeout_ms, Some(90_000));
1792    }
1793
1794    #[test]
1795    fn adversarial_timeout_for_provider_kind_scales_local_vs_cloud() {
1796        assert_eq!(adversarial_timeout_for_provider_kind("ollama"), 45_000);
1797        assert_eq!(adversarial_timeout_for_provider_kind("candle"), 45_000);
1798        assert_eq!(adversarial_timeout_for_provider_kind("local"), 45_000);
1799        assert_eq!(adversarial_timeout_for_provider_kind("cloud"), 3_000);
1800        assert_eq!(adversarial_timeout_for_provider_kind("unknown"), 3_000);
1801    }
1802}