claude_agent/config/
settings.rs

1//! Claude Code settings.json provider with hierarchical loading.
2//!
3//! Loads settings from (lowest to highest priority):
4//! 1. User settings: ~/.claude/settings.json
5//! 2. Project settings: .claude/settings.json
6//! 3. Local settings: .claude/settings.local.json (not committed)
7//! 4. Managed settings: organization policy (locked, cannot be overridden)
8
9use std::collections::{HashMap, HashSet};
10use std::path::{Path, PathBuf};
11
12use serde::{Deserialize, Serialize};
13
14use super::ConfigResult;
15
16#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "lowercase")]
18pub enum SettingsSource {
19    Builtin,
20    #[default]
21    User,
22    Project,
23    Local,
24    Managed,
25}
26
27#[derive(Debug, Clone, Default, Serialize, Deserialize)]
28pub struct Settings {
29    #[serde(skip)]
30    pub source: SettingsSource,
31
32    #[serde(default)]
33    pub env: HashMap<String, String>,
34
35    #[serde(default)]
36    pub permissions: PermissionSettings,
37
38    #[serde(default)]
39    pub sandbox: SandboxSettings,
40
41    #[serde(default, rename = "mcpServers")]
42    pub mcp_servers: HashMap<String, serde_json::Value>,
43
44    #[serde(default)]
45    pub model: Option<String>,
46
47    #[serde(default, rename = "smallModel")]
48    pub small_model: Option<String>,
49
50    #[serde(default, rename = "maxTokens")]
51    pub max_tokens: Option<u32>,
52
53    #[serde(default)]
54    pub hooks: Option<HooksSettings>,
55
56    #[serde(default, rename = "outputStyle")]
57    pub output_style: Option<String>,
58
59    #[serde(default, rename = "awsAuthRefresh")]
60    pub aws_auth_refresh: Option<String>,
61
62    #[serde(default, rename = "awsCredentialExport")]
63    pub aws_credential_export: Option<String>,
64
65    #[serde(default, rename = "apiKeyHelper")]
66    pub api_key_helper: Option<String>,
67
68    #[serde(default, rename = "toolSearch")]
69    pub tool_search: ToolSearchSettings,
70
71    #[serde(flatten)]
72    pub extra: HashMap<String, serde_json::Value>,
73}
74
75impl Settings {
76    pub fn with_source(mut self, source: SettingsSource) -> Self {
77        self.source = source;
78        self
79    }
80
81    pub fn is_managed(&self) -> bool {
82        self.source == SettingsSource::Managed
83    }
84}
85
86#[derive(Debug, Clone, Default, Serialize, Deserialize)]
87pub struct HooksSettings {
88    #[serde(default, rename = "PreToolUse")]
89    pub pre_tool_use: HashMap<String, HookConfig>,
90
91    #[serde(default, rename = "PostToolUse")]
92    pub post_tool_use: HashMap<String, HookConfig>,
93
94    #[serde(default, rename = "SessionStart")]
95    pub session_start: Vec<HookConfig>,
96
97    #[serde(default, rename = "SessionEnd")]
98    pub session_end: Vec<HookConfig>,
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize)]
102#[serde(untagged)]
103pub enum HookConfig {
104    Command(String),
105    Full {
106        command: String,
107        #[serde(default)]
108        timeout_secs: Option<u64>,
109        #[serde(default)]
110        matcher: Option<String>,
111    },
112}
113
114#[derive(Debug, Clone, Default, Serialize, Deserialize)]
115pub struct PermissionSettings {
116    #[serde(default)]
117    pub deny: Vec<String>,
118    #[serde(default)]
119    pub allow: Vec<String>,
120    #[serde(default, rename = "defaultMode")]
121    pub default_mode: Option<String>,
122}
123
124impl PermissionSettings {
125    pub fn to_policy(&self) -> crate::permissions::PermissionPolicy {
126        use crate::permissions::{PermissionMode, PermissionPolicy};
127
128        let mut builder = PermissionPolicy::builder();
129
130        if let Some(mode_str) = &self.default_mode
131            && let Ok(mode) = mode_str.parse::<PermissionMode>()
132        {
133            builder = builder.mode(mode);
134        }
135
136        for pattern in &self.deny {
137            builder = builder.deny(pattern);
138        }
139
140        for pattern in &self.allow {
141            builder = builder.allow(pattern);
142        }
143
144        builder.build()
145    }
146
147    pub fn is_empty(&self) -> bool {
148        self.deny.is_empty() && self.allow.is_empty() && self.default_mode.is_none()
149    }
150}
151
152#[derive(Debug, Clone, Default, Serialize, Deserialize)]
153pub struct SandboxSettings {
154    #[serde(default)]
155    pub enabled: bool,
156
157    #[serde(default)]
158    pub network: NetworkSandboxSettings,
159
160    #[serde(default, rename = "excludedCommands")]
161    pub excluded_commands: Vec<String>,
162
163    #[serde(default, rename = "allowUnsandboxedCommands")]
164    pub allow_unsandboxed_commands: bool,
165
166    #[serde(default, rename = "autoAllowBashIfSandboxed")]
167    pub auto_allow_bash_if_sandboxed: Option<bool>,
168}
169
170impl SandboxSettings {
171    /// Convert settings to SandboxConfig for use with SecurityContext.
172    ///
173    /// # Default Behaviors
174    /// - `auto_allow_bash_if_sandboxed`: defaults to `true` for backward compatibility
175    /// - `enable_weaker_nested_sandbox`: defaults to `false` (strict mode)
176    /// - `allowed_paths` and `denied_paths`: empty by default (use working_dir as root)
177    pub fn to_sandbox_config(
178        &self,
179        working_dir: std::path::PathBuf,
180    ) -> crate::security::sandbox::SandboxConfig {
181        use crate::security::sandbox::{NetworkConfig, SandboxConfig};
182
183        SandboxConfig {
184            enabled: self.enabled,
185            auto_allow_bash_if_sandboxed: self.auto_allow_bash_if_sandboxed.unwrap_or(true),
186            excluded_commands: self.excluded_commands.iter().cloned().collect(),
187            allow_unsandboxed_commands: self.allow_unsandboxed_commands,
188            network: NetworkConfig {
189                http_proxy_port: self.network.http_proxy_port,
190                socks_proxy_port: self.network.socks_proxy_port,
191                allow_unix_sockets: Vec::new(),
192                allow_local_binding: false,
193            },
194            working_dir,
195            allowed_domains: self.network.allowed_domains.clone(),
196            blocked_domains: self.network.blocked_domains.clone(),
197            // Explicit defaults for clarity
198            enable_weaker_nested_sandbox: false,
199            allowed_paths: Vec::new(),
200            denied_paths: Vec::new(),
201        }
202    }
203
204    pub fn is_enabled(&self) -> bool {
205        self.enabled
206    }
207
208    pub fn has_network_settings(&self) -> bool {
209        !self.network.allowed_domains.is_empty() || !self.network.blocked_domains.is_empty()
210    }
211}
212
213#[derive(Debug, Clone, Default, Serialize, Deserialize)]
214pub struct NetworkSandboxSettings {
215    #[serde(default, rename = "allowedDomains")]
216    pub allowed_domains: HashSet<String>,
217
218    #[serde(default, rename = "blockedDomains")]
219    pub blocked_domains: HashSet<String>,
220
221    #[serde(default, rename = "httpProxyPort")]
222    pub http_proxy_port: Option<u16>,
223
224    #[serde(default, rename = "socksProxyPort")]
225    pub socks_proxy_port: Option<u16>,
226}
227
228#[derive(Debug, Clone, Default, Serialize, Deserialize)]
229pub struct ToolSearchSettings {
230    #[serde(default)]
231    pub enabled: Option<bool>,
232
233    #[serde(default)]
234    pub threshold: Option<f64>,
235
236    #[serde(default)]
237    pub mode: Option<String>,
238
239    #[serde(default, rename = "maxResults")]
240    pub max_results: Option<usize>,
241
242    #[serde(default, rename = "alwaysLoad")]
243    pub always_load: Option<Vec<String>>,
244}
245
246impl ToolSearchSettings {
247    pub fn is_enabled(&self) -> bool {
248        self.enabled.unwrap_or(true)
249    }
250
251    pub fn to_config(&self, context_window: usize) -> crate::tools::ToolSearchConfig {
252        use crate::tools::{SearchMode, ToolSearchConfig};
253
254        let mut config = ToolSearchConfig::default().with_context_window(context_window);
255
256        if let Some(threshold) = self.threshold {
257            config = config.with_threshold(threshold);
258        }
259
260        if let Some(ref mode) = self.mode {
261            let search_mode = match mode.to_lowercase().as_str() {
262                "bm25" => SearchMode::Bm25,
263                _ => SearchMode::Regex,
264            };
265            config = config.with_search_mode(search_mode);
266        }
267
268        if let Some(max_results) = self.max_results {
269            config.max_results = max_results;
270        }
271
272        if let Some(ref always_load) = self.always_load {
273            config = config.with_always_load(always_load.clone());
274        }
275
276        config
277    }
278
279    pub fn is_empty(&self) -> bool {
280        self.enabled.is_none()
281            && self.threshold.is_none()
282            && self.mode.is_none()
283            && self.max_results.is_none()
284            && self.always_load.is_none()
285    }
286}
287
288/// Settings loader that merges from multiple sources.
289#[derive(Debug, Default)]
290pub struct SettingsLoader {
291    settings: Settings,
292    locked_keys: HashSet<String>,
293}
294
295impl SettingsLoader {
296    pub fn new() -> Self {
297        Self::default()
298    }
299
300    /// Loads settings from all levels (enterprise + user + project + local).
301    /// Priority (lowest to highest): Enterprise → User → Project → Local.
302    /// Enterprise settings lock keys and cannot be overridden by lower levels.
303    pub async fn load(&mut self, project_dir: &Path) -> ConfigResult<&Settings> {
304        // 1. Enterprise settings (lowest priority, but locks keys)
305        if let Some(enterprise_path) = crate::context::enterprise_base_path() {
306            self.load_enterprise(&enterprise_path).await?;
307        }
308
309        // 2. User settings
310        if let Some(home) = crate::common::home_dir() {
311            self.load_from_dir(&home.join(".claude"), SettingsSource::User)
312                .await?;
313        }
314
315        // 3. Project settings
316        self.load_from_dir(&project_dir.join(".claude"), SettingsSource::Project)
317            .await?;
318
319        // 4. Local settings (highest priority)
320        let local_settings = project_dir.join(".claude").join("settings.local.json");
321        if local_settings.exists() {
322            self.merge_file(&local_settings, SettingsSource::Local)
323                .await?;
324        }
325
326        Ok(&self.settings)
327    }
328
329    /// Loads settings from a single base directory.
330    /// Use this for loading from a specific resource level.
331    pub async fn load_from(&mut self, base_dir: &Path) -> ConfigResult<&Settings> {
332        // Check for settings.json directly in base_dir
333        let direct_path = base_dir.join("settings.json");
334        if direct_path.exists() {
335            self.merge_file(&direct_path, SettingsSource::Project)
336                .await?;
337        } else {
338            // Check for .claude/settings.json
339            self.load_from_dir(&base_dir.join(".claude"), SettingsSource::Project)
340                .await?;
341        }
342        Ok(&self.settings)
343    }
344
345    /// Loads only local settings (settings.local.json).
346    pub async fn load_local(&mut self, project_dir: &Path) -> ConfigResult<&Settings> {
347        let local_path = project_dir.join(".claude").join("settings.local.json");
348        if local_path.exists() {
349            self.merge_file(&local_path, SettingsSource::Local).await?;
350        }
351        Ok(&self.settings)
352    }
353
354    /// Internal: Load enterprise settings with key locking.
355    async fn load_enterprise(&mut self, enterprise_dir: &Path) -> ConfigResult<()> {
356        let settings_path = enterprise_dir.join("settings.json");
357        if settings_path.exists() {
358            let content = tokio::fs::read_to_string(&settings_path).await?;
359            let managed: Settings = serde_json::from_str(&content)?;
360
361            // Lock non-empty fields from enterprise settings
362            if !managed.permissions.deny.is_empty() {
363                self.locked_keys.insert("permissions.deny".to_string());
364            }
365            if !managed.permissions.allow.is_empty() {
366                self.locked_keys.insert("permissions.allow".to_string());
367            }
368            if managed.model.is_some() {
369                self.locked_keys.insert("model".to_string());
370            }
371
372            self.merge_settings(managed, true);
373        }
374        Ok(())
375    }
376
377    /// Internal: Load from a .claude directory.
378    async fn load_from_dir(
379        &mut self,
380        claude_dir: &Path,
381        source: SettingsSource,
382    ) -> ConfigResult<()> {
383        let settings_path = claude_dir.join("settings.json");
384        if settings_path.exists() {
385            self.merge_file(&settings_path, source).await?;
386        }
387        Ok(())
388    }
389
390    async fn merge_file(&mut self, path: &PathBuf, source: SettingsSource) -> ConfigResult<()> {
391        let content = tokio::fs::read_to_string(path).await?;
392        let mut file_settings: Settings = serde_json::from_str(&content)?;
393        file_settings.source = source;
394        self.merge_settings(file_settings, false);
395        Ok(())
396    }
397
398    fn merge_settings(&mut self, other: Settings, is_managed: bool) {
399        self.settings.env.extend(other.env);
400
401        if !self.locked_keys.contains("permissions.deny") || is_managed {
402            self.settings
403                .permissions
404                .deny
405                .extend(other.permissions.deny);
406        }
407        if !self.locked_keys.contains("permissions.allow") || is_managed {
408            self.settings
409                .permissions
410                .allow
411                .extend(other.permissions.allow);
412        }
413        if other.permissions.default_mode.is_some() {
414            self.settings.permissions.default_mode = other.permissions.default_mode;
415        }
416
417        self.settings
418            .sandbox
419            .network
420            .allowed_domains
421            .extend(other.sandbox.network.allowed_domains);
422        self.settings
423            .sandbox
424            .network
425            .blocked_domains
426            .extend(other.sandbox.network.blocked_domains);
427        self.settings
428            .sandbox
429            .excluded_commands
430            .extend(other.sandbox.excluded_commands);
431
432        if other.sandbox.enabled {
433            self.settings.sandbox.enabled = true;
434        }
435        if other.sandbox.allow_unsandboxed_commands {
436            self.settings.sandbox.allow_unsandboxed_commands = true;
437        }
438        if other.sandbox.auto_allow_bash_if_sandboxed.is_some() {
439            self.settings.sandbox.auto_allow_bash_if_sandboxed =
440                other.sandbox.auto_allow_bash_if_sandboxed;
441        }
442        if let Some(port) = other.sandbox.network.http_proxy_port {
443            self.settings.sandbox.network.http_proxy_port = Some(port);
444        }
445        if let Some(port) = other.sandbox.network.socks_proxy_port {
446            self.settings.sandbox.network.socks_proxy_port = Some(port);
447        }
448
449        self.settings.mcp_servers.extend(other.mcp_servers);
450
451        if other.aws_auth_refresh.is_some() {
452            self.settings.aws_auth_refresh = other.aws_auth_refresh;
453        }
454        if other.aws_credential_export.is_some() {
455            self.settings.aws_credential_export = other.aws_credential_export;
456        }
457        if other.api_key_helper.is_some() {
458            self.settings.api_key_helper = other.api_key_helper;
459        }
460
461        self.settings.extra.extend(other.extra);
462
463        if (!self.locked_keys.contains("model") || is_managed) && other.model.is_some() {
464            self.settings.model = other.model;
465        }
466        if other.small_model.is_some() {
467            self.settings.small_model = other.small_model;
468        }
469        if other.max_tokens.is_some() {
470            self.settings.max_tokens = other.max_tokens;
471        }
472        if let Some(other_hooks) = other.hooks {
473            match &mut self.settings.hooks {
474                Some(existing) => {
475                    existing.pre_tool_use.extend(other_hooks.pre_tool_use);
476                    existing.post_tool_use.extend(other_hooks.post_tool_use);
477                    existing.session_start.extend(other_hooks.session_start);
478                    existing.session_end.extend(other_hooks.session_end);
479                }
480                None => self.settings.hooks = Some(other_hooks),
481            }
482        }
483        if other.output_style.is_some() {
484            self.settings.output_style = other.output_style;
485        }
486
487        // Merge tool_search settings (later settings override earlier)
488        if other.tool_search.enabled.is_some() {
489            self.settings.tool_search.enabled = other.tool_search.enabled;
490        }
491        if other.tool_search.threshold.is_some() {
492            self.settings.tool_search.threshold = other.tool_search.threshold;
493        }
494        if other.tool_search.mode.is_some() {
495            self.settings.tool_search.mode = other.tool_search.mode;
496        }
497        if other.tool_search.max_results.is_some() {
498            self.settings.tool_search.max_results = other.tool_search.max_results;
499        }
500        if let Some(always_load) = other.tool_search.always_load {
501            match &mut self.settings.tool_search.always_load {
502                Some(existing) => existing.extend(always_load),
503                None => self.settings.tool_search.always_load = Some(always_load),
504            }
505        }
506    }
507
508    pub fn settings(&self) -> &Settings {
509        &self.settings
510    }
511
512    pub fn into_settings(self) -> Settings {
513        self.settings
514    }
515}
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520
521    #[tokio::test]
522    async fn test_settings_loader() {
523        let loader = SettingsLoader::new();
524        assert!(loader.settings.env.is_empty());
525    }
526
527    #[test]
528    fn test_permission_settings_to_policy() {
529        use crate::permissions::PermissionMode;
530
531        let settings = PermissionSettings {
532            deny: vec!["Bash(rm:*)".to_string()],
533            allow: vec!["Bash(git:*)".to_string()],
534            default_mode: Some("acceptEdits".to_string()),
535        };
536
537        let policy = settings.to_policy();
538        assert_eq!(policy.mode, PermissionMode::AcceptEdits);
539        assert_eq!(policy.rules.len(), 2);
540    }
541
542    #[test]
543    fn test_permission_settings_is_empty() {
544        let empty = PermissionSettings::default();
545        assert!(empty.is_empty());
546
547        let with_deny = PermissionSettings {
548            deny: vec!["Bash".to_string()],
549            ..Default::default()
550        };
551        assert!(!with_deny.is_empty());
552    }
553
554    #[test]
555    fn test_sandbox_settings_enabled() {
556        let settings = SandboxSettings {
557            enabled: true,
558            ..Default::default()
559        };
560        assert!(settings.is_enabled());
561
562        let disabled = SandboxSettings::default();
563        assert!(!disabled.is_enabled());
564    }
565
566    #[test]
567    fn test_sandbox_settings_to_sandbox_config() {
568        use std::path::PathBuf;
569
570        let settings = SandboxSettings {
571            enabled: true,
572            network: NetworkSandboxSettings {
573                allowed_domains: ["example.com".to_string()].into_iter().collect(),
574                blocked_domains: ["malware.com".to_string()].into_iter().collect(),
575                ..Default::default()
576            },
577            ..Default::default()
578        };
579
580        let config = settings.to_sandbox_config(PathBuf::from("/tmp"));
581        assert!(config.enabled);
582        assert!(config.allowed_domains.contains("example.com"));
583        assert!(config.blocked_domains.contains("malware.com"));
584
585        let network_sandbox = config.to_network_sandbox();
586        assert!(network_sandbox.allowed_domains().contains("example.com"));
587        assert!(network_sandbox.blocked_domains().contains("malware.com"));
588    }
589
590    #[test]
591    fn test_tool_search_settings_default() {
592        let settings = ToolSearchSettings::default();
593        assert!(settings.is_empty());
594        assert!(settings.is_enabled()); // Default is enabled
595    }
596
597    #[test]
598    fn test_tool_search_settings_to_config() {
599        use crate::tools::SearchMode;
600
601        let settings = ToolSearchSettings {
602            enabled: Some(true),
603            threshold: Some(0.15),
604            mode: Some("bm25".to_string()),
605            max_results: Some(10),
606            always_load: Some(vec!["mcp__my_tool".to_string()]),
607        };
608
609        let config = settings.to_config(200_000);
610        assert_eq!(config.threshold, 0.15);
611        assert_eq!(config.search_mode, SearchMode::Bm25);
612        assert_eq!(config.max_results, 10);
613        assert!(config.always_load.contains(&"mcp__my_tool".to_string()));
614    }
615
616    #[test]
617    fn test_tool_search_settings_regex_mode() {
618        use crate::tools::SearchMode;
619
620        let settings = ToolSearchSettings {
621            mode: Some("regex".to_string()),
622            ..Default::default()
623        };
624
625        let config = settings.to_config(100_000);
626        assert_eq!(config.search_mode, SearchMode::Regex);
627        assert_eq!(config.context_window, 100_000);
628    }
629
630    #[test]
631    fn test_tool_search_settings_disabled() {
632        let settings = ToolSearchSettings {
633            enabled: Some(false),
634            ..Default::default()
635        };
636
637        assert!(!settings.is_enabled());
638        assert!(!settings.is_empty());
639    }
640}