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    /// Free-form per-channel settings (`channel_id`, `space`, `server`, `nick`,
150    /// …). Kept untyped so a channel — including one registered by a plugin —
151    /// can take the parameters it needs without a config schema change.
152    #[serde(default)]
153    pub settings: std::collections::HashMap<String, String>,
154}
155
156#[derive(Debug, Clone, Serialize, Deserialize)]
157#[serde(default)]
158pub struct PolicyConfig {
159    pub allow_shell: bool,
160    pub allow_dynamic_tools: bool,
161    pub allow_plugin_shell: bool,
162    pub allow_plugin_git: bool,
163    pub allow_computer_use: bool,
164}
165
166#[derive(Debug, Clone, Serialize, Deserialize)]
167#[serde(default)]
168pub struct PluginLayerConfig {
169    pub enabled: bool,
170    #[serde(default = "default_manifest_path")]
171    pub manifest_path: PathBuf,
172    /// Extra directories to scan for OpenClaw SKILL.md and Hermes plugin.json
173    #[serde(default)]
174    pub host_plugin_roots: Vec<PathBuf>,
175    pub hook_events: Vec<String>,
176    pub allow_core_fallback: bool,
177    pub layered_overrides: Vec<String>,
178}
179
180#[derive(Debug, Clone, Serialize, Deserialize)]
181#[serde(default)]
182pub struct GroupChatConfig {
183    pub enable_ambient_questions: bool,
184    pub rolling_memory_namespace: String,
185    pub rolling_memory_max_chars: usize,
186    pub rolling_memory_recent_turns: usize,
187    pub ambient_question_window: usize,
188}
189
190#[derive(Debug, Clone, Serialize, Deserialize, Default)]
191#[serde(default)]
192pub struct ToolsetConfig {
193    pub enabled: Vec<String>,
194    pub disabled: Vec<String>,
195}
196
197#[derive(Debug, Clone, Serialize, Deserialize)]
198#[serde(default)]
199pub struct MemoryIdeasConfig {
200    /// One logical user across channels (merged history when set).
201    pub principal_id: Option<String>,
202    /// Inject brief + recall gate + idea graph context each turn.
203    pub inject_context: bool,
204    pub graph_recall_limit: usize,
205    /// Route heartbeat synthetic messages to this chat_id (e.g. telegram chat id).
206    pub heartbeat_chat_id: Option<String>,
207    /// After idle, expand open loops into dream nodes (graph).
208    pub dream_on_heartbeat: bool,
209}
210
211impl Default for MemoryIdeasConfig {
212    fn default() -> Self {
213        Self {
214            principal_id: None,
215            inject_context: true,
216            graph_recall_limit: 5,
217            heartbeat_chat_id: None,
218            dream_on_heartbeat: false,
219        }
220    }
221}
222
223#[derive(Debug, Clone, Serialize, Deserialize)]
224#[serde(default)]
225pub struct ZkrConfig {
226    pub enabled: bool,
227    pub database: PathBuf,
228    pub tenant_id: String,
229    pub person_id: String,
230    pub auto_capture: bool,
231    pub inject_recall: bool,
232    pub recall_limit: u32,
233    pub self_improve: bool,
234}
235
236impl Default for ZkrConfig {
237    fn default() -> Self {
238        Self {
239            enabled: true,
240            database: PathBuf::from(".apollo/zkr.db"),
241            tenant_id: "apollo".to_string(),
242            person_id: "local".to_string(),
243            auto_capture: true,
244            inject_recall: true,
245            recall_limit: 5,
246            self_improve: true,
247        }
248    }
249}
250
251/// Apply a named onboarding permission profile to `cfg` (policy, toolsets, and `permission_profile`).
252pub fn apply_permission_profile(cfg: &mut Config, profile: &str) {
253    let p = profile.trim().to_ascii_lowercase().replace(['-', ' '], "_");
254    match p.as_str() {
255        "full" => {
256            cfg.agent.permission_profile = "full".to_string();
257            cfg.policy.allow_shell = true;
258            cfg.policy.allow_dynamic_tools = true;
259            cfg.policy.allow_computer_use = true;
260            cfg.toolsets = ToolsetConfig::default();
261            cfg.agent.permissions = PermissionRulesConfig::default();
262        }
263        "auto" => {
264            cfg.agent.permission_profile = "auto".to_string();
265            cfg.policy = PolicyConfig::default();
266            cfg.toolsets = ToolsetConfig::default();
267            cfg.agent.permissions = PermissionRulesConfig::default();
268        }
269        "prompt" => {
270            cfg.agent.permission_profile = "prompt".to_string();
271            cfg.policy = PolicyConfig::default();
272            cfg.toolsets = ToolsetConfig::default();
273            cfg.agent.permissions = PermissionRulesConfig::default();
274        }
275        "tools_only" | "tools" => {
276            cfg.agent.permission_profile = "tools_only".to_string();
277            cfg.policy.allow_shell = false;
278            cfg.policy.allow_dynamic_tools = false;
279            cfg.policy.allow_computer_use = false;
280            cfg.toolsets.enabled = vec![
281                "web".to_string(),
282                "memory".to_string(),
283                "sessions".to_string(),
284            ];
285            cfg.toolsets.disabled = vec![
286                "browser".to_string(),
287                "vibemania".to_string(),
288                "create_tool".to_string(),
289                "mcp".to_string(),
290            ];
291            cfg.agent.permissions = PermissionRulesConfig::default();
292        }
293        _ => {
294            cfg.agent.permission_profile = "auto".to_string();
295            cfg.policy = PolicyConfig::default();
296            cfg.toolsets = ToolsetConfig::default();
297            cfg.agent.permissions = PermissionRulesConfig::default();
298        }
299    }
300}
301
302/// Leaf field names whose values must never be printed.
303const SECRET_LEAF_KEYS: &[&str] = &["api_key", "token", "secret", "password"];
304
305/// Whether a leaf field name holds a credential.
306pub fn is_secret_key(leaf: &str) -> bool {
307    let leaf = leaf.to_ascii_lowercase();
308    SECRET_LEAF_KEYS
309        .iter()
310        .any(|s| leaf == *s || leaf.ends_with(&format!("_{s}")))
311}
312
313/// Replace every credential-bearing leaf with a fixed mask, in place.
314///
315/// Empty strings and nulls stay as they are: "unset" is not a secret, and
316/// showing a mask for one would be a lie.
317pub fn mask_secrets(value: &mut serde_json::Value) {
318    match value {
319        serde_json::Value::Object(map) => {
320            for (key, child) in map.iter_mut() {
321                if is_secret_key(key) {
322                    if let serde_json::Value::String(s) = child {
323                        if !s.is_empty() {
324                            *child = serde_json::Value::String("********".to_string());
325                            continue;
326                        }
327                    }
328                }
329                mask_secrets(child);
330            }
331        }
332        serde_json::Value::Array(items) => {
333            for item in items {
334                mask_secrets(item);
335            }
336        }
337        _ => {}
338    }
339}
340
341fn lookup<'a>(root: &'a serde_json::Value, key: &str) -> anyhow::Result<&'a serde_json::Value> {
342    let mut current = root;
343    let mut walked: Vec<&str> = Vec::new();
344    for segment in key.split('.') {
345        let object = current.as_object().ok_or_else(|| {
346            anyhow::anyhow!(
347                "unknown config key `{key}`: `{}` is not a section",
348                walked.join(".")
349            )
350        })?;
351        current = object.get(segment).ok_or_else(|| {
352            let mut names: Vec<&str> = object.keys().map(|k| k.as_str()).collect();
353            names.sort_unstable();
354            anyhow::anyhow!(
355                "unknown config key `{key}`. Available under `{}`: {}",
356                if walked.is_empty() {
357                    "<root>".to_string()
358                } else {
359                    walked.join(".")
360                },
361                names.join(", ")
362            )
363        })?;
364        walked.push(segment);
365    }
366    Ok(current)
367}
368
369fn coerce(existing: &serde_json::Value, input: &str) -> anyhow::Result<serde_json::Value> {
370    use serde_json::Value;
371    match existing {
372        Value::String(_) => Ok(Value::String(input.to_string())),
373        Value::Bool(_) => input
374            .parse::<bool>()
375            .map(Value::Bool)
376            .map_err(|_| anyhow::anyhow!("expected `true` or `false`, got `{input}`")),
377        Value::Number(n) => {
378            if n.is_f64() {
379                input
380                    .parse::<f64>()
381                    .ok()
382                    .and_then(serde_json::Number::from_f64)
383                    .map(Value::Number)
384                    .ok_or_else(|| anyhow::anyhow!("expected a number, got `{input}`"))
385            } else {
386                input
387                    .parse::<i64>()
388                    .map(|v| Value::Number(v.into()))
389                    .map_err(|_| anyhow::anyhow!("expected an integer, got `{input}`"))
390            }
391        }
392        Value::Array(_) | Value::Object(_) => serde_json::from_str(input)
393            .map_err(|e| anyhow::anyhow!("expected JSON matching the existing value: {e}")),
394        Value::Null => Ok(serde_json::from_str(input).unwrap_or(Value::String(input.to_string()))),
395    }
396}
397
398fn assign(root: &mut serde_json::Value, key: &str, value: serde_json::Value) {
399    let segments: Vec<&str> = key.split('.').collect();
400    let mut current = root;
401    for segment in &segments[..segments.len() - 1] {
402        if !current.is_object() {
403            *current = serde_json::Value::Object(serde_json::Map::new());
404        }
405        current = current
406            .as_object_mut()
407            .expect("object")
408            .entry((*segment).to_string())
409            .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
410    }
411    if !current.is_object() {
412        *current = serde_json::Value::Object(serde_json::Map::new());
413    }
414    current
415        .as_object_mut()
416        .expect("object")
417        .insert(segments[segments.len() - 1].to_string(), value);
418}
419
420impl Config {
421    /// Read a dotted path (`agent.max_rounds`, `provider.name`, `model`) out of the
422    /// effective configuration.
423    pub fn get_path(&self, key: &str) -> anyhow::Result<serde_json::Value> {
424        if key.trim().is_empty() {
425            anyhow::bail!("empty config key");
426        }
427        let root = serde_json::to_value(self)?;
428        lookup(&root, key).cloned()
429    }
430
431    /// Coerce `value` to the type the schema already uses at `key`, apply it,
432    /// and round-trip the result through `Config` so an invalid write is
433    /// rejected before it can reach disk.
434    ///
435    /// Returns the updated config and the JSON value that was written, so a
436    /// caller can splice just that path into the on-disk file.
437    pub fn set_path(&self, key: &str, value: &str) -> anyhow::Result<(Config, serde_json::Value)> {
438        if key.trim().is_empty() {
439            anyhow::bail!("empty config key");
440        }
441        let mut root = serde_json::to_value(self)?;
442        let existing = lookup(&root, key)?;
443        let coerced = coerce(existing, value)
444            .map_err(|e| anyhow::anyhow!("invalid value for `{key}`: {e}"))?;
445        assign(&mut root, key, coerced.clone());
446        let updated: Config = serde_json::from_value(root)
447            .map_err(|e| anyhow::anyhow!("invalid value for `{key}`: {e}"))?;
448        Ok((updated, coerced))
449    }
450
451    /// Splice a validated value into a raw config document, leaving every other
452    /// key in the file untouched.
453    pub fn splice_into_raw(raw: &mut serde_json::Value, key: &str, value: serde_json::Value) {
454        assign(raw, key, value);
455    }
456
457    pub fn load(path: &str) -> anyhow::Result<Self> {
458        let content = std::fs::read_to_string(path)?;
459        let config: Config = serde_json::from_str(&content)?;
460        Ok(config)
461    }
462
463    pub fn default_config() -> Self {
464        Self {
465            provider: ProviderConfig::default(),
466            embeddings: EmbeddingsConfig::default(),
467            agent: AgentConfig::default(),
468            model: "gpt-5.5".to_string(),
469            system_prompt: "You are a helpful AI assistant.".to_string(),
470            workspace: PathBuf::from("."),
471            storage: StorageConfig::default(),
472            runtime: RuntimeConfig::default(),
473            observability: ObservabilityConfig::default(),
474            channel: ChannelConfig::default(),
475            policy: PolicyConfig::default(),
476            plugin_layer: PluginLayerConfig::default(),
477            group_chat: GroupChatConfig::default(),
478            toolsets: ToolsetConfig::default(),
479            memory: MemoryIdeasConfig::default(),
480            zkr: ZkrConfig::default(),
481        }
482    }
483}
484
485impl Default for Config {
486    fn default() -> Self {
487        Self::default_config()
488    }
489}
490
491impl Default for ProviderConfig {
492    fn default() -> Self {
493        Self {
494            name: "chatgpt".to_string(),
495            api_key: None,
496            base_url: None,
497            native_web_search: false,
498        }
499    }
500}
501
502impl Default for EmbeddingsConfig {
503    fn default() -> Self {
504        Self {
505            enabled: false,
506            provider: "noop".to_string(),
507            api_key: None,
508            model: None,
509            base_url: None,
510        }
511    }
512}
513
514impl Default for RuntimeConfig {
515    fn default() -> Self {
516        Self {
517            kind: "native".to_string(),
518            docker_image: None,
519            memory_limit_mb: None,
520            state_path: None,
521            self_update: SelfUpdateConfig::default(),
522        }
523    }
524}
525
526impl Default for SelfUpdateConfig {
527    fn default() -> Self {
528        Self {
529            enabled: false,
530            interval_secs: 900,
531            remote: "origin".to_string(),
532            branch: "main".to_string(),
533            restart_service: Some("apollo".to_string()),
534        }
535    }
536}
537
538impl Default for StorageConfig {
539    fn default() -> Self {
540        Self {
541            backend: "surreal".to_string(),
542            root: PathBuf::from(".apollo"),
543        }
544    }
545}
546
547impl Default for ObservabilityConfig {
548    fn default() -> Self {
549        Self {
550            service_name: "apollo".to_string(),
551            environment: "development".to_string(),
552            json_logs: false,
553            trace_header_name: "traceparent".to_string(),
554        }
555    }
556}
557
558impl Default for ChannelConfig {
559    fn default() -> Self {
560        Self {
561            kind: "cli".to_string(),
562            token: None,
563            allowed_chat_ids: Vec::new(),
564            allowed_sender_ids: Vec::new(),
565            settings: std::collections::HashMap::new(),
566        }
567    }
568}
569
570impl Default for PolicyConfig {
571    fn default() -> Self {
572        Self {
573            allow_shell: true,
574            allow_dynamic_tools: true,
575            allow_plugin_shell: true,
576            allow_plugin_git: true,
577            allow_computer_use: true,
578        }
579    }
580}
581
582fn default_manifest_path() -> PathBuf {
583    PathBuf::from("plugins/manifest.json")
584}
585
586impl Default for PluginLayerConfig {
587    fn default() -> Self {
588        Self {
589            enabled: true,
590            manifest_path: default_manifest_path(),
591            host_plugin_roots: Vec::new(),
592            hook_events: vec![
593                "before_message".to_string(),
594                "after_message".to_string(),
595                "before_tool".to_string(),
596                "after_tool".to_string(),
597            ],
598            allow_core_fallback: true,
599            layered_overrides: vec!["system_prompt".to_string(), "toolsets".to_string()],
600        }
601    }
602}
603
604impl Default for GroupChatConfig {
605    fn default() -> Self {
606        Self {
607            enable_ambient_questions: true,
608            rolling_memory_namespace: "group_memory".to_string(),
609            rolling_memory_max_chars: 6_000,
610            rolling_memory_recent_turns: 16,
611            ambient_question_window: 24,
612        }
613    }
614}
615
616#[cfg(test)]
617mod config_path_tests {
618    use super::*;
619
620    #[test]
621    fn get_reads_nested_and_top_level_keys() {
622        let cfg = Config::default_config();
623        assert_eq!(cfg.get_path("model").unwrap(), cfg.model.as_str());
624        assert_eq!(cfg.get_path("provider.name").unwrap(), "chatgpt");
625        assert_eq!(cfg.get_path("agent.max_rounds").unwrap(), 50);
626    }
627
628    #[test]
629    fn set_updates_strings_bools_and_numbers() {
630        let cfg = Config::default_config();
631        let (cfg, _) = cfg.set_path("policy.allow_shell", "false").unwrap();
632        assert!(!cfg.policy.allow_shell);
633        let (cfg, written) = cfg.set_path("agent.max_rounds", "12").unwrap();
634        assert_eq!(cfg.agent.max_rounds, 12);
635        assert_eq!(written, 12);
636    }
637
638    #[test]
639    fn set_fills_an_optional_field_that_was_null() {
640        let cfg = Config::default_config();
641        let (cfg, _) = cfg
642            .set_path("provider.base_url", "http://localhost:11434")
643            .unwrap();
644        assert_eq!(
645            cfg.provider.base_url.as_deref(),
646            Some("http://localhost:11434")
647        );
648    }
649
650    #[test]
651    fn unknown_keys_are_rejected_with_the_available_names() {
652        let cfg = Config::default_config();
653        let err = cfg.set_path("agent.nope", "1").unwrap_err().to_string();
654        assert!(err.contains("unknown config key `agent.nope`"), "{err}");
655        assert!(err.contains("max_rounds"), "{err}");
656
657        let err = cfg.get_path("not_a_section.x").unwrap_err().to_string();
658        assert!(err.contains("unknown config key"), "{err}");
659    }
660
661    #[test]
662    fn wrong_typed_values_are_rejected_before_they_reach_disk() {
663        let cfg = Config::default_config();
664        let err = cfg
665            .set_path("agent.max_rounds", "abc")
666            .unwrap_err()
667            .to_string();
668        assert!(err.contains("expected an integer"), "{err}");
669
670        let err = cfg
671            .set_path("policy.allow_shell", "yes-please")
672            .unwrap_err()
673            .to_string();
674        assert!(err.contains("expected `true` or `false`"), "{err}");
675    }
676
677    #[test]
678    fn secrets_are_masked_and_other_values_are_not() {
679        let mut cfg = Config::default_config();
680        cfg.provider.api_key = Some("sk-ant-secret-value".to_string());
681        cfg.channel.token = Some("123:telegram-secret".to_string());
682        let mut value = serde_json::to_value(&cfg).unwrap();
683        mask_secrets(&mut value);
684        let rendered = serde_json::to_string(&value).unwrap();
685        assert!(!rendered.contains("sk-ant-secret-value"), "{rendered}");
686        assert!(!rendered.contains("telegram-secret"), "{rendered}");
687        assert_eq!(value["provider"]["api_key"], "********");
688        assert_eq!(value["channel"]["token"], "********");
689        assert_eq!(value["provider"]["name"], "chatgpt");
690    }
691
692    #[test]
693    fn splicing_preserves_unrelated_keys_in_the_file() {
694        let mut raw: serde_json::Value =
695            serde_json::from_str(r#"{"model":"m","custom":{"kept":true}}"#).unwrap();
696        Config::splice_into_raw(&mut raw, "agent.max_rounds", serde_json::json!(12));
697        assert_eq!(raw["custom"]["kept"], true);
698        assert_eq!(raw["model"], "m");
699        assert_eq!(raw["agent"]["max_rounds"], 12);
700    }
701}
702
703#[cfg(test)]
704mod permission_profile_tests {
705    use super::*;
706
707    #[test]
708    fn full_enables_shell_and_resets_toolsets() {
709        let mut cfg = Config::default();
710        cfg.policy.allow_shell = false;
711        cfg.toolsets.enabled = vec!["browser".into()];
712        apply_permission_profile(&mut cfg, "full");
713        assert_eq!(cfg.agent.permission_profile, "full");
714        assert!(cfg.policy.allow_shell);
715        assert!(cfg.policy.allow_dynamic_tools);
716        assert!(cfg.toolsets.enabled.is_empty());
717    }
718
719    #[test]
720    fn tools_only_disables_shell_and_limits_toolsets() {
721        let mut cfg = Config::default();
722        apply_permission_profile(&mut cfg, "tools-only");
723        assert_eq!(cfg.agent.permission_profile, "tools_only");
724        assert!(!cfg.policy.allow_shell);
725        assert!(!cfg.policy.allow_dynamic_tools);
726        assert_eq!(
727            cfg.toolsets.enabled,
728            vec![
729                "web".to_string(),
730                "memory".to_string(),
731                "sessions".to_string()
732            ]
733        );
734        assert!(cfg.toolsets.disabled.contains(&"browser".to_string()));
735    }
736
737    #[test]
738    fn unknown_profile_falls_back_to_auto_defaults() {
739        let mut cfg = Config::default();
740        cfg.policy.allow_shell = false;
741        apply_permission_profile(&mut cfg, "nope");
742        assert_eq!(cfg.agent.permission_profile, "auto");
743        assert!(cfg.policy.allow_shell);
744    }
745}