Skip to main content

vtcode_config/loader/manager/
mod.rs

1mod cache;
2mod inspect;
3mod migrate;
4mod persistence;
5mod repository;
6
7use cache::{
8    RepositoryProviderSecurityViolation, canonicalize_workspace_root, push_unique_layer_source,
9    should_skip_optional_global_error,
10};
11#[cfg(not(test))]
12use cache::{cache_get, cache_insert, cache_remove, with_cache_mut};
13use migrate::migrate_custom_api_keys_if_needed;
14
15use std::collections::HashMap;
16use std::fmt;
17use std::fs;
18use std::path::{Path, PathBuf};
19use std::sync::{Arc, Mutex};
20
21use anyhow::{Context, Result, bail};
22
23use crate::api_keys::{api_key_env_var, credential_metadata_key, store_credential_with_mode};
24use crate::defaults::{self};
25use crate::hooks::{HooksConfig, LifecycleHooksConfig, WorkspaceHookCommand, WorkspaceLifecycleHooks};
26use crate::loader::config::VTCodeConfig;
27use crate::loader::layers::{
28    ConfigLayerEntry, ConfigLayerMetadata, ConfigLayerSource, ConfigLayerStack, LayerDisabledReason,
29};
30use crate::loader::session_override;
31use vtcode_commons::VtCodePaths;
32use vtcode_commons::canonicalize;
33
34/// Timing metrics (in microseconds) for configuration loading phases
35#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
36pub struct ConfigPhaseTiming {
37    /// Duration of workspace path resolution (in microseconds)
38    pub path_resolution_us: u64,
39    /// Duration of layer probing and loading (in microseconds)
40    pub layer_loading_us: u64,
41    /// Duration of TOML merging and deserialization (in microseconds)
42    pub merge_and_parse_us: u64,
43    /// Duration of validation and API key migration (in microseconds)
44    pub validation_us: u64,
45}
46
47/// Configuration manager for loading and validating configurations
48#[derive(Clone)]
49pub struct ConfigManager {
50    pub(crate) config: VTCodeConfig,
51    pub(super) config_path: Option<PathBuf>,
52    pub(super) canonical_user_config_path: Option<PathBuf>,
53    pub(super) tracked_user_config_paths: Vec<PathBuf>,
54    write_global_config_to_canonical: bool,
55    /// Whether the associated file was explicitly selected by the user.
56    ///
57    /// Explicit files are trusted even when they are represented as a
58    /// workspace layer internally. Repository and project layers are not.
59    pub(super) explicit_config_is_trusted: bool,
60    pub(super) workspace_root: Option<PathBuf>,
61    pub(super) config_file_name: String,
62    pub(crate) layer_stack: ConfigLayerStack,
63    pub(super) phase_timing: Option<ConfigPhaseTiming>,
64}
65
66impl ConfigManager {
67    /// Load configuration from the default locations rooted at the current directory.
68    pub fn load() -> Result<Self> {
69        if let Some(override_path) = session_override::explicit_config_path() {
70            return Self::load_for_session(std::env::current_dir()?, &override_path).with_context(|| {
71                format!("Failed to load configuration from explicit path {}", override_path.display())
72            });
73        }
74
75        Self::load_from_workspace_with_repository_repair(std::env::current_dir()?)
76    }
77
78    /// Load configuration for an interactive session with an explicit config file.
79    ///
80    /// The explicit file takes the highest file-layer precedence (the same
81    /// position a workspace root `vtcode.toml` would occupy), while the
82    /// system/user global layers are still loaded underneath it. Unlike
83    /// [`Self::load_from_file`], the manager's `workspace_root` remains the
84    /// session workspace (not the explicit file's parent directory), so
85    /// workspace-relative config writes and project-level paths keep
86    /// resolving against the workspace.
87    pub fn load_for_session(workspace: impl AsRef<Path>, explicit_path: impl AsRef<Path>) -> Result<Self> {
88        let workspace = workspace.as_ref();
89        let explicit_path = explicit_path.as_ref();
90        #[cfg(not(test))]
91        let canonical_workspace = canonicalize_workspace_root(workspace);
92
93        #[cfg(not(test))]
94        if let Some(cached) = cache_get(&canonical_workspace) {
95            return Ok(cached.as_ref().clone());
96        }
97
98        let mut manager = Self::load_from_file_impl(explicit_path, false).with_context(|| {
99            format!(
100                "Failed to load explicit session config file {} (from --config / VTCODE_CONFIG_PATH)",
101                explicit_path.display()
102            )
103        })?;
104        // The session workspace, not the explicit file's parent, anchors
105        // workspace-relative config resolution for the rest of the session.
106        manager.workspace_root = Some(canonicalize_workspace_root(workspace));
107
108        #[cfg(not(test))]
109        cache_insert(canonical_workspace, Arc::new(manager.clone()));
110
111        Ok(manager)
112    }
113
114    /// Load only the system and user configuration layers.
115    ///
116    /// This is used by global configuration commands that must honor legacy
117    /// and XDG search precedence without accidentally importing the current
118    /// workspace's `vtcode.toml`.
119    pub fn load_global() -> Result<Self> {
120        let defaults_provider = defaults::current_config_defaults();
121        let config_file_name = defaults_provider.config_file_name().to_string();
122        let canonical_user_config_path = defaults_provider.canonical_user_config_path(&config_file_name)?;
123        let mut tracked_user_config_paths = defaults_provider.home_config_paths(&config_file_name);
124        if let Some(path) = &canonical_user_config_path
125            && !tracked_user_config_paths.iter().any(|existing| existing == path)
126        {
127            tracked_user_config_paths.push(path.clone());
128        }
129
130        let mut layer_sources = Vec::new();
131        for system_config_path in defaults_provider.system_config_paths(&config_file_name)? {
132            push_unique_layer_source(&mut layer_sources, ConfigLayerSource::System { file: system_config_path });
133        }
134        for home_config_path in &tracked_user_config_paths {
135            push_unique_layer_source(&mut layer_sources, ConfigLayerSource::User { file: home_config_path.clone() });
136        }
137
138        let mut layer_stack = ConfigLayerStack::default();
139        for source in layer_sources {
140            if let Some(layer) = Self::load_optional_layer(source) {
141                layer_stack.push(layer);
142            }
143        }
144
145        if let Some((layer, error)) = layer_stack.first_layer_error() {
146            bail!("Configuration layer '{}' failed to load: {}", layer.source.label(), error.message);
147        }
148
149        let (config, config_path) = if layer_stack.layers().is_empty() {
150            let config = VTCodeConfig::default();
151            config.validate().context("Default configuration failed validation")?;
152            (config, None)
153        } else {
154            let (effective_toml, origins) = layer_stack.effective_config_with_origins();
155            let mut config: VTCodeConfig = effective_toml
156                .try_into()
157                .context("Failed to deserialize effective global configuration")?;
158            Self::validate_restricted_agent_fields(&layer_stack, &origins)?;
159            Self::validate_provider_security_fields(&layer_stack, &origins, &config, false)?;
160            config.validate().context("Global configuration failed validation")?;
161            config.workspace_lifecycle_hooks =
162                Some(Self::collect_workspace_lifecycle_hooks(&layer_stack, &config.hooks));
163            let config_path = layer_stack
164                .layers()
165                .iter()
166                .rev()
167                .find(|layer| layer.is_enabled())
168                .and_then(|layer| match &layer.source {
169                    ConfigLayerSource::System { file } | ConfigLayerSource::User { file } => Some(file.clone()),
170                    _ => None,
171                });
172            (config, config_path)
173        };
174
175        Ok(Self {
176            config,
177            config_path,
178            canonical_user_config_path,
179            tracked_user_config_paths,
180            write_global_config_to_canonical: false,
181            explicit_config_is_trusted: false,
182            workspace_root: None,
183            config_file_name,
184            layer_stack,
185            phase_timing: None,
186        })
187    }
188
189    /// Invalidate the cached configuration for a specific workspace.
190    ///
191    /// Call this when config files may have changed on disk and the next
192    /// `load_from_workspace` call should perform a fresh read instead of
193    /// returning a previously cached result.
194    pub fn invalidate_workspace_cache(workspace: impl AsRef<Path>) {
195        let workspace = workspace.as_ref();
196        #[cfg(not(test))]
197        {
198            let canonical_workspace = canonicalize_workspace_root(workspace);
199            cache_remove(&canonical_workspace);
200        }
201        #[cfg(test)]
202        let _ = workspace;
203    }
204
205    /// Invalidate every cached workspace configuration.
206    ///
207    /// Used when a session-scoped override changes or is cleared: the cache is
208    /// keyed by canonical workspace only, so an override-loaded manager for any
209    /// workspace must not leak into later default loads. This is a rare event
210    /// (startup and tests), so a full sweep is cheap and safe.
211    pub fn invalidate_all_workspace_cache() {
212        #[cfg(not(test))]
213        with_cache_mut(|map| map.clear());
214    }
215
216    /// Load configuration from a specific workspace
217    ///
218    /// When the session has an explicit config-file override (captured at
219    /// startup via [`session_override::set_explicit_config_path`]), the
220    /// override file takes precedence and is loaded as the highest file
221    /// layer above the default global layers.
222    pub fn load_from_workspace(workspace: impl AsRef<Path>) -> Result<Self> {
223        if let Some(override_path) = session_override::explicit_config_path() {
224            return Self::load_for_session(workspace, override_path);
225        }
226
227        let workspace = workspace.as_ref();
228        #[cfg(not(test))]
229        let canonical_workspace = canonicalize_workspace_root(workspace);
230
231        #[cfg(not(test))]
232        if let Some(cached) = cache_get(&canonical_workspace) {
233            return Ok(cached.as_ref().clone());
234        }
235
236        let manager = Self::load_from_workspace_impl(workspace)?;
237
238        #[cfg(not(test))]
239        cache_insert(canonical_workspace, Arc::new(manager.clone()));
240
241        Ok(manager)
242    }
243
244    /// Load a workspace configuration and repair provider settings left by
245    /// older versions that persisted the merged configuration into a
246    /// repository-controlled file.
247    ///
248    /// The strict loader still rejects repository-controlled provider
249    /// definitions and endpoint/credential overrides. This entry point only
250    /// handles that specific, already-validated violation by removing the
251    /// prohibited fields from the exact repository layer that introduced it,
252    /// then retrying the normal load. Explicit session files remain trusted
253    /// and are never repaired.
254    pub fn load_from_workspace_with_repository_repair(workspace: impl AsRef<Path>) -> Result<Self> {
255        let workspace = workspace.as_ref();
256        let mut error = match Self::load_from_workspace(workspace) {
257            Ok(manager) => return Ok(manager),
258            Err(error) => error,
259        };
260
261        // More than one repository layer can contain a stale flattened copy.
262        // Repair and retry until the strict loader succeeds or every known
263        // repository layer has had a chance to be cleaned.
264        let repository_paths = Self::repository_config_paths(workspace);
265        for _ in 0..=repository_paths.len() {
266            let Some(violation) = error.downcast_ref::<RepositoryProviderSecurityViolation>() else {
267                return Err(error);
268            };
269
270            let source = violation.source.clone();
271            let Some(path) = repository_paths
272                .iter()
273                .find(|candidate| Self::repository_source_matches_path(&source, candidate))
274            else {
275                return Err(error);
276            };
277
278            if !Self::repair_repository_config_file(path)? {
279                return Err(error);
280            }
281
282            tracing::warn!(
283                path = %path.display(),
284                "Removed prohibited provider settings left by an older repository configuration write"
285            );
286
287            Self::invalidate_workspace_cache(workspace);
288            match Self::load_from_workspace(workspace) {
289                Ok(manager) => return Ok(manager),
290                Err(next_error) => error = next_error,
291            }
292        }
293
294        Err(error).context("Failed to load workspace configuration after repairing prohibited provider settings")
295    }
296
297    fn load_from_workspace_impl(workspace: impl AsRef<Path>) -> Result<Self> {
298        let t0 = std::time::Instant::now();
299        let workspace = workspace.as_ref();
300        let defaults_provider = defaults::current_config_defaults();
301        let workspace_paths = defaults_provider.workspace_paths_for(workspace);
302        let workspace_root = canonicalize_workspace_root(workspace_paths.workspace_root());
303        let config_dir = workspace_paths.config_dir();
304        let config_file_name = defaults_provider.config_file_name().to_string();
305        let canonical_user_config_path = defaults_provider.canonical_user_config_path(&config_file_name)?;
306        let mut tracked_user_config_paths = defaults_provider.home_config_paths(&config_file_name);
307        if let Some(path) = &canonical_user_config_path
308            && !tracked_user_config_paths.iter().any(|existing| existing == path)
309        {
310            tracked_user_config_paths.push(path.clone());
311        }
312        let path_res_duration = t0.elapsed();
313
314        let t1 = std::time::Instant::now();
315
316        // Collect layer sources in precedence order so we can load them
317        // concurrently and then push results while preserving order.
318        let mut layer_sources: Vec<ConfigLayerSource> = Vec::with_capacity(8);
319
320        // 1. System configuration: /etc compatibility followed by
321        // XDG_CONFIG_DIRS candidates, all low-to-high precedence.
322        for system_config_path in defaults_provider.system_config_paths(&config_file_name)? {
323            push_unique_layer_source(&mut layer_sources, ConfigLayerSource::System { file: system_config_path });
324        }
325
326        // 2. Legacy user config followed by the canonical XDG user config.
327        for home_config_path in &tracked_user_config_paths {
328            push_unique_layer_source(&mut layer_sources, ConfigLayerSource::User { file: home_config_path.clone() });
329        }
330
331        // 3. Project-specific config (.vtcode/projects/<project>/config/vtcode.toml)
332        if let Some(project_config_path) = Self::project_config_path(&config_dir, &workspace_root, &config_file_name) {
333            push_unique_layer_source(&mut layer_sources, ConfigLayerSource::Project { file: project_config_path });
334        }
335
336        // 4. Config directory fallback (.vtcode/vtcode.toml)
337        let fallback_path = config_dir.join(&config_file_name);
338        let workspace_config_path = workspace_root.join(&config_file_name);
339        if fallback_path != workspace_config_path {
340            push_unique_layer_source(&mut layer_sources, ConfigLayerSource::Workspace { file: fallback_path });
341        }
342
343        // 5. Workspace config (vtcode.toml in workspace root)
344        push_unique_layer_source(
345            &mut layer_sources,
346            ConfigLayerSource::Workspace { file: workspace_config_path.clone() },
347        );
348
349        // Load all layers concurrently. Each load is independent I/O, so
350        // spawning threads overlaps the disk reads and canonicalization
351        // syscalls. The results are collected in source order to preserve
352        // layer precedence.
353        let raw_layers: Vec<Option<ConfigLayerEntry>> = std::thread::scope(|s| {
354            let handles = layer_sources
355                .into_iter()
356                .map(|source| s.spawn(move || Self::load_optional_layer(source)))
357                .collect::<Vec<_>>();
358            // Join every handle first: `scope` re-panics if any panicked thread is left unjoined.
359            let joined = handles.into_iter().map(|h| h.join()).collect::<Vec<_>>();
360            joined
361                .into_iter()
362                .map(|r| r.map_err(|_payload| anyhow::anyhow!("config layer loader thread panicked")))
363                .collect::<Result<Vec<_>>>()
364        })?;
365
366        let mut layer_stack = ConfigLayerStack::default();
367        for layer in raw_layers.into_iter().flatten() {
368            layer_stack.push(layer);
369        }
370
371        let layer_load_duration = t1.elapsed();
372
373        // If no layers found, use default config
374        if layer_stack.layers().is_empty() {
375            let t_val = std::time::Instant::now();
376            let config = VTCodeConfig::default();
377            config.validate().context("Default configuration failed validation")?;
378            let val_duration = t_val.elapsed();
379
380            let phase_timing = ConfigPhaseTiming {
381                path_resolution_us: path_res_duration.as_micros() as u64,
382                layer_loading_us: layer_load_duration.as_micros() as u64,
383                merge_and_parse_us: 0,
384                validation_us: val_duration.as_micros() as u64,
385            };
386            tracing::debug!(target: "vtcode_config", ?phase_timing, "Default configuration loaded");
387
388            return Ok(Self {
389                config,
390                config_path: None,
391                canonical_user_config_path,
392                tracked_user_config_paths,
393                write_global_config_to_canonical: false,
394                explicit_config_is_trusted: false,
395                workspace_root: Some(workspace_root),
396                config_file_name,
397                layer_stack,
398                phase_timing: Some(phase_timing),
399            });
400        }
401
402        // If the workspace root vtcode.toml sets workspace.use_root_config = true,
403        // discard all lower-precedence layers and re-merge with only the workspace
404        // root and runtime layers.
405        let use_root_config = layer_stack
406            .layers()
407            .iter()
408            .find(|l| {
409                matches!(
410                    &l.source,
411                    ConfigLayerSource::Workspace { file }
412                        if *file == workspace_config_path
413                )
414            })
415            .map(|l| Self::workspace_root_wants_root_config_only(&l.config))
416            .unwrap_or(false);
417        if use_root_config {
418            layer_stack.retain(|layer| {
419                matches!(
420                    &layer.source,
421                    ConfigLayerSource::Workspace { file }
422                        if *file == workspace_config_path
423                ) || matches!(&layer.source, ConfigLayerSource::Runtime)
424            });
425            if layer_stack.layers().is_empty() {
426                bail!(
427                    "workspace.use_root_config is true but no workspace root config was found at {}",
428                    workspace_config_path.display()
429                );
430            }
431        }
432
433        if let Some((layer, error)) = layer_stack.first_layer_error() {
434            bail!("Configuration layer '{}' failed to load: {}", layer.source.label(), error.message);
435        }
436
437        let t2 = std::time::Instant::now();
438        let (effective_toml, origins) = layer_stack.effective_config_with_origins();
439        let mut config: VTCodeConfig = effective_toml
440            .try_into()
441            .context("Failed to deserialize effective configuration")?;
442        let merge_duration = t2.elapsed();
443
444        let t3 = std::time::Instant::now();
445        Self::validate_restricted_agent_fields(&layer_stack, &origins)?;
446        Self::validate_provider_security_fields(&layer_stack, &origins, &config, false)?;
447
448        config.validate().context("Configuration failed validation")?;
449        config.workspace_lifecycle_hooks = Some(Self::collect_workspace_lifecycle_hooks(&layer_stack, &config.hooks));
450
451        // Migrate any plain-text API keys from config to secure storage
452        migrate_custom_api_keys_if_needed(&mut config)?;
453        let val_duration = t3.elapsed();
454
455        let phase_timing = ConfigPhaseTiming {
456            path_resolution_us: path_res_duration.as_micros() as u64,
457            layer_loading_us: layer_load_duration.as_micros() as u64,
458            merge_and_parse_us: merge_duration.as_micros() as u64,
459            validation_us: val_duration.as_micros() as u64,
460        };
461        tracing::debug!(target: "vtcode_config", ?phase_timing, "Workspace configuration loaded");
462
463        let config_path = layer_stack
464            .layers()
465            .iter()
466            .rev()
467            .find(|layer| layer.is_enabled())
468            .and_then(|l| match &l.source {
469                ConfigLayerSource::User { file } => Some(file.clone()),
470                ConfigLayerSource::Project { file } => Some(file.clone()),
471                ConfigLayerSource::Workspace { file } => Some(file.clone()),
472                ConfigLayerSource::System { file } => Some(file.clone()),
473                ConfigLayerSource::Runtime => None,
474            });
475
476        Ok(Self {
477            config,
478            config_path,
479            canonical_user_config_path,
480            tracked_user_config_paths,
481            write_global_config_to_canonical: false,
482            explicit_config_is_trusted: false,
483            workspace_root: Some(workspace_root),
484            config_file_name,
485            layer_stack,
486            phase_timing: Some(phase_timing),
487        })
488    }
489
490    fn load_toml_from_file(path: &Path) -> Result<toml::Value> {
491        let content =
492            fs::read_to_string(path).with_context(|| format!("Failed to read config file: {}", path.display()))?;
493        let mut value: toml::Value =
494            toml::from_str(&content).with_context(|| format!("Failed to parse config file: {}", path.display()))?;
495        crate::loader::merge::normalize_legacy_top_level_provider_aliases(&mut value);
496        Ok(value)
497    }
498
499    fn load_optional_layer(source: ConfigLayerSource) -> Option<ConfigLayerEntry> {
500        let file = match &source {
501            ConfigLayerSource::System { file }
502            | ConfigLayerSource::User { file }
503            | ConfigLayerSource::Project { file }
504            | ConfigLayerSource::Workspace { file } => file,
505            ConfigLayerSource::Runtime => {
506                return Some(ConfigLayerEntry::new(source, toml::Value::Table(toml::Table::new())));
507            }
508        };
509
510        // Read first so missing files return None without a redundant canonicalize.
511        let content = match fs::read_to_string(file) {
512            Ok(content) => content,
513            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return None,
514            Err(err) if should_skip_optional_global_error(&source, &err) => {
515                tracing::debug!(path = %file.display(), "skipping inaccessible optional configuration layer");
516                return None;
517            }
518            Err(err) => {
519                let resolved_file = canonicalize_workspace_root(file);
520                let resolved_source = source.with_file(resolved_file);
521                return Some(Self::disabled_layer_from_error(resolved_source, err.into()));
522            }
523        };
524
525        let resolved_file = canonicalize_workspace_root(file);
526        let resolved_source = source.with_file(resolved_file);
527
528        match toml::from_str::<toml::Value>(&content) {
529            Ok(mut toml) => {
530                crate::loader::merge::normalize_legacy_top_level_provider_aliases(&mut toml);
531                Some(ConfigLayerEntry::new(resolved_source, toml))
532            }
533            Err(err) => {
534                let error =
535                    anyhow::Error::from(err).context(format!("Failed to parse config file: {}", file.display()));
536                Some(Self::disabled_layer_from_error(resolved_source, error))
537            }
538        }
539    }
540
541    fn disabled_layer_from_error(source: ConfigLayerSource, error: anyhow::Error) -> ConfigLayerEntry {
542        let reason = if error.is::<toml::de::Error>() {
543            LayerDisabledReason::ParseError
544        } else {
545            LayerDisabledReason::LoadError
546        };
547        ConfigLayerEntry::disabled(source, reason, format!("{error:#}"))
548    }
549
550    /// Check whether a parsed TOML value has `workspace.use_root_config = true`.
551    ///
552    /// This is checked against the already-loaded workspace root layer config
553    /// to avoid a redundant file read.
554    fn workspace_root_wants_root_config_only(config: &toml::Value) -> bool {
555        config
556            .get("workspace")
557            .and_then(|v| v.get("use_root_config"))
558            .and_then(|v| v.as_bool())
559            .unwrap_or(false)
560    }
561
562    /// Load configuration from a specific file
563    pub fn load_from_file(path: impl AsRef<Path>) -> Result<Self> {
564        Self::load_from_file_impl(path, false)
565    }
566
567    fn load_from_file_impl(path: impl AsRef<Path>, write_global_config_to_canonical: bool) -> Result<Self> {
568        let path = path.as_ref();
569        let defaults_provider = defaults::current_config_defaults();
570        // Global layer probing always uses the canonical config file name, never
571        // the basename of the explicit override file. Deriving it from the file
572        // previously caused global layers (e.g. `~/.config/vtcode/vtcode.toml`
573        // with `[[custom_providers]]`) to be skipped whenever the override file
574        // was named something else (e.g. `night.toml`), silently dropping
575        // custom providers and other user-global settings.
576        let config_file_name = defaults_provider.config_file_name().to_string();
577        let canonical_user_config_path = defaults_provider.canonical_user_config_path(&config_file_name)?;
578        let mut tracked_user_config_paths = defaults_provider.home_config_paths(&config_file_name);
579        if let Some(path) = &canonical_user_config_path
580            && !tracked_user_config_paths.iter().any(|existing| existing == path)
581        {
582            tracked_user_config_paths.push(path.clone());
583        }
584
585        let mut layer_stack = ConfigLayerStack::default();
586        let mut global_sources = Vec::new();
587
588        for system_config in defaults_provider.system_config_paths(&config_file_name)? {
589            push_unique_layer_source(&mut global_sources, ConfigLayerSource::System { file: system_config });
590        }
591        for home_config_path in &tracked_user_config_paths {
592            push_unique_layer_source(&mut global_sources, ConfigLayerSource::User { file: home_config_path.clone() });
593        }
594        for source in global_sources {
595            if let Some(layer) = Self::load_optional_layer(source) {
596                layer_stack.push(layer);
597            }
598        }
599
600        // 3. The specific file provided (Workspace layer)
601        let file = path.to_path_buf();
602        match Self::load_toml_from_file(path) {
603            Ok(toml) => layer_stack.push(ConfigLayerEntry::new(ConfigLayerSource::Workspace { file }, toml)),
604            Err(error) => {
605                layer_stack.push(Self::disabled_layer_from_error(ConfigLayerSource::Workspace { file }, error))
606            }
607        }
608
609        // If the provided file sets workspace.use_root_config = true, discard
610        // lower-precedence layers so only this file and runtime overrides apply.
611        let use_root_config = layer_stack
612            .layers()
613            .iter()
614            .find(|l| {
615                matches!(
616                    &l.source,
617                    ConfigLayerSource::Workspace { file }
618                        if *file == path
619                )
620            })
621            .map(|l| Self::workspace_root_wants_root_config_only(&l.config))
622            .unwrap_or(false);
623        if use_root_config {
624            layer_stack.retain(|layer| {
625                matches!(
626                    &layer.source,
627                    ConfigLayerSource::Workspace { file }
628                        if *file == path
629                ) || matches!(&layer.source, ConfigLayerSource::Runtime)
630            });
631        }
632
633        if let Some((layer, error)) = layer_stack.first_layer_error() {
634            bail!("Configuration layer '{}' failed to load: {}", layer.source.label(), error.message);
635        }
636
637        let (effective_toml, origins) = layer_stack.effective_config_with_origins();
638        let mut config: VTCodeConfig = effective_toml
639            .try_into()
640            .with_context(|| format!("Failed to parse effective config with file: {}", path.display()))?;
641        Self::validate_restricted_agent_fields(&layer_stack, &origins)?;
642        // A path supplied explicitly by the user is an opt-in trusted layer,
643        // even though it uses the Workspace source variant for precedence and
644        // compatibility with the existing loader.
645        Self::validate_provider_security_fields(&layer_stack, &origins, &config, true)?;
646
647        config
648            .validate()
649            .with_context(|| format!("Failed to validate effective config with file: {}", path.display()))?;
650        config.workspace_lifecycle_hooks = Some(Self::collect_workspace_lifecycle_hooks(&layer_stack, &config.hooks));
651
652        Ok(Self {
653            config,
654            config_path: Some(canonicalize_workspace_root(path)),
655            canonical_user_config_path,
656            tracked_user_config_paths,
657            write_global_config_to_canonical,
658            explicit_config_is_trusted: true,
659            workspace_root: path.parent().map(canonicalize_workspace_root),
660            config_file_name,
661            layer_stack,
662            phase_timing: None,
663        })
664    }
665
666    /// Resolve the current project name used for project-level config overlays.
667    pub fn current_project_name(workspace_root: &Path) -> Option<String> {
668        Self::identify_current_project(workspace_root)
669    }
670
671    fn validate_restricted_agent_fields(
672        layer_stack: &ConfigLayerStack,
673        origins: &hashbrown::HashMap<String, ConfigLayerMetadata>,
674    ) -> Result<()> {
675        if let Some(origin) = origins.get("agent.persistent_memory.directory_override")
676            && let Some(layer) = layer_stack.layers().iter().find(|layer| layer.metadata == *origin)
677        {
678            match layer.source {
679                ConfigLayerSource::System { .. }
680                | ConfigLayerSource::User { .. }
681                | ConfigLayerSource::Project { .. } => {}
682                ConfigLayerSource::Workspace { .. } | ConfigLayerSource::Runtime => {
683                    bail!(
684                        "agent.persistent_memory.directory_override may only be set in system, user, or project-profile configuration layers"
685                    );
686                }
687            }
688        }
689
690        Ok(())
691    }
692
693    /// Reject provider settings from repository-controlled layers before they
694    /// can reach provider registration or request handling.
695    ///
696    /// Workspace and project files are repository-controlled input. They may
697    /// configure ordinary agent behavior, but must not introduce executable
698    /// authentication commands or redirect provider requests/credentials.
699    /// `load_from_file` is an explicit user opt-in and therefore passes
700    /// `explicit_config_is_trusted = true`.
701    fn validate_provider_security_fields(
702        layer_stack: &ConfigLayerStack,
703        origins: &hashbrown::HashMap<String, ConfigLayerMetadata>,
704        config: &VTCodeConfig,
705        explicit_config_is_trusted: bool,
706    ) -> Result<()> {
707        if explicit_config_is_trusted {
708            return Ok(());
709        }
710
711        if !config.custom_providers.is_empty()
712            && let Some(origin) = origins.get("custom_providers")
713            && let Some(layer) = Self::enabled_layer_for_origin(layer_stack, origin)
714            && Self::is_repository_controlled_source(&layer.source)
715        {
716            return Err(RepositoryProviderSecurityViolation {
717                source: layer.source.clone(),
718                message: format!(
719                    "repository-controlled configuration cannot define `custom_providers` (including `auth.command`); move custom provider settings to system, user, or an explicitly selected config file (source: {})",
720                    layer.source.label()
721                ),
722            }
723            .into());
724        }
725
726        for (path, origin) in origins {
727            if path == "auth.copilot.command"
728                && config
729                    .auth
730                    .copilot
731                    .command
732                    .as_deref()
733                    .is_some_and(|command| !command.trim().is_empty())
734                && let Some(layer) = Self::enabled_layer_for_origin(layer_stack, origin)
735                && Self::is_repository_controlled_source(&layer.source)
736            {
737                bail!(
738                    "repository-controlled configuration cannot set `auth.copilot.command`; configure it in system, user, or an explicitly selected config file (source: {})",
739                    layer.source.label()
740                );
741            }
742            if !Self::is_provider_endpoint_or_credential_path(path) {
743                continue;
744            }
745
746            let Some(layer) = Self::enabled_layer_for_origin(layer_stack, origin) else {
747                continue;
748            };
749            if Self::is_repository_controlled_source(&layer.source) {
750                return Err(RepositoryProviderSecurityViolation {
751                    source: layer.source.clone(),
752                    message: format!(
753                        "repository-controlled configuration cannot set `{path}`; provider endpoints and credential environment variables must be configured in system, user, or an explicitly selected config file (source: {})",
754                        layer.source.label()
755                    ),
756                }
757                .into());
758            }
759        }
760
761        Ok(())
762    }
763
764    fn enabled_layer_for_origin<'a>(
765        layer_stack: &'a ConfigLayerStack,
766        origin: &ConfigLayerMetadata,
767    ) -> Option<&'a ConfigLayerEntry> {
768        layer_stack
769            .layers()
770            .iter()
771            .find(|layer| layer.is_enabled() && layer.metadata == *origin)
772    }
773
774    fn is_repository_controlled_source(source: &ConfigLayerSource) -> bool {
775        matches!(source, ConfigLayerSource::Project { .. } | ConfigLayerSource::Workspace { .. })
776    }
777
778    fn is_provider_endpoint_or_credential_path(path: &str) -> bool {
779        let mut segments = path.split('.');
780        matches!(segments.next(), Some("provider_overrides"))
781            && segments.next().is_some()
782            && matches!(segments.next(), Some("base_url" | "api_key_env"))
783            && segments.next().is_none()
784    }
785
786    /// Collect lifecycle hook commands whose effective origin is a
787    /// workspace-controlled layer: the workspace-root `vtcode.toml`, the
788    /// workspace `.vtcode/vtcode.toml` fallback, or a project profile stored
789    /// inside the workspace. These are attacker-influenceable in an untrusted
790    /// repository and must be gated behind explicit user approval.
791    pub(crate) fn collect_workspace_lifecycle_hooks(
792        layer_stack: &ConfigLayerStack,
793        hooks: &HooksConfig,
794    ) -> WorkspaceLifecycleHooks {
795        let (_, origins) = layer_stack.effective_config_with_origins();
796        let mut commands = Vec::new();
797
798        for event in crate::hooks::LIFECYCLE_HOOK_EVENTS {
799            let origin_key = format!("hooks.lifecycle.{event}");
800            let Some(origin) = origins.get(&origin_key) else {
801                continue;
802            };
803            let workspace_controlled = layer_stack.layers().iter().any(|layer| {
804                layer.is_enabled()
805                    && layer.metadata == *origin
806                    && matches!(layer.source, ConfigLayerSource::Workspace { .. } | ConfigLayerSource::Project { .. })
807            });
808            if !workspace_controlled {
809                continue;
810            }
811
812            // Deprecated aliases fold into `stop` at engine normalization.
813            let tag = match *event {
814                "task_completion" | "task_completed" => "stop",
815                other => other,
816            };
817            for group in hooks.lifecycle.groups_for_event(event) {
818                for command in &group.hooks {
819                    commands.push(WorkspaceHookCommand {
820                        event: tag.to_string(),
821                        matcher: group.matcher.clone(),
822                        command: command.command.clone(),
823                        timeout_seconds: command.timeout_seconds,
824                    });
825                }
826            }
827        }
828
829        WorkspaceLifecycleHooks { commands }
830    }
831}
832
833#[cfg(test)]
834mod sparse_config_tests;
835#[cfg(test)]
836mod workspace_lifecycle_hooks_tests;