1use 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 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 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#[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 pub async fn load(&mut self, project_dir: &Path) -> ConfigResult<&Settings> {
304 if let Some(enterprise_path) = crate::context::enterprise_base_path() {
306 self.load_enterprise(&enterprise_path).await?;
307 }
308
309 if let Some(home) = crate::common::home_dir() {
311 self.load_from_dir(&home.join(".claude"), SettingsSource::User)
312 .await?;
313 }
314
315 self.load_from_dir(&project_dir.join(".claude"), SettingsSource::Project)
317 .await?;
318
319 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 pub async fn load_from(&mut self, base_dir: &Path) -> ConfigResult<&Settings> {
332 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 self.load_from_dir(&base_dir.join(".claude"), SettingsSource::Project)
340 .await?;
341 }
342 Ok(&self.settings)
343 }
344
345 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 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 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 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 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()); }
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}