Skip to main content

apollo/config/
mod.rs

1//! Configuration management.
2
3use serde::{Deserialize, Serialize};
4use std::path::PathBuf;
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
7#[serde(default)]
8pub struct Config {
9    pub provider: ProviderConfig,
10    pub embeddings: EmbeddingsConfig,
11    pub agent: AgentConfig,
12    pub model: String,
13    pub system_prompt: String,
14    pub workspace: PathBuf,
15    pub storage: StorageConfig,
16    pub runtime: RuntimeConfig,
17    pub observability: ObservabilityConfig,
18    pub channel: ChannelConfig,
19    pub policy: PolicyConfig,
20    pub plugin_layer: PluginLayerConfig,
21    pub group_chat: GroupChatConfig,
22    pub toolsets: ToolsetConfig,
23    pub memory: MemoryIdeasConfig,
24    #[serde(default)]
25    pub zkr: ZkrConfig,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
29#[serde(default)]
30pub struct ProviderConfig {
31    pub name: String,
32    pub api_key: Option<String>,
33    pub base_url: Option<String>,
34    /// Let the provider run web search on its own infrastructure instead of
35    /// apollo's `web_search` tool. Anthropic only; billed by the provider.
36    pub native_web_search: bool,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
40#[serde(default)]
41pub struct EmbeddingsConfig {
42    pub enabled: bool,
43    pub provider: String,
44    pub api_key: Option<String>,
45    pub model: Option<String>,
46    pub base_url: Option<String>,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
50#[serde(default)]
51pub struct AgentConfig {
52    /// Hard circuit breaker (absolute max execution rounds)
53    pub max_rounds: usize,
54    /// Max conversation history (prevents context overflow)
55    pub max_history_messages: usize,
56    /// Max chars for a single tool result
57    pub max_tool_result_chars: usize,
58    /// Max context chars before triggering mid-loop compaction
59    pub max_context_chars: usize,
60    /// Fast/cheap model for planning + summarization
61    pub fast_model: String,
62    /// Heavy model for complex coding/reasoning
63    pub heavy_model: String,
64    /// Per-tool allow/deny rules
65    pub permissions: PermissionRulesConfig,
66    /// Initial safety profile: `full`, `auto`, `prompt`, or `tools_only` (drives default `AgentMode`).
67    pub permission_profile: String,
68    /// rx4 auto-compaction threshold. `0` leaves compaction off (the bridge
69    /// default); a non-zero value is forwarded to `Agent::auto_compact_after`,
70    /// which rx4 interprets as an estimated-token cutoff before each prompt.
71    pub auto_compact_after: usize,
72}
73
74impl Default for AgentConfig {
75    fn default() -> Self {
76        Self {
77            max_rounds: 50,
78            max_history_messages: 10,
79            max_tool_result_chars: 20_000,
80            max_context_chars: 150_000,
81            fast_model: "gpt-5.4-mini".to_string(),
82            heavy_model: "gpt-5.5".to_string(),
83            permissions: PermissionRulesConfig::default(),
84            permission_profile: "auto".to_string(),
85            auto_compact_after: 0,
86        }
87    }
88}
89
90/// Per-tool permission rules.
91///
92/// `deny` blocks matching tools outright (checked first).
93/// `allow` restricts to only those tools when non-empty (allowlist mode).
94/// Supports exact tool names and glob-style `*` wildcards.
95#[derive(Debug, Clone, Default, Serialize, Deserialize)]
96#[serde(default)]
97pub struct PermissionRulesConfig {
98    /// Tools that are always blocked (e.g. `["exec", "shell"]`).
99    pub deny: Vec<String>,
100    /// If non-empty, only these tools are allowed (allowlist mode).
101    pub allow: Vec<String>,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
105#[serde(default)]
106pub struct RuntimeConfig {
107    pub kind: String, // "native", "docker"
108    pub docker_image: Option<String>,
109    pub memory_limit_mb: Option<u64>,
110    pub state_path: Option<PathBuf>,
111    pub self_update: SelfUpdateConfig,
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
115#[serde(default)]
116pub struct SelfUpdateConfig {
117    pub enabled: bool,
118    pub interval_secs: u64,
119    pub remote: String,
120    pub branch: String,
121    pub restart_service: Option<String>,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
125#[serde(default)]
126pub struct StorageConfig {
127    pub backend: String, // "surreal"
128    pub root: PathBuf,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
132#[serde(default)]
133pub struct ObservabilityConfig {
134    pub service_name: String,
135    pub environment: String,
136    pub json_logs: bool,
137    pub trace_header_name: String,
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize)]
141#[serde(default)]
142pub struct ChannelConfig {
143    pub kind: String, // "cli", "telegram", "discord", "websocket"
144    pub token: Option<String>,
145    /// When non-empty, only these Telegram (or channel) chat IDs may send inbound messages.
146    pub allowed_chat_ids: Vec<String>,
147    /// When non-empty, only these sender user IDs may send inbound messages.
148    pub allowed_sender_ids: Vec<String>,
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize)]
152#[serde(default)]
153pub struct PolicyConfig {
154    pub allow_shell: bool,
155    pub allow_dynamic_tools: bool,
156    pub allow_plugin_shell: bool,
157    pub allow_plugin_git: bool,
158    pub allow_computer_use: bool,
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize)]
162#[serde(default)]
163pub struct PluginLayerConfig {
164    pub enabled: bool,
165    #[serde(default = "default_manifest_path")]
166    pub manifest_path: PathBuf,
167    /// Extra directories to scan for OpenClaw SKILL.md and Hermes plugin.json
168    #[serde(default)]
169    pub host_plugin_roots: Vec<PathBuf>,
170    pub hook_events: Vec<String>,
171    pub allow_core_fallback: bool,
172    pub layered_overrides: Vec<String>,
173}
174
175#[derive(Debug, Clone, Serialize, Deserialize)]
176#[serde(default)]
177pub struct GroupChatConfig {
178    pub enable_ambient_questions: bool,
179    pub rolling_memory_namespace: String,
180    pub rolling_memory_max_chars: usize,
181    pub rolling_memory_recent_turns: usize,
182    pub ambient_question_window: usize,
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize, Default)]
186#[serde(default)]
187pub struct ToolsetConfig {
188    pub enabled: Vec<String>,
189    pub disabled: Vec<String>,
190}
191
192#[derive(Debug, Clone, Serialize, Deserialize)]
193#[serde(default)]
194pub struct MemoryIdeasConfig {
195    /// One logical user across channels (merged history when set).
196    pub principal_id: Option<String>,
197    /// Inject brief + recall gate + idea graph context each turn.
198    pub inject_context: bool,
199    pub graph_recall_limit: usize,
200    /// Route heartbeat synthetic messages to this chat_id (e.g. telegram chat id).
201    pub heartbeat_chat_id: Option<String>,
202    /// After idle, expand open loops into dream nodes (graph).
203    pub dream_on_heartbeat: bool,
204}
205
206impl Default for MemoryIdeasConfig {
207    fn default() -> Self {
208        Self {
209            principal_id: None,
210            inject_context: true,
211            graph_recall_limit: 5,
212            heartbeat_chat_id: None,
213            dream_on_heartbeat: false,
214        }
215    }
216}
217
218#[derive(Debug, Clone, Serialize, Deserialize)]
219#[serde(default)]
220pub struct ZkrConfig {
221    pub enabled: bool,
222    pub database: PathBuf,
223    pub tenant_id: String,
224    pub person_id: String,
225    pub auto_capture: bool,
226    pub inject_recall: bool,
227    pub recall_limit: u32,
228    pub self_improve: bool,
229}
230
231impl Default for ZkrConfig {
232    fn default() -> Self {
233        Self {
234            enabled: true,
235            database: PathBuf::from(".apollo/zkr.db"),
236            tenant_id: "apollo".to_string(),
237            person_id: "local".to_string(),
238            auto_capture: true,
239            inject_recall: true,
240            recall_limit: 5,
241            self_improve: true,
242        }
243    }
244}
245
246/// Apply a named onboarding permission profile to `cfg` (policy, toolsets, and `permission_profile`).
247pub fn apply_permission_profile(cfg: &mut Config, profile: &str) {
248    let p = profile.trim().to_ascii_lowercase().replace(['-', ' '], "_");
249    match p.as_str() {
250        "full" => {
251            cfg.agent.permission_profile = "full".to_string();
252            cfg.policy.allow_shell = true;
253            cfg.policy.allow_dynamic_tools = true;
254            cfg.policy.allow_computer_use = true;
255            cfg.toolsets = ToolsetConfig::default();
256            cfg.agent.permissions = PermissionRulesConfig::default();
257        }
258        "auto" => {
259            cfg.agent.permission_profile = "auto".to_string();
260            cfg.policy = PolicyConfig::default();
261            cfg.toolsets = ToolsetConfig::default();
262            cfg.agent.permissions = PermissionRulesConfig::default();
263        }
264        "prompt" => {
265            cfg.agent.permission_profile = "prompt".to_string();
266            cfg.policy = PolicyConfig::default();
267            cfg.toolsets = ToolsetConfig::default();
268            cfg.agent.permissions = PermissionRulesConfig::default();
269        }
270        "tools_only" | "tools" => {
271            cfg.agent.permission_profile = "tools_only".to_string();
272            cfg.policy.allow_shell = false;
273            cfg.policy.allow_dynamic_tools = false;
274            cfg.policy.allow_computer_use = false;
275            cfg.toolsets.enabled = vec![
276                "web".to_string(),
277                "memory".to_string(),
278                "sessions".to_string(),
279            ];
280            cfg.toolsets.disabled = vec![
281                "browser".to_string(),
282                "vibemania".to_string(),
283                "create_tool".to_string(),
284                "mcp".to_string(),
285            ];
286            cfg.agent.permissions = PermissionRulesConfig::default();
287        }
288        _ => {
289            cfg.agent.permission_profile = "auto".to_string();
290            cfg.policy = PolicyConfig::default();
291            cfg.toolsets = ToolsetConfig::default();
292            cfg.agent.permissions = PermissionRulesConfig::default();
293        }
294    }
295}
296
297/// Leaf field names whose values must never be printed.
298const SECRET_LEAF_KEYS: &[&str] = &["api_key", "token", "secret", "password"];
299
300/// Whether a leaf field name holds a credential.
301pub fn is_secret_key(leaf: &str) -> bool {
302    let leaf = leaf.to_ascii_lowercase();
303    SECRET_LEAF_KEYS
304        .iter()
305        .any(|s| leaf == *s || leaf.ends_with(&format!("_{s}")))
306}
307
308/// Replace every credential-bearing leaf with a fixed mask, in place.
309///
310/// Empty strings and nulls stay as they are: "unset" is not a secret, and
311/// showing a mask for one would be a lie.
312pub fn mask_secrets(value: &mut serde_json::Value) {
313    match value {
314        serde_json::Value::Object(map) => {
315            for (key, child) in map.iter_mut() {
316                if is_secret_key(key) {
317                    if let serde_json::Value::String(s) = child {
318                        if !s.is_empty() {
319                            *child = serde_json::Value::String("********".to_string());
320                            continue;
321                        }
322                    }
323                }
324                mask_secrets(child);
325            }
326        }
327        serde_json::Value::Array(items) => {
328            for item in items {
329                mask_secrets(item);
330            }
331        }
332        _ => {}
333    }
334}
335
336fn lookup<'a>(root: &'a serde_json::Value, key: &str) -> anyhow::Result<&'a serde_json::Value> {
337    let mut current = root;
338    let mut walked: Vec<&str> = Vec::new();
339    for segment in key.split('.') {
340        let object = current.as_object().ok_or_else(|| {
341            anyhow::anyhow!(
342                "unknown config key `{key}`: `{}` is not a section",
343                walked.join(".")
344            )
345        })?;
346        current = object.get(segment).ok_or_else(|| {
347            let mut names: Vec<&str> = object.keys().map(|k| k.as_str()).collect();
348            names.sort_unstable();
349            anyhow::anyhow!(
350                "unknown config key `{key}`. Available under `{}`: {}",
351                if walked.is_empty() {
352                    "<root>".to_string()
353                } else {
354                    walked.join(".")
355                },
356                names.join(", ")
357            )
358        })?;
359        walked.push(segment);
360    }
361    Ok(current)
362}
363
364fn coerce(existing: &serde_json::Value, input: &str) -> anyhow::Result<serde_json::Value> {
365    use serde_json::Value;
366    match existing {
367        Value::String(_) => Ok(Value::String(input.to_string())),
368        Value::Bool(_) => input
369            .parse::<bool>()
370            .map(Value::Bool)
371            .map_err(|_| anyhow::anyhow!("expected `true` or `false`, got `{input}`")),
372        Value::Number(n) => {
373            if n.is_f64() {
374                input
375                    .parse::<f64>()
376                    .ok()
377                    .and_then(serde_json::Number::from_f64)
378                    .map(Value::Number)
379                    .ok_or_else(|| anyhow::anyhow!("expected a number, got `{input}`"))
380            } else {
381                input
382                    .parse::<i64>()
383                    .map(|v| Value::Number(v.into()))
384                    .map_err(|_| anyhow::anyhow!("expected an integer, got `{input}`"))
385            }
386        }
387        Value::Array(_) | Value::Object(_) => serde_json::from_str(input)
388            .map_err(|e| anyhow::anyhow!("expected JSON matching the existing value: {e}")),
389        Value::Null => Ok(serde_json::from_str(input).unwrap_or(Value::String(input.to_string()))),
390    }
391}
392
393fn assign(root: &mut serde_json::Value, key: &str, value: serde_json::Value) {
394    let segments: Vec<&str> = key.split('.').collect();
395    let mut current = root;
396    for segment in &segments[..segments.len() - 1] {
397        if !current.is_object() {
398            *current = serde_json::Value::Object(serde_json::Map::new());
399        }
400        current = current
401            .as_object_mut()
402            .expect("object")
403            .entry((*segment).to_string())
404            .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
405    }
406    if !current.is_object() {
407        *current = serde_json::Value::Object(serde_json::Map::new());
408    }
409    current
410        .as_object_mut()
411        .expect("object")
412        .insert(segments[segments.len() - 1].to_string(), value);
413}
414
415impl Config {
416    /// Read a dotted path (`agent.max_rounds`, `provider.name`, `model`) out of the
417    /// effective configuration.
418    pub fn get_path(&self, key: &str) -> anyhow::Result<serde_json::Value> {
419        if key.trim().is_empty() {
420            anyhow::bail!("empty config key");
421        }
422        let root = serde_json::to_value(self)?;
423        lookup(&root, key).cloned()
424    }
425
426    /// Coerce `value` to the type the schema already uses at `key`, apply it,
427    /// and round-trip the result through `Config` so an invalid write is
428    /// rejected before it can reach disk.
429    ///
430    /// Returns the updated config and the JSON value that was written, so a
431    /// caller can splice just that path into the on-disk file.
432    pub fn set_path(&self, key: &str, value: &str) -> anyhow::Result<(Config, serde_json::Value)> {
433        if key.trim().is_empty() {
434            anyhow::bail!("empty config key");
435        }
436        let mut root = serde_json::to_value(self)?;
437        let existing = lookup(&root, key)?;
438        let coerced = coerce(existing, value)
439            .map_err(|e| anyhow::anyhow!("invalid value for `{key}`: {e}"))?;
440        assign(&mut root, key, coerced.clone());
441        let updated: Config = serde_json::from_value(root)
442            .map_err(|e| anyhow::anyhow!("invalid value for `{key}`: {e}"))?;
443        Ok((updated, coerced))
444    }
445
446    /// Splice a validated value into a raw config document, leaving every other
447    /// key in the file untouched.
448    pub fn splice_into_raw(raw: &mut serde_json::Value, key: &str, value: serde_json::Value) {
449        assign(raw, key, value);
450    }
451
452    pub fn load(path: &str) -> anyhow::Result<Self> {
453        let content = std::fs::read_to_string(path)?;
454        let config: Config = serde_json::from_str(&content)?;
455        Ok(config)
456    }
457
458    pub fn default_config() -> Self {
459        Self {
460            provider: ProviderConfig::default(),
461            embeddings: EmbeddingsConfig::default(),
462            agent: AgentConfig::default(),
463            model: "gpt-5.5".to_string(),
464            system_prompt: "You are a helpful AI assistant.".to_string(),
465            workspace: PathBuf::from("."),
466            storage: StorageConfig::default(),
467            runtime: RuntimeConfig::default(),
468            observability: ObservabilityConfig::default(),
469            channel: ChannelConfig::default(),
470            policy: PolicyConfig::default(),
471            plugin_layer: PluginLayerConfig::default(),
472            group_chat: GroupChatConfig::default(),
473            toolsets: ToolsetConfig::default(),
474            memory: MemoryIdeasConfig::default(),
475            zkr: ZkrConfig::default(),
476        }
477    }
478}
479
480impl Default for Config {
481    fn default() -> Self {
482        Self::default_config()
483    }
484}
485
486impl Default for ProviderConfig {
487    fn default() -> Self {
488        Self {
489            name: "chatgpt".to_string(),
490            api_key: None,
491            base_url: None,
492            native_web_search: false,
493        }
494    }
495}
496
497impl Default for EmbeddingsConfig {
498    fn default() -> Self {
499        Self {
500            enabled: false,
501            provider: "noop".to_string(),
502            api_key: None,
503            model: None,
504            base_url: None,
505        }
506    }
507}
508
509impl Default for RuntimeConfig {
510    fn default() -> Self {
511        Self {
512            kind: "native".to_string(),
513            docker_image: None,
514            memory_limit_mb: None,
515            state_path: None,
516            self_update: SelfUpdateConfig::default(),
517        }
518    }
519}
520
521impl Default for SelfUpdateConfig {
522    fn default() -> Self {
523        Self {
524            enabled: false,
525            interval_secs: 900,
526            remote: "origin".to_string(),
527            branch: "main".to_string(),
528            restart_service: Some("apollo".to_string()),
529        }
530    }
531}
532
533impl Default for StorageConfig {
534    fn default() -> Self {
535        Self {
536            backend: "surreal".to_string(),
537            root: PathBuf::from(".apollo"),
538        }
539    }
540}
541
542impl Default for ObservabilityConfig {
543    fn default() -> Self {
544        Self {
545            service_name: "apollo".to_string(),
546            environment: "development".to_string(),
547            json_logs: false,
548            trace_header_name: "traceparent".to_string(),
549        }
550    }
551}
552
553impl Default for ChannelConfig {
554    fn default() -> Self {
555        Self {
556            kind: "cli".to_string(),
557            token: None,
558            allowed_chat_ids: Vec::new(),
559            allowed_sender_ids: Vec::new(),
560        }
561    }
562}
563
564impl Default for PolicyConfig {
565    fn default() -> Self {
566        Self {
567            allow_shell: true,
568            allow_dynamic_tools: true,
569            allow_plugin_shell: true,
570            allow_plugin_git: true,
571            allow_computer_use: true,
572        }
573    }
574}
575
576fn default_manifest_path() -> PathBuf {
577    PathBuf::from("plugins/manifest.json")
578}
579
580impl Default for PluginLayerConfig {
581    fn default() -> Self {
582        Self {
583            enabled: true,
584            manifest_path: default_manifest_path(),
585            host_plugin_roots: Vec::new(),
586            hook_events: vec![
587                "before_message".to_string(),
588                "after_message".to_string(),
589                "before_tool".to_string(),
590                "after_tool".to_string(),
591            ],
592            allow_core_fallback: true,
593            layered_overrides: vec!["system_prompt".to_string(), "toolsets".to_string()],
594        }
595    }
596}
597
598impl Default for GroupChatConfig {
599    fn default() -> Self {
600        Self {
601            enable_ambient_questions: true,
602            rolling_memory_namespace: "group_memory".to_string(),
603            rolling_memory_max_chars: 6_000,
604            rolling_memory_recent_turns: 16,
605            ambient_question_window: 24,
606        }
607    }
608}
609
610#[cfg(test)]
611mod config_path_tests {
612    use super::*;
613
614    #[test]
615    fn get_reads_nested_and_top_level_keys() {
616        let cfg = Config::default_config();
617        assert_eq!(cfg.get_path("model").unwrap(), cfg.model.as_str());
618        assert_eq!(cfg.get_path("provider.name").unwrap(), "chatgpt");
619        assert_eq!(cfg.get_path("agent.max_rounds").unwrap(), 50);
620    }
621
622    #[test]
623    fn set_updates_strings_bools_and_numbers() {
624        let cfg = Config::default_config();
625        let (cfg, _) = cfg.set_path("policy.allow_shell", "false").unwrap();
626        assert!(!cfg.policy.allow_shell);
627        let (cfg, written) = cfg.set_path("agent.max_rounds", "12").unwrap();
628        assert_eq!(cfg.agent.max_rounds, 12);
629        assert_eq!(written, 12);
630    }
631
632    #[test]
633    fn set_fills_an_optional_field_that_was_null() {
634        let cfg = Config::default_config();
635        let (cfg, _) = cfg
636            .set_path("provider.base_url", "http://localhost:11434")
637            .unwrap();
638        assert_eq!(
639            cfg.provider.base_url.as_deref(),
640            Some("http://localhost:11434")
641        );
642    }
643
644    #[test]
645    fn unknown_keys_are_rejected_with_the_available_names() {
646        let cfg = Config::default_config();
647        let err = cfg.set_path("agent.nope", "1").unwrap_err().to_string();
648        assert!(err.contains("unknown config key `agent.nope`"), "{err}");
649        assert!(err.contains("max_rounds"), "{err}");
650
651        let err = cfg.get_path("not_a_section.x").unwrap_err().to_string();
652        assert!(err.contains("unknown config key"), "{err}");
653    }
654
655    #[test]
656    fn wrong_typed_values_are_rejected_before_they_reach_disk() {
657        let cfg = Config::default_config();
658        let err = cfg
659            .set_path("agent.max_rounds", "abc")
660            .unwrap_err()
661            .to_string();
662        assert!(err.contains("expected an integer"), "{err}");
663
664        let err = cfg
665            .set_path("policy.allow_shell", "yes-please")
666            .unwrap_err()
667            .to_string();
668        assert!(err.contains("expected `true` or `false`"), "{err}");
669    }
670
671    #[test]
672    fn secrets_are_masked_and_other_values_are_not() {
673        let mut cfg = Config::default_config();
674        cfg.provider.api_key = Some("sk-ant-secret-value".to_string());
675        cfg.channel.token = Some("123:telegram-secret".to_string());
676        let mut value = serde_json::to_value(&cfg).unwrap();
677        mask_secrets(&mut value);
678        let rendered = serde_json::to_string(&value).unwrap();
679        assert!(!rendered.contains("sk-ant-secret-value"), "{rendered}");
680        assert!(!rendered.contains("telegram-secret"), "{rendered}");
681        assert_eq!(value["provider"]["api_key"], "********");
682        assert_eq!(value["channel"]["token"], "********");
683        assert_eq!(value["provider"]["name"], "chatgpt");
684    }
685
686    #[test]
687    fn splicing_preserves_unrelated_keys_in_the_file() {
688        let mut raw: serde_json::Value =
689            serde_json::from_str(r#"{"model":"m","custom":{"kept":true}}"#).unwrap();
690        Config::splice_into_raw(&mut raw, "agent.max_rounds", serde_json::json!(12));
691        assert_eq!(raw["custom"]["kept"], true);
692        assert_eq!(raw["model"], "m");
693        assert_eq!(raw["agent"]["max_rounds"], 12);
694    }
695}
696
697#[cfg(test)]
698mod permission_profile_tests {
699    use super::*;
700
701    #[test]
702    fn full_enables_shell_and_resets_toolsets() {
703        let mut cfg = Config::default();
704        cfg.policy.allow_shell = false;
705        cfg.toolsets.enabled = vec!["browser".into()];
706        apply_permission_profile(&mut cfg, "full");
707        assert_eq!(cfg.agent.permission_profile, "full");
708        assert!(cfg.policy.allow_shell);
709        assert!(cfg.policy.allow_dynamic_tools);
710        assert!(cfg.toolsets.enabled.is_empty());
711    }
712
713    #[test]
714    fn tools_only_disables_shell_and_limits_toolsets() {
715        let mut cfg = Config::default();
716        apply_permission_profile(&mut cfg, "tools-only");
717        assert_eq!(cfg.agent.permission_profile, "tools_only");
718        assert!(!cfg.policy.allow_shell);
719        assert!(!cfg.policy.allow_dynamic_tools);
720        assert_eq!(
721            cfg.toolsets.enabled,
722            vec![
723                "web".to_string(),
724                "memory".to_string(),
725                "sessions".to_string()
726            ]
727        );
728        assert!(cfg.toolsets.disabled.contains(&"browser".to_string()));
729    }
730
731    #[test]
732    fn unknown_profile_falls_back_to_auto_defaults() {
733        let mut cfg = Config::default();
734        cfg.policy.allow_shell = false;
735        apply_permission_profile(&mut cfg, "nope");
736        assert_eq!(cfg.agent.permission_profile, "auto");
737        assert!(cfg.policy.allow_shell);
738    }
739}