Skip to main content

aft/
config_resolve.rs

1//! Pure aft.jsonc tier resolver.
2//!
3//! This module mirrors the TypeScript config pipeline for the core-consumed
4//! slice: raw JSONC tiers -> strict raw schema -> user/project trust merge ->
5//! flat [`Config`]. It intentionally performs no IO; callers supply the already
6//! read config documents.
7
8use std::collections::{BTreeMap, HashMap, HashSet};
9use std::path::PathBuf;
10
11use serde::de;
12use serde::{Deserialize, Deserializer};
13use serde_json::{Map, Value};
14
15use crate::config::{
16    BackupConfig, Config, InspectConfig, SandboxConfig, SemanticBackend, SemanticBackendConfig,
17    UserServerDef, WorktreeConfig, DEFAULT_INSPECT_DIAGNOSTICS_TIMEOUT_MS,
18    MAX_INSPECT_DIAGNOSTICS_TIMEOUT_MS, MAX_SEMANTIC_QUERY_TIMEOUT_MS,
19    MIN_INSPECT_DIAGNOSTICS_TIMEOUT_MS, MIN_SEMANTIC_QUERY_TIMEOUT_MS,
20};
21use crate::jsonc::strip_jsonc;
22
23const FOREGROUND_WAIT_WINDOW_DEFAULT_MS: u64 = 15_000;
24const FOREGROUND_WAIT_WINDOW_MIN_MS: u64 = 5_000;
25
26// Semantic budget clamps — restored from the deleted configure-time
27// parse_semantic_config so the tier-resolved Config matches the historical
28// clamping (zero-behavior-change relocation, not a new policy).
29const MAX_SEMANTIC_TIMEOUT_MS: u64 = 120_000;
30const MAX_SEMANTIC_BATCH_SIZE: usize = 1_024;
31
32const USER_ONLY_REASON: &str =
33    "security: this setting only honors user-level config and project values are ignored";
34const SEMANTIC_SECRET_REASON: &str =
35    "security: semantic backend credentials and endpoints must come from user-level config";
36const LSP_USER_ONLY_REASON: &str =
37    "security: LSP executable-origin and diagnostic-suppression settings must come from user-level config";
38
39/// One raw config document supplied by the host plugin.
40///
41/// `tier` is trusted process metadata stamped by the caller. The document body is
42/// never allowed to relabel itself.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct ConfigTier {
45    pub tier: String,
46    pub source: String,
47    pub doc: String,
48}
49
50/// A project-tier key that was intentionally ignored at the user/project trust boundary.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct DroppedKey {
53    pub key: String,
54    pub tier: String,
55    pub reason: String,
56}
57
58/// A non-fatal config issue reported back to the caller during configuration.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct ConfigWarning {
61    pub code: &'static str,
62    pub key: &'static str,
63    pub tier: String,
64    pub value: String,
65    pub message: String,
66}
67
68/// Diagnostics produced while resetting an existing runtime config.
69#[derive(Debug, Clone, Default, PartialEq, Eq)]
70pub struct ResolveDiagnostics {
71    pub dropped: Vec<DroppedKey>,
72    pub warnings: Vec<ConfigWarning>,
73}
74
75/// Fully resolved core config plus trust-boundary diagnostics.
76#[derive(Debug, Clone)]
77pub struct ResolveResult {
78    pub config: Config,
79    pub dropped: Vec<DroppedKey>,
80    pub warnings: Vec<ConfigWarning>,
81}
82
83/// Strict raw shape for aft.jsonc. This mirrors the TypeScript Zod schema, not
84/// the flat runtime [`Config`]. Privileged process-state fields are deliberately
85/// absent and therefore rejected by `deny_unknown_fields`.
86#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
87#[serde(default, deny_unknown_fields)]
88pub struct RawAftConfig {
89    #[serde(rename = "$schema")]
90    pub schema: Option<String>,
91    /// Master switch read by the TypeScript plugins before they start AFT.
92    /// The resolver accepts and merges it for validation, but does not copy it
93    /// into `Config`; when this is false the plugin returns before launching the
94    /// Rust process.
95    pub enabled: Option<bool>,
96    pub edit_mode: Option<RawEditMode>,
97    pub format_on_edit: Option<bool>,
98    #[serde(deserialize_with = "deserialize_opt_timeout_secs")]
99    pub formatter_timeout_secs: Option<u32>,
100    #[serde(deserialize_with = "deserialize_opt_timeout_secs")]
101    pub type_checker_timeout_secs: Option<u32>,
102    pub validate_on_edit: Option<RawValidateOnEdit>,
103    pub formatter: Option<HashMap<String, RawFormatter>>,
104    pub checker: Option<HashMap<String, RawChecker>>,
105    pub configure_warnings_delivery: Option<RawConfigureWarningsDelivery>,
106    pub hoist_builtin_tools: Option<bool>,
107    pub tool_surface: Option<RawToolSurface>,
108    pub disabled_tools: Option<Vec<String>>,
109    pub restrict_to_project_root: Option<bool>,
110    pub search_index: Option<bool>,
111    pub semantic_search: Option<bool>,
112    pub callgraph_store: Option<bool>,
113    #[serde(deserialize_with = "deserialize_opt_usize")]
114    pub callgraph_chunk_size: Option<usize>,
115    pub inspect: Option<RawInspect>,
116    pub backup: Option<RawBackup>,
117    pub worktree: Option<RawWorktree>,
118    pub sandbox: Option<RawSandbox>,
119    pub bash: Option<RawBash>,
120    pub experimental: Option<RawExperimental>,
121    pub lsp: Option<RawLsp>,
122    pub url_fetch_allow_private: Option<bool>,
123    pub semantic: Option<RawSemantic>,
124    pub auto_update: Option<bool>,
125    pub bridge: Option<RawBridge>,
126}
127
128#[derive(Debug, Clone, PartialEq, Eq)]
129pub enum RawEditMode {
130    Default,
131    Hashline,
132    Unknown(String),
133}
134
135impl<'de> Deserialize<'de> for RawEditMode {
136    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
137    where
138        D: Deserializer<'de>,
139    {
140        let value = String::deserialize(deserializer)?;
141        Ok(match value.as_str() {
142            "default" => Self::Default,
143            "hashline" => Self::Hashline,
144            _ => Self::Unknown(value),
145        })
146    }
147}
148
149#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
150#[serde(rename_all = "snake_case")]
151pub enum RawValidateOnEdit {
152    Syntax,
153    Full,
154}
155
156impl RawValidateOnEdit {
157    const fn as_str(self) -> &'static str {
158        match self {
159            Self::Syntax => "syntax",
160            Self::Full => "full",
161        }
162    }
163}
164
165#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
166#[serde(rename_all = "snake_case")]
167pub enum RawFormatter {
168    Biome,
169    Oxfmt,
170    Prettier,
171    Deno,
172    Ruff,
173    Black,
174    Rustfmt,
175    Goimports,
176    Gofmt,
177    None,
178}
179
180impl RawFormatter {
181    const fn as_str(self) -> &'static str {
182        match self {
183            Self::Biome => "biome",
184            Self::Oxfmt => "oxfmt",
185            Self::Prettier => "prettier",
186            Self::Deno => "deno",
187            Self::Ruff => "ruff",
188            Self::Black => "black",
189            Self::Rustfmt => "rustfmt",
190            Self::Goimports => "goimports",
191            Self::Gofmt => "gofmt",
192            Self::None => "none",
193        }
194    }
195}
196
197#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
198#[serde(rename_all = "snake_case")]
199pub enum RawChecker {
200    Tsc,
201    Tsgo,
202    Biome,
203    Pyright,
204    Ruff,
205    Cargo,
206    Go,
207    Staticcheck,
208    None,
209}
210
211impl RawChecker {
212    const fn as_str(self) -> &'static str {
213        match self {
214            Self::Tsc => "tsc",
215            Self::Tsgo => "tsgo",
216            Self::Biome => "biome",
217            Self::Pyright => "pyright",
218            Self::Ruff => "ruff",
219            Self::Cargo => "cargo",
220            Self::Go => "go",
221            Self::Staticcheck => "staticcheck",
222            Self::None => "none",
223        }
224    }
225}
226
227#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
228#[serde(rename_all = "snake_case")]
229pub enum RawConfigureWarningsDelivery {
230    Toast,
231    Log,
232    Chat,
233}
234
235#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
236#[serde(rename_all = "snake_case")]
237pub enum RawToolSurface {
238    Minimal,
239    Recommended,
240    All,
241}
242
243#[derive(Debug, Clone, Deserialize, PartialEq)]
244// Nested objects mirror TS sub-schemas, which are non-strict z.object (unknown
245// keys are silently stripped, the object survives). Only the TOP-LEVEL
246// RawAftConfig is strict (matches AftConfigSchema.strict()). Privileged denylist
247// fields are all top-level, so nested unknowns are harmlessly ignored.
248pub struct RawSemantic {
249    pub backend: Option<SemanticBackend>,
250    #[serde(default, deserialize_with = "deserialize_opt_trimmed_non_empty_string")]
251    pub model: Option<String>,
252    #[serde(default, deserialize_with = "deserialize_opt_trimmed_non_empty_string")]
253    pub base_url: Option<String>,
254    #[serde(default, deserialize_with = "deserialize_opt_trimmed_non_empty_string")]
255    pub api_key_env: Option<String>,
256    #[serde(default, deserialize_with = "deserialize_opt_positive_u64")]
257    pub timeout_ms: Option<u64>,
258    #[serde(default, deserialize_with = "deserialize_opt_positive_u64")]
259    pub query_timeout_ms: Option<u64>,
260    #[serde(default, deserialize_with = "deserialize_opt_positive_usize")]
261    pub max_batch_size: Option<usize>,
262    #[serde(default, deserialize_with = "deserialize_opt_positive_usize")]
263    pub max_files: Option<usize>,
264}
265
266impl RawSemantic {
267    fn is_empty(&self) -> bool {
268        self.backend.is_none()
269            && self.model.is_none()
270            && self.base_url.is_none()
271            && self.api_key_env.is_none()
272            && self.timeout_ms.is_none()
273            && self.query_timeout_ms.is_none()
274            && self.max_batch_size.is_none()
275            && self.max_files.is_none()
276    }
277}
278
279#[derive(Debug, Clone, Deserialize, PartialEq)]
280pub struct RawLsp {
281    #[serde(default, deserialize_with = "deserialize_opt_lsp_servers")]
282    pub servers: Option<BTreeMap<String, RawLspServerEntry>>,
283    #[serde(
284        default,
285        deserialize_with = "deserialize_opt_trimmed_non_empty_string_vec"
286    )]
287    pub disabled: Option<Vec<String>>,
288    pub python: Option<RawPythonLsp>,
289    pub diagnostics_on_edit: Option<bool>,
290    pub auto_install: Option<bool>,
291    #[serde(default, deserialize_with = "deserialize_opt_positive_u64")]
292    pub grace_days: Option<u64>,
293    #[serde(default, deserialize_with = "deserialize_opt_versions_map")]
294    pub versions: Option<HashMap<String, String>>,
295}
296
297impl RawLsp {
298    fn is_empty(&self) -> bool {
299        self.servers.is_none()
300            && self.disabled.is_none()
301            && self.python.is_none()
302            && self.diagnostics_on_edit.is_none()
303            && self.auto_install.is_none()
304            && self.grace_days.is_none()
305            && self.versions.is_none()
306    }
307}
308
309#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
310#[serde(rename_all = "snake_case")]
311pub enum RawPythonLsp {
312    Pyright,
313    Ty,
314    Auto,
315}
316
317#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
318#[serde(default)]
319pub struct RawLspServerEntry {
320    #[serde(deserialize_with = "deserialize_opt_lsp_extensions")]
321    pub extensions: Option<Vec<String>>,
322    #[serde(deserialize_with = "deserialize_opt_trimmed_non_empty_string")]
323    pub binary: Option<String>,
324    pub args: Option<Vec<String>>,
325    #[serde(deserialize_with = "deserialize_opt_trimmed_non_empty_string_vec")]
326    pub root_markers: Option<Vec<String>>,
327    pub disabled: Option<bool>,
328    pub env: Option<HashMap<String, String>>,
329    pub initialization_options: Option<Value>,
330}
331
332#[derive(Debug, Clone, Deserialize, PartialEq)]
333#[serde(untagged)]
334pub enum RawBash {
335    Bool(bool),
336    Features(RawBashFeatures),
337}
338
339#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
340#[serde(default)]
341pub struct RawBashFeatures {
342    pub rewrite: Option<bool>,
343    pub compress: Option<bool>,
344    pub background: Option<bool>,
345    pub host_fallback: Option<bool>,
346    pub subagent_background: Option<bool>,
347    pub long_running_reminder_enabled: Option<bool>,
348    #[serde(deserialize_with = "deserialize_opt_positive_u64")]
349    pub long_running_reminder_interval_ms: Option<u64>,
350    #[serde(deserialize_with = "deserialize_opt_positive_u64")]
351    pub foreground_wait_window_ms: Option<u64>,
352}
353
354#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
355#[serde(default)]
356pub struct RawExperimental {
357    pub bash: Option<RawExperimentalBash>,
358    pub lsp_ty: Option<bool>,
359}
360
361impl RawExperimental {
362    fn is_empty(&self) -> bool {
363        self.bash.is_none() && self.lsp_ty.is_none()
364    }
365}
366
367#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
368#[serde(default)]
369pub struct RawExperimentalBash {
370    pub rewrite: Option<bool>,
371    pub compress: Option<bool>,
372    pub background: Option<bool>,
373    pub long_running_reminder_enabled: Option<bool>,
374    #[serde(deserialize_with = "deserialize_opt_positive_u64")]
375    pub long_running_reminder_interval_ms: Option<u64>,
376}
377
378impl RawExperimentalBash {
379    fn has_any_value(&self) -> bool {
380        self.rewrite.is_some()
381            || self.compress.is_some()
382            || self.background.is_some()
383            || self.long_running_reminder_enabled.is_some()
384            || self.long_running_reminder_interval_ms.is_some()
385    }
386
387    fn has_legacy_feature_flag(&self) -> bool {
388        self.rewrite.is_some() || self.compress.is_some() || self.background.is_some()
389    }
390}
391
392#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
393#[serde(default)]
394pub struct RawInspect {
395    pub enabled: Option<bool>,
396    #[serde(default, deserialize_with = "deserialize_opt_positive_u64")]
397    pub diagnostics_timeout_ms: Option<u64>,
398    #[serde(deserialize_with = "deserialize_opt_nonnegative_f64")]
399    pub tier2_idle_minutes: Option<f64>,
400    pub categories: Option<HashMap<String, bool>>,
401    #[serde(deserialize_with = "deserialize_opt_positive_u64")]
402    pub tier2_soft_deadline_ms: Option<u64>,
403    #[serde(deserialize_with = "deserialize_opt_drill_down_items")]
404    pub max_drill_down_items: Option<usize>,
405    pub duplicates: Option<RawInspectDuplicates>,
406}
407
408impl RawInspect {
409    fn is_empty(&self) -> bool {
410        self.enabled.is_none()
411            && self.diagnostics_timeout_ms.is_none()
412            && self.tier2_idle_minutes.is_none()
413            && self.categories.is_none()
414            && self.tier2_soft_deadline_ms.is_none()
415            && self.max_drill_down_items.is_none()
416            && self.duplicates.is_none()
417    }
418}
419
420/// Only `expected_mirrors` survives here: `lower_bound`, `discard_cost`, and
421/// `anonymize` were accepted-but-never-read knobs (the scanner hardcodes its
422/// cost bounds and anonymization rules), so they were removed from the schema
423/// rather than wired up.
424#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
425#[serde(default)]
426pub struct RawInspectDuplicates {
427    pub expected_mirrors: Option<Vec<[String; 2]>>,
428}
429
430impl RawInspectDuplicates {
431    fn is_empty(&self) -> bool {
432        self.expected_mirrors.is_none()
433    }
434}
435
436#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
437#[serde(default)]
438pub struct RawBridge {
439    #[serde(deserialize_with = "deserialize_opt_bridge_request_timeout_ms")]
440    pub request_timeout_ms: Option<u64>,
441    #[serde(deserialize_with = "deserialize_opt_positive_u64")]
442    pub hang_threshold: Option<u64>,
443}
444
445#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
446#[serde(default)]
447pub struct RawWorktree {
448    pub ram_overlay: Option<bool>,
449}
450
451impl RawWorktree {
452    fn is_empty(&self) -> bool {
453        self.ram_overlay.is_none()
454    }
455}
456
457#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
458#[serde(default)]
459pub struct RawBackup {
460    pub enabled: Option<bool>,
461    #[serde(default, deserialize_with = "deserialize_opt_positive_usize")]
462    pub max_depth: Option<usize>,
463    #[serde(default, deserialize_with = "deserialize_opt_positive_u64")]
464    pub max_file_size: Option<u64>,
465}
466
467#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
468#[serde(default)]
469pub struct RawSandbox {
470    pub enabled: Option<bool>,
471    pub write_allow: Option<Vec<PathBuf>>,
472    pub read_deny: Option<Vec<PathBuf>>,
473}
474
475/// Resolve raw user/project config tiers into the flat core [`Config`].
476///
477/// Empty input is NOT special-cased: no config file is equivalent to an empty
478/// config object, so it still flows through the resolver and picks up the bash
479/// surface default (recommended ⇒ bash on), matching the TypeScript pipeline
480/// which always runs `resolveProjectOverridesForConfigure` even on `{}`.
481pub fn resolve_config(tiers: &[ConfigTier]) -> ResolveResult {
482    let mut merged = RawAftConfig::default();
483    let mut dropped = Vec::new();
484    let mut warnings = Vec::new();
485
486    for tier in tiers {
487        let Some(raw) = parse_tier(tier) else {
488            continue;
489        };
490        if let Some(RawEditMode::Unknown(value)) = raw.edit_mode.as_ref() {
491            warnings.push(ConfigWarning {
492                code: "invalid_edit_mode",
493                key: "edit_mode",
494                tier: tier.tier.clone(),
495                value: value.clone(),
496                message: format!("Unknown edit_mode value {value:?}; falling back to \"default\""),
497            });
498        }
499
500        if tier.tier == "user" {
501            merge_trusted_config(&mut merged, raw);
502        } else {
503            record_project_drops(&raw, &tier.tier, &mut dropped);
504            merge_project_config(&mut merged, raw);
505        }
506    }
507
508    let mut config = Config::default();
509    apply_resolved_config(&merged, &mut config);
510    ResolveResult {
511        config,
512        dropped,
513        warnings,
514    }
515}
516
517/// Resolve raw config tiers into the core-domain config and RESET it onto an
518/// existing `base`, preserving only `base`'s process-state fields (storage_dir,
519/// harness, lsp_paths_extra, bash_permissions, …). This is the configure-path
520/// entry.
521///
522/// RESET, not overlay: the core-domain config is rebuilt from DEFAULT + the
523/// supplied tiers, so a field absent from the tiers returns to its default —
524/// it NEVER keeps `base`'s prior value. This closes a cross-bind privilege
525/// escalation: under the subc daemon a single `AppContext` per project root is
526/// shared across harness identities, and `configure` seeds `base` from the
527/// previous bind's config. With the old overlay semantics, a later low-trust
528/// bind (e.g. `mcp:*` or `fed:*`) that omitted a field inherited an earlier high-trust
529/// bind's capability for it (confirmed on the wire: `url_fetch_allow_private`
530/// SSRF, and `lsp_servers` arbitrary-binary). Reset-onto-default makes the
531/// resolved core config a pure function of THIS bind's own tiers.
532///
533/// Parity-safe by construction: this routes through [`resolve_config`] (the same
534/// entry the cross-language parity gate validates), which builds onto
535/// `Config::default()` — so reset-onto-default == overlay-onto-default there and
536/// no parity/unit fixture changes. Only this configure path, seeded from a prior
537/// config, changes behavior — which is exactly the leak site.
538///
539/// Process-state fields are not part of `RawAftConfig`; they are carried from
540/// `base` here and re-applied by `handle_configure`'s flat-param parsing
541/// afterwards, so plugin-mode behavior is unchanged (the plugin re-sends them on
542/// every configure). They are also unreachable as a subc escalation vector: a
543/// subc RouteBind sends only `config:[tiers]`, never the flat process-state
544/// params, so they stay at default for every subc bind regardless.
545pub fn resolve_config_onto(tiers: &[ConfigTier], base: &mut Config) -> Vec<DroppedKey> {
546    resolve_config_onto_with_diagnostics(tiers, base).dropped
547}
548
549/// Reset a runtime config while retaining both trust-boundary drops and
550/// non-fatal value warnings for the configure response.
551pub fn resolve_config_onto_with_diagnostics(
552    tiers: &[ConfigTier],
553    base: &mut Config,
554) -> ResolveDiagnostics {
555    let ResolveResult {
556        mut config,
557        dropped,
558        warnings,
559    } = resolve_config(tiers);
560    carry_process_state(base, &mut config);
561    *base = config;
562    ResolveDiagnostics { dropped, warnings }
563}
564
565/// Carry the process-state (non-`RawAftConfig`) fields from `base` onto a
566/// freshly-resolved core config. EVERY `Config` field not copied here is
567/// core-domain and intentionally comes from the resolved tiers (reset). Keeping
568/// this list complete is load-bearing: a core field accidentally copied here
569/// would re-introduce cross-bind inheritance for it.
570fn carry_process_state(base: &Config, resolved: &mut Config) {
571    resolved.project_root = base.project_root.clone();
572    resolved.harness = base.harness.clone();
573    resolved.validation_depth = base.validation_depth;
574    resolved.checkpoint_ttl_hours = base.checkpoint_ttl_hours;
575    resolved.max_symbol_depth = base.max_symbol_depth;
576    resolved.diagnostic_cache_size = base.diagnostic_cache_size;
577    resolved.aft_search_registered = base.aft_search_registered;
578    resolved.max_background_bash_tasks = base.max_background_bash_tasks;
579    resolved.bash_permissions = base.bash_permissions;
580    resolved.search_index_max_file_size = base.search_index_max_file_size;
581    resolved.storage_dir = base.storage_dir.clone();
582    resolved.lsp_paths_extra = base.lsp_paths_extra.clone();
583    resolved.lsp_auto_install_binaries = base.lsp_auto_install_binaries.clone();
584    resolved.lsp_inflight_installs = base.lsp_inflight_installs.clone();
585}
586
587fn parse_tier(tier: &ConfigTier) -> Option<RawAftConfig> {
588    let stripped = strip_jsonc(&tier.doc);
589    let value = serde_json::from_str::<Value>(&stripped).ok()?;
590    let Value::Object(map) = value else {
591        return None;
592    };
593
594    match serde_json::from_value::<RawAftConfig>(Value::Object(map.clone())) {
595        Ok(config) => Some(config),
596        Err(_) => Some(parse_config_partially(map)),
597    }
598}
599
600fn parse_config_partially(raw_config: Map<String, Value>) -> RawAftConfig {
601    let mut partial = RawAftConfig::default();
602
603    for (key, value) in raw_config {
604        let mut one_field = Map::new();
605        one_field.insert(key, value);
606        if let Ok(section) = serde_json::from_value::<RawAftConfig>(Value::Object(one_field)) {
607            merge_trusted_config(&mut partial, section);
608        }
609    }
610
611    partial
612}
613
614fn merge_trusted_config(base: &mut RawAftConfig, override_config: RawAftConfig) {
615    if override_config.schema.is_some() {
616        base.schema = override_config.schema;
617    }
618    if override_config.enabled.is_some() {
619        base.enabled = override_config.enabled;
620    }
621    if override_config.edit_mode.is_some() {
622        base.edit_mode = override_config.edit_mode;
623    }
624    if override_config.format_on_edit.is_some() {
625        base.format_on_edit = override_config.format_on_edit;
626    }
627    if override_config.formatter_timeout_secs.is_some() {
628        base.formatter_timeout_secs = override_config.formatter_timeout_secs;
629    }
630    if override_config.type_checker_timeout_secs.is_some() {
631        base.type_checker_timeout_secs = override_config.type_checker_timeout_secs;
632    }
633    if override_config.validate_on_edit.is_some() {
634        base.validate_on_edit = override_config.validate_on_edit;
635    }
636    if override_config.formatter.is_some() {
637        base.formatter = override_config.formatter;
638    }
639    if override_config.checker.is_some() {
640        base.checker = override_config.checker;
641    }
642    if override_config.configure_warnings_delivery.is_some() {
643        base.configure_warnings_delivery = override_config.configure_warnings_delivery;
644    }
645    if override_config.hoist_builtin_tools.is_some() {
646        base.hoist_builtin_tools = override_config.hoist_builtin_tools;
647    }
648    if override_config.tool_surface.is_some() {
649        base.tool_surface = override_config.tool_surface;
650    }
651    if override_config.disabled_tools.is_some() {
652        base.disabled_tools = override_config.disabled_tools;
653    }
654    if override_config.restrict_to_project_root.is_some() {
655        base.restrict_to_project_root = override_config.restrict_to_project_root;
656    }
657    if override_config.search_index.is_some() {
658        base.search_index = override_config.search_index;
659    }
660    if override_config.semantic_search.is_some() {
661        base.semantic_search = override_config.semantic_search;
662    }
663    if override_config.callgraph_store.is_some() {
664        base.callgraph_store = override_config.callgraph_store;
665    }
666    if override_config.callgraph_chunk_size.is_some() {
667        base.callgraph_chunk_size = override_config.callgraph_chunk_size;
668    }
669    if override_config.inspect.is_some() {
670        base.inspect = override_config.inspect;
671    }
672    if override_config.backup.is_some() {
673        base.backup = override_config.backup;
674    }
675    if override_config.worktree.is_some() {
676        base.worktree = override_config.worktree;
677    }
678    if override_config.sandbox.is_some() {
679        base.sandbox = override_config.sandbox;
680    }
681    if override_config.bash.is_some() {
682        base.bash = override_config.bash;
683    }
684    if override_config.experimental.is_some() {
685        base.experimental = override_config.experimental;
686    }
687    if override_config.lsp.is_some() {
688        base.lsp = override_config.lsp;
689    }
690    if override_config.url_fetch_allow_private.is_some() {
691        base.url_fetch_allow_private = override_config.url_fetch_allow_private;
692    }
693    if override_config.semantic.is_some() {
694        base.semantic = override_config.semantic;
695    }
696    if override_config.auto_update.is_some() {
697        base.auto_update = override_config.auto_update;
698    }
699    if override_config.bridge.is_some() {
700        base.bridge = override_config.bridge;
701    }
702}
703
704fn merge_project_config(base: &mut RawAftConfig, project: RawAftConfig) {
705    // Project-safe shallow top-level fields.
706    if project.enabled.is_some() {
707        base.enabled = project.enabled;
708    }
709    if project.edit_mode.is_some() {
710        base.edit_mode = project.edit_mode;
711    }
712    if project.format_on_edit.is_some() {
713        base.format_on_edit = project.format_on_edit;
714    }
715    if project.validate_on_edit.is_some() {
716        base.validate_on_edit = project.validate_on_edit;
717    }
718    if project.configure_warnings_delivery.is_some() {
719        base.configure_warnings_delivery = project.configure_warnings_delivery;
720    }
721    if project.hoist_builtin_tools.is_some() {
722        base.hoist_builtin_tools = project.hoist_builtin_tools;
723    }
724    if project.tool_surface.is_some() {
725        base.tool_surface = project.tool_surface;
726    }
727    if project.search_index.is_some() {
728        base.search_index = project.search_index;
729    }
730    if project.semantic_search.is_some() {
731        base.semantic_search = project.semantic_search;
732    }
733    if project.callgraph_store.is_some() {
734        base.callgraph_store = project.callgraph_store;
735    }
736    if project.callgraph_chunk_size.is_some() {
737        base.callgraph_chunk_size = project.callgraph_chunk_size;
738    }
739
740    merge_formatter_map(&mut base.formatter, project.formatter);
741    merge_checker_map(&mut base.checker, project.checker);
742    merge_disabled_tools(&mut base.disabled_tools, project.disabled_tools);
743    base.semantic = merge_semantic_config(base.semantic.clone(), project.semantic);
744    base.lsp = merge_lsp_config(base.lsp.clone(), project.lsp);
745    base.experimental = merge_experimental_config(base.experimental.clone(), project.experimental);
746    base.bash = merge_bash_config(base.bash.clone(), project.bash);
747    base.inspect = merge_inspect_config(base.inspect.clone(), project.inspect);
748    base.worktree = merge_worktree_config(base.worktree.clone(), project.worktree);
749    base.sandbox = merge_project_sandbox(base.sandbox.clone(), project.sandbox);
750}
751
752fn merge_project_sandbox(
753    base: Option<RawSandbox>,
754    project: Option<RawSandbox>,
755) -> Option<RawSandbox> {
756    let Some(project) = project else {
757        return base;
758    };
759    let mut sandbox = base.unwrap_or_default();
760    if let Some(project_denies) = project.read_deny {
761        let denies = sandbox.read_deny.get_or_insert_with(Vec::new);
762        for path in project_denies {
763            if !denies.contains(&path) {
764                denies.push(path);
765            }
766        }
767    }
768    // A project may ENABLE the sandbox for itself (hardening is one-way): a
769    // repo can opt its own bash into kernel confinement, but a project-tier
770    // `enabled: false` can never switch off what the user turned on.
771    if project.enabled == Some(true) {
772        sandbox.enabled = Some(true);
773    }
774    (sandbox.enabled.is_some() || sandbox.write_allow.is_some() || sandbox.read_deny.is_some())
775        .then_some(sandbox)
776}
777
778fn merge_formatter_map(
779    base: &mut Option<HashMap<String, RawFormatter>>,
780    override_map: Option<HashMap<String, RawFormatter>>,
781) {
782    let Some(override_map) = override_map else {
783        return;
784    };
785    if override_map.is_empty() && base.as_ref().is_none_or(HashMap::is_empty) {
786        return;
787    }
788    let target = base.get_or_insert_with(HashMap::new);
789    target.extend(override_map);
790}
791
792fn merge_checker_map(
793    base: &mut Option<HashMap<String, RawChecker>>,
794    override_map: Option<HashMap<String, RawChecker>>,
795) {
796    let Some(override_map) = override_map else {
797        return;
798    };
799    if override_map.is_empty() && base.as_ref().is_none_or(HashMap::is_empty) {
800        return;
801    }
802    let target = base.get_or_insert_with(HashMap::new);
803    target.extend(override_map);
804}
805
806fn merge_disabled_tools(base: &mut Option<Vec<String>>, override_tools: Option<Vec<String>>) {
807    let Some(override_tools) = override_tools else {
808        return;
809    };
810    let mut merged = Vec::new();
811    let mut seen = HashSet::new();
812    for tool in base.iter().flatten() {
813        if seen.insert(tool.clone()) {
814            merged.push(tool.clone());
815        }
816    }
817    for tool in override_tools
818        .iter()
819        .filter(|tool| tool.as_str() != "aft_safety")
820    {
821        if seen.insert(tool.clone()) {
822            merged.push(tool.clone());
823        }
824    }
825    if !merged.is_empty() {
826        *base = Some(merged);
827    }
828}
829
830fn merge_semantic_config(
831    base: Option<RawSemantic>,
832    override_semantic: Option<RawSemantic>,
833) -> Option<RawSemantic> {
834    let mut semantic = base.unwrap_or(RawSemantic {
835        backend: None,
836        model: None,
837        base_url: None,
838        api_key_env: None,
839        timeout_ms: None,
840        query_timeout_ms: None,
841        max_batch_size: None,
842        max_files: None,
843    });
844
845    if let Some(project) = override_semantic {
846        if project.model.is_some() {
847            semantic.model = project.model;
848        }
849        if project.timeout_ms.is_some() {
850            semantic.timeout_ms = project.timeout_ms;
851        }
852        if project.max_batch_size.is_some() {
853            semantic.max_batch_size = project.max_batch_size;
854        }
855        if project.max_files.is_some() {
856            semantic.max_files = project.max_files;
857        }
858    }
859
860    (!semantic.is_empty()).then_some(semantic)
861}
862
863fn merge_lsp_config(base: Option<RawLsp>, override_lsp: Option<RawLsp>) -> Option<RawLsp> {
864    let mut lsp = base.unwrap_or(RawLsp {
865        servers: None,
866        disabled: None,
867        python: None,
868        diagnostics_on_edit: None,
869        auto_install: None,
870        grace_days: None,
871        versions: None,
872    });
873
874    if let Some(project) = override_lsp {
875        if project.python.is_some() {
876            lsp.python = project.python;
877        }
878        if project.diagnostics_on_edit.is_some() {
879            lsp.diagnostics_on_edit = project.diagnostics_on_edit;
880        }
881    }
882
883    (!lsp.is_empty()).then_some(lsp)
884}
885
886fn merge_experimental_config(
887    base: Option<RawExperimental>,
888    override_experimental: Option<RawExperimental>,
889) -> Option<RawExperimental> {
890    let Some(override_experimental) = override_experimental else {
891        return base;
892    };
893
894    let mut experimental = base.unwrap_or_default();
895    experimental.lsp_ty = override_experimental.lsp_ty.or(experimental.lsp_ty);
896    experimental.bash = merge_experimental_bash(experimental.bash, override_experimental.bash);
897
898    (!experimental.is_empty()).then_some(experimental)
899}
900
901fn merge_experimental_bash(
902    base: Option<RawExperimentalBash>,
903    override_bash: Option<RawExperimentalBash>,
904) -> Option<RawExperimentalBash> {
905    let Some(override_bash) = override_bash else {
906        return base;
907    };
908    let mut bash = base.unwrap_or_default();
909    bash.rewrite = override_bash.rewrite.or(bash.rewrite);
910    bash.compress = override_bash.compress.or(bash.compress);
911    bash.background = override_bash.background.or(bash.background);
912    bash.long_running_reminder_enabled = override_bash
913        .long_running_reminder_enabled
914        .or(bash.long_running_reminder_enabled);
915    bash.long_running_reminder_interval_ms = override_bash
916        .long_running_reminder_interval_ms
917        .or(bash.long_running_reminder_interval_ms);
918
919    bash.has_any_value().then_some(bash)
920}
921
922fn merge_bash_config(base: Option<RawBash>, override_bash: Option<RawBash>) -> Option<RawBash> {
923    match (base, override_bash) {
924        (None, None) => None,
925        (None, Some(override_bash)) => Some(override_bash),
926        (Some(base), None) => Some(base),
927        (Some(base), Some(override_bash)) => {
928            let base = expand_bash_for_merge(&base);
929            let override_features = expand_bash_for_merge(&override_bash);
930            Some(RawBash::Features(RawBashFeatures {
931                rewrite: override_features.rewrite.or(base.rewrite),
932                compress: override_features.compress.or(base.compress),
933                background: override_features.background.or(base.background),
934                host_fallback: override_features.host_fallback.or(base.host_fallback),
935                subagent_background: override_features
936                    .subagent_background
937                    .or(base.subagent_background),
938                long_running_reminder_enabled: override_features
939                    .long_running_reminder_enabled
940                    .or(base.long_running_reminder_enabled),
941                long_running_reminder_interval_ms: override_features
942                    .long_running_reminder_interval_ms
943                    .or(base.long_running_reminder_interval_ms),
944                foreground_wait_window_ms: override_features
945                    .foreground_wait_window_ms
946                    .or(base.foreground_wait_window_ms),
947            }))
948        }
949    }
950}
951
952fn expand_bash_for_merge(value: &RawBash) -> RawBashFeatures {
953    match value {
954        RawBash::Bool(enabled) => RawBashFeatures {
955            rewrite: Some(*enabled),
956            compress: Some(*enabled),
957            background: Some(*enabled),
958            host_fallback: None,
959            subagent_background: None,
960            long_running_reminder_enabled: None,
961            long_running_reminder_interval_ms: None,
962            foreground_wait_window_ms: None,
963        },
964        RawBash::Features(features) => features.clone(),
965    }
966}
967
968fn merge_inspect_config(
969    base: Option<RawInspect>,
970    override_inspect: Option<RawInspect>,
971) -> Option<RawInspect> {
972    let Some(override_inspect) = override_inspect else {
973        return base;
974    };
975
976    let mut inspect = base.unwrap_or_default();
977    inspect.enabled = override_inspect.enabled.or(inspect.enabled);
978    if let Some(project_timeout) = override_inspect.diagnostics_timeout_ms {
979        // A project may ask for more time, but it must not silently shrink another
980        // consumer's diagnostic completeness by reducing the user's effective wait.
981        inspect.diagnostics_timeout_ms = Some(
982            project_timeout.max(
983                inspect
984                    .diagnostics_timeout_ms
985                    .unwrap_or(DEFAULT_INSPECT_DIAGNOSTICS_TIMEOUT_MS),
986            ),
987        );
988    }
989    inspect.tier2_idle_minutes = override_inspect
990        .tier2_idle_minutes
991        .or(inspect.tier2_idle_minutes);
992    inspect.categories = override_inspect.categories.or(inspect.categories);
993    inspect.tier2_soft_deadline_ms = override_inspect
994        .tier2_soft_deadline_ms
995        .or(inspect.tier2_soft_deadline_ms);
996    inspect.max_drill_down_items = override_inspect
997        .max_drill_down_items
998        .or(inspect.max_drill_down_items);
999    inspect.duplicates = merge_inspect_duplicates(inspect.duplicates, override_inspect.duplicates);
1000
1001    (!inspect.is_empty()).then_some(inspect)
1002}
1003
1004fn merge_worktree_config(
1005    base: Option<RawWorktree>,
1006    override_worktree: Option<RawWorktree>,
1007) -> Option<RawWorktree> {
1008    let Some(override_worktree) = override_worktree else {
1009        return base;
1010    };
1011
1012    let mut worktree = base.unwrap_or_default();
1013    worktree.ram_overlay = override_worktree.ram_overlay.or(worktree.ram_overlay);
1014    (!worktree.is_empty()).then_some(worktree)
1015}
1016
1017fn merge_inspect_duplicates(
1018    base: Option<RawInspectDuplicates>,
1019    override_duplicates: Option<RawInspectDuplicates>,
1020) -> Option<RawInspectDuplicates> {
1021    let Some(override_duplicates) = override_duplicates else {
1022        return base;
1023    };
1024
1025    let mut duplicates = base.unwrap_or_default();
1026    duplicates.expected_mirrors = override_duplicates
1027        .expected_mirrors
1028        .or(duplicates.expected_mirrors);
1029
1030    (!duplicates.is_empty()).then_some(duplicates)
1031}
1032
1033fn record_project_drops(raw: &RawAftConfig, tier: &str, dropped: &mut Vec<DroppedKey>) {
1034    if raw.restrict_to_project_root.is_some() {
1035        push_drop(dropped, "restrict_to_project_root", tier, USER_ONLY_REASON);
1036    }
1037    if raw.url_fetch_allow_private.is_some() {
1038        push_drop(dropped, "url_fetch_allow_private", tier, USER_ONLY_REASON);
1039    }
1040    if raw.formatter_timeout_secs.is_some() {
1041        push_drop(dropped, "formatter_timeout_secs", tier, USER_ONLY_REASON);
1042    }
1043    if raw.type_checker_timeout_secs.is_some() {
1044        push_drop(dropped, "type_checker_timeout_secs", tier, USER_ONLY_REASON);
1045    }
1046    if raw.auto_update.is_some() {
1047        push_drop(dropped, "auto_update", tier, USER_ONLY_REASON);
1048    }
1049    if raw.bridge.is_some() {
1050        push_drop(dropped, "bridge", tier, USER_ONLY_REASON);
1051    }
1052    if raw.backup.is_some() {
1053        push_drop(dropped, "backup", tier, USER_ONLY_REASON);
1054    }
1055    if let Some(sandbox) = &raw.sandbox {
1056        // enabled:true is an accepted project-tier hardening opt-in (merged by
1057        // merge_project_sandbox); only the weakening direction is dropped.
1058        if sandbox.enabled == Some(false) {
1059            push_drop(dropped, "sandbox.enabled", tier, USER_ONLY_REASON);
1060        }
1061        if sandbox.write_allow.is_some() {
1062            push_drop(dropped, "sandbox.write_allow", tier, USER_ONLY_REASON);
1063        }
1064    }
1065    if raw
1066        .disabled_tools
1067        .as_ref()
1068        .is_some_and(|tools| tools.iter().any(|tool| tool == "aft_safety"))
1069    {
1070        push_drop(dropped, "disabled_tools.aft_safety", tier, USER_ONLY_REASON);
1071    }
1072
1073    if let Some(semantic) = &raw.semantic {
1074        if semantic.backend.is_some() {
1075            push_drop(dropped, "semantic.backend", tier, SEMANTIC_SECRET_REASON);
1076        }
1077        if semantic.base_url.is_some() {
1078            push_drop(dropped, "semantic.base_url", tier, SEMANTIC_SECRET_REASON);
1079        }
1080        if semantic.api_key_env.is_some() {
1081            push_drop(
1082                dropped,
1083                "semantic.api_key_env",
1084                tier,
1085                SEMANTIC_SECRET_REASON,
1086            );
1087        }
1088        if semantic.query_timeout_ms.is_some() {
1089            push_drop(dropped, "semantic.query_timeout_ms", tier, USER_ONLY_REASON);
1090        }
1091    }
1092
1093    if let Some(lsp) = &raw.lsp {
1094        if lsp.servers.is_some() {
1095            push_drop(dropped, "lsp.servers", tier, LSP_USER_ONLY_REASON);
1096        }
1097        if lsp.versions.is_some() {
1098            push_drop(dropped, "lsp.versions", tier, LSP_USER_ONLY_REASON);
1099        }
1100        if lsp.auto_install.is_some() {
1101            push_drop(dropped, "lsp.auto_install", tier, LSP_USER_ONLY_REASON);
1102        }
1103        if lsp.grace_days.is_some() {
1104            push_drop(dropped, "lsp.grace_days", tier, LSP_USER_ONLY_REASON);
1105        }
1106        if lsp.disabled.is_some() {
1107            push_drop(dropped, "lsp.disabled", tier, LSP_USER_ONLY_REASON);
1108        }
1109    }
1110}
1111
1112fn push_drop(dropped: &mut Vec<DroppedKey>, key: &str, tier: &str, reason: &str) {
1113    dropped.push(DroppedKey {
1114        key: key.to_string(),
1115        tier: tier.to_string(),
1116        reason: reason.to_string(),
1117    });
1118}
1119
1120/// Overlay the resolved core-domain fields from `raw` onto `config`. Scalar
1121/// fields are only written when present in the merged tiers (preserving the
1122/// caller's base for absent keys); semantic/inspect/lsp are core-domain and are
1123/// fully resolved from the tiers. Process-state fields on `config` are never
1124/// touched (they are not part of `RawAftConfig`).
1125fn apply_resolved_config(raw: &RawAftConfig, config: &mut Config) {
1126    config.hashline_enabled = matches!(raw.edit_mode, Some(RawEditMode::Hashline));
1127    if let Some(value) = raw.format_on_edit {
1128        config.format_on_edit = value;
1129    }
1130    if let Some(value) = raw.formatter_timeout_secs {
1131        config.formatter_timeout_secs = value;
1132    }
1133    if let Some(value) = raw.type_checker_timeout_secs {
1134        config.type_checker_timeout_secs = value;
1135    }
1136    if let Some(value) = raw.validate_on_edit {
1137        config.validate_on_edit = Some(value.as_str().to_string());
1138    }
1139    if let Some(formatter) = &raw.formatter {
1140        config.formatter = formatter
1141            .iter()
1142            .map(|(language, formatter)| (language.clone(), formatter.as_str().to_string()))
1143            .collect();
1144    }
1145    if let Some(checker) = &raw.checker {
1146        config.checker = checker
1147            .iter()
1148            .map(|(language, checker)| (language.clone(), checker.as_str().to_string()))
1149            .collect();
1150    }
1151    if let Some(value) = raw.restrict_to_project_root {
1152        config.restrict_to_project_root = value;
1153    }
1154    if let Some(value) = raw.search_index {
1155        config.search_index = value;
1156    }
1157    if let Some(value) = raw.semantic_search {
1158        config.semantic_search = value;
1159    }
1160    if let Some(value) = raw.callgraph_store {
1161        config.callgraph_store = value;
1162    }
1163    if let Some(value) = raw.callgraph_chunk_size {
1164        config.callgraph_chunk_size = value;
1165    }
1166    if let Some(value) = raw.url_fetch_allow_private {
1167        config.url_fetch_allow_private = value;
1168    }
1169    config.semantic = resolve_semantic_config(raw.semantic.as_ref());
1170    config.inspect = resolve_inspect_config(raw.inspect.as_ref());
1171    config.backup = resolve_backup_config(raw.backup.as_ref());
1172    config.worktree = resolve_worktree_config(raw.worktree.as_ref());
1173    config.sandbox = resolve_sandbox_config(raw.sandbox.as_ref());
1174    resolve_lsp_config(raw, config);
1175    resolve_bash_fields(raw, config);
1176}
1177
1178fn resolve_semantic_config(raw: Option<&RawSemantic>) -> SemanticBackendConfig {
1179    let mut semantic = SemanticBackendConfig::default();
1180    let Some(raw) = raw else {
1181        return semantic;
1182    };
1183
1184    if let Some(value) = raw.backend {
1185        semantic.backend = value;
1186    }
1187    if let Some(value) = &raw.model {
1188        semantic.model = value.clone();
1189    }
1190    if let Some(value) = &raw.base_url {
1191        semantic.base_url = Some(value.clone());
1192    }
1193    if let Some(value) = &raw.api_key_env {
1194        semantic.api_key_env = Some(value.clone());
1195    }
1196    if let Some(value) = raw.timeout_ms {
1197        semantic.timeout_ms = value.min(MAX_SEMANTIC_TIMEOUT_MS);
1198    }
1199    if let Some(value) = raw.query_timeout_ms {
1200        semantic.query_timeout_ms =
1201            value.clamp(MIN_SEMANTIC_QUERY_TIMEOUT_MS, MAX_SEMANTIC_QUERY_TIMEOUT_MS);
1202    }
1203    if let Some(value) = raw.max_batch_size {
1204        semantic.max_batch_size = value.min(MAX_SEMANTIC_BATCH_SIZE);
1205    }
1206    if let Some(value) = raw.max_files {
1207        semantic.max_files = value;
1208    }
1209
1210    semantic
1211}
1212
1213fn resolve_inspect_config(raw: Option<&RawInspect>) -> InspectConfig {
1214    let mut inspect = InspectConfig::default();
1215    let Some(raw) = raw else {
1216        return inspect;
1217    };
1218    if let Some(enabled) = raw.enabled {
1219        inspect.enabled = enabled;
1220    }
1221    if let Some(value) = raw.diagnostics_timeout_ms {
1222        inspect.diagnostics_timeout_ms = value.clamp(
1223            MIN_INSPECT_DIAGNOSTICS_TIMEOUT_MS,
1224            MAX_INSPECT_DIAGNOSTICS_TIMEOUT_MS,
1225        );
1226    }
1227    if let Some(expected_mirrors) = raw
1228        .duplicates
1229        .as_ref()
1230        .and_then(|duplicates| duplicates.expected_mirrors.clone())
1231    {
1232        inspect.duplicates.expected_mirrors = expected_mirrors;
1233    }
1234    inspect
1235}
1236
1237fn resolve_backup_config(raw: Option<&RawBackup>) -> BackupConfig {
1238    let mut backup = BackupConfig::default();
1239    if let Some(raw) = raw {
1240        if raw.enabled.is_some() {
1241            backup.enabled = raw.enabled;
1242        }
1243        if raw.max_depth.is_some() {
1244            backup.max_depth = raw.max_depth;
1245        }
1246        if raw.max_file_size.is_some() {
1247            backup.max_file_size = raw.max_file_size;
1248        }
1249    }
1250    backup
1251}
1252
1253fn resolve_worktree_config(raw: Option<&RawWorktree>) -> WorktreeConfig {
1254    let mut worktree = WorktreeConfig::default();
1255    if let Some(value) = raw.and_then(|raw| raw.ram_overlay) {
1256        worktree.ram_overlay = value;
1257    }
1258    worktree
1259}
1260
1261fn resolve_sandbox_config(raw: Option<&RawSandbox>) -> SandboxConfig {
1262    let Some(raw) = raw else {
1263        return SandboxConfig::default();
1264    };
1265    SandboxConfig {
1266        enabled: raw.enabled.unwrap_or(false),
1267        write_allow: raw.write_allow.clone().unwrap_or_default(),
1268        read_deny: raw.read_deny.clone().unwrap_or_default(),
1269    }
1270}
1271
1272fn resolve_lsp_config(raw: &RawAftConfig, config: &mut Config) {
1273    let lsp = raw.lsp.as_ref();
1274    let mut disabled: HashSet<String> = lsp
1275        .and_then(|lsp| lsp.disabled.as_ref())
1276        .into_iter()
1277        .flatten()
1278        .map(|value| value.to_ascii_lowercase())
1279        .collect();
1280    let mut experimental_ty = raw
1281        .experimental
1282        .as_ref()
1283        .and_then(|experimental| experimental.lsp_ty);
1284
1285    match lsp.and_then(|lsp| lsp.python).unwrap_or(RawPythonLsp::Auto) {
1286        RawPythonLsp::Ty => {
1287            experimental_ty = Some(true);
1288            disabled.insert("python".to_string());
1289        }
1290        RawPythonLsp::Pyright => {
1291            experimental_ty = Some(false);
1292            disabled.insert("ty".to_string());
1293        }
1294        RawPythonLsp::Auto => {}
1295    }
1296
1297    if let Some(value) = experimental_ty {
1298        config.experimental_lsp_ty = value;
1299    }
1300
1301    if let Some(value) = lsp.and_then(|lsp| lsp.diagnostics_on_edit) {
1302        config.diagnostics_on_edit = value;
1303    }
1304
1305    if let Some(servers) = lsp.and_then(|lsp| lsp.servers.as_ref()) {
1306        config.lsp_servers = servers
1307            .iter()
1308            .map(|(id, server)| UserServerDef {
1309                id: id.clone(),
1310                extensions: server
1311                    .extensions
1312                    .clone()
1313                    .unwrap_or_default()
1314                    .into_iter()
1315                    .map(|extension| extension.trim_start_matches('.').to_string())
1316                    .collect(),
1317                binary: server.binary.clone().unwrap_or_default(),
1318                args: server.args.clone().unwrap_or_default(),
1319                root_markers: server
1320                    .root_markers
1321                    .clone()
1322                    .unwrap_or_else(|| vec![".git".to_string()]),
1323                env: server.env.clone().unwrap_or_default(),
1324                initialization_options: server.initialization_options.clone(),
1325                disabled: server.disabled.unwrap_or(false),
1326            })
1327            .collect();
1328    }
1329
1330    if !disabled.is_empty() {
1331        config.disabled_lsp = disabled;
1332    }
1333}
1334
1335#[derive(Debug, Clone, PartialEq, Eq)]
1336struct ResolvedBashConfig {
1337    enabled: bool,
1338    rewrite: bool,
1339    compress: bool,
1340    background: bool,
1341    host_fallback: bool,
1342    subagent_background: bool,
1343    long_running_reminder_enabled: Option<bool>,
1344    long_running_reminder_interval_ms: Option<u64>,
1345    foreground_wait_window_ms: u64,
1346}
1347
1348fn resolve_bash_fields(raw: &RawAftConfig, config: &mut Config) {
1349    let bash = resolve_bash_config(raw);
1350    // The plugins use `enabled` and `subagent_background` when registering bash
1351    // capabilities. Rust resolves them only to accept and merge the same config;
1352    // they do not control engine behavior.
1353    let _registration_only = (bash.enabled, bash.subagent_background);
1354    config.bash.host_fallback = bash.host_fallback;
1355    config.experimental_bash_rewrite = bash.rewrite;
1356    config.experimental_bash_compress = bash.compress;
1357    config.experimental_bash_background = bash.background;
1358    config.foreground_wait_window_ms = bash.foreground_wait_window_ms;
1359    if let Some(value) = bash.long_running_reminder_enabled {
1360        config.bash_long_running_reminder_enabled = value;
1361    }
1362    if let Some(value) = bash.long_running_reminder_interval_ms {
1363        config.bash_long_running_reminder_interval_ms = value;
1364    }
1365}
1366
1367fn resolve_bash_config(raw: &RawAftConfig) -> ResolvedBashConfig {
1368    let top = raw.bash.as_ref();
1369    let legacy = raw
1370        .experimental
1371        .as_ref()
1372        .and_then(|experimental| experimental.bash.as_ref());
1373    let surface = raw.tool_surface.unwrap_or(RawToolSurface::Recommended);
1374    let surface_default_enabled = surface != RawToolSurface::Minimal;
1375
1376    let top_features = match top {
1377        Some(RawBash::Features(features)) => Some(features),
1378        _ => None,
1379    };
1380    let reminder_enabled = top_features
1381        .and_then(|features| features.long_running_reminder_enabled)
1382        .or_else(|| legacy.and_then(|legacy| legacy.long_running_reminder_enabled));
1383    let reminder_interval = top_features
1384        .and_then(|features| features.long_running_reminder_interval_ms)
1385        .or_else(|| legacy.and_then(|legacy| legacy.long_running_reminder_interval_ms));
1386    let top_host_fallback = top_features
1387        .and_then(|features| features.host_fallback)
1388        .unwrap_or(false);
1389    let top_subagent_background = top_features
1390        .and_then(|features| features.subagent_background)
1391        .unwrap_or(false);
1392    let raw_foreground_wait = top_features.and_then(|features| features.foreground_wait_window_ms);
1393    let foreground_wait_window_ms = raw_foreground_wait
1394        .unwrap_or(FOREGROUND_WAIT_WINDOW_DEFAULT_MS)
1395        .max(FOREGROUND_WAIT_WINDOW_MIN_MS);
1396
1397    let base = ResolvedBashConfig {
1398        enabled: false,
1399        rewrite: false,
1400        compress: false,
1401        background: false,
1402        host_fallback: false,
1403        subagent_background: false,
1404        long_running_reminder_enabled: reminder_enabled,
1405        long_running_reminder_interval_ms: reminder_interval,
1406        foreground_wait_window_ms,
1407    };
1408
1409    match top {
1410        Some(RawBash::Bool(false)) => base,
1411        Some(RawBash::Bool(true)) => ResolvedBashConfig {
1412            enabled: true,
1413            rewrite: true,
1414            compress: true,
1415            background: true,
1416            ..base
1417        },
1418        Some(RawBash::Features(features)) => ResolvedBashConfig {
1419            enabled: true,
1420            rewrite: features.rewrite.unwrap_or(true),
1421            compress: features.compress.unwrap_or(true),
1422            background: features.background.unwrap_or(true),
1423            host_fallback: top_host_fallback,
1424            subagent_background: top_subagent_background,
1425            ..base
1426        },
1427        None => {
1428            if legacy.is_some_and(RawExperimentalBash::has_legacy_feature_flag) {
1429                let legacy = legacy.cloned().unwrap_or_default();
1430                let rewrite = legacy.rewrite == Some(true);
1431                let compress = legacy.compress == Some(true);
1432                let background = legacy.background == Some(true);
1433                return ResolvedBashConfig {
1434                    enabled: rewrite || compress || background,
1435                    rewrite,
1436                    compress,
1437                    background,
1438                    ..base
1439                };
1440            }
1441
1442            ResolvedBashConfig {
1443                enabled: surface_default_enabled,
1444                rewrite: surface_default_enabled,
1445                compress: surface_default_enabled,
1446                background: surface_default_enabled,
1447                ..base
1448            }
1449        }
1450    }
1451}
1452
1453fn deserialize_opt_trimmed_non_empty_string<'de, D>(
1454    deserializer: D,
1455) -> Result<Option<String>, D::Error>
1456where
1457    D: Deserializer<'de>,
1458{
1459    let value = Option::<String>::deserialize(deserializer)?;
1460    value
1461        .map(|value| {
1462            let trimmed = value.trim().to_string();
1463            if trimmed.is_empty() {
1464                Err(de::Error::custom("must be a non-empty string"))
1465            } else {
1466                Ok(trimmed)
1467            }
1468        })
1469        .transpose()
1470}
1471
1472fn deserialize_opt_trimmed_non_empty_string_vec<'de, D>(
1473    deserializer: D,
1474) -> Result<Option<Vec<String>>, D::Error>
1475where
1476    D: Deserializer<'de>,
1477{
1478    let value = Option::<Vec<String>>::deserialize(deserializer)?;
1479    value
1480        .map(|values| {
1481            values
1482                .into_iter()
1483                .map(|value| {
1484                    let trimmed = value.trim().to_string();
1485                    if trimmed.is_empty() {
1486                        Err(de::Error::custom("array entries must be non-empty strings"))
1487                    } else {
1488                        Ok(trimmed)
1489                    }
1490                })
1491                .collect()
1492        })
1493        .transpose()
1494}
1495
1496fn deserialize_opt_lsp_extensions<'de, D>(deserializer: D) -> Result<Option<Vec<String>>, D::Error>
1497where
1498    D: Deserializer<'de>,
1499{
1500    let value = Option::<Vec<String>>::deserialize(deserializer)?;
1501    value
1502        .map(|values| {
1503            if values.is_empty() {
1504                return Err(de::Error::custom(
1505                    "extensions must contain at least one entry",
1506                ));
1507            }
1508            values
1509                .into_iter()
1510                .map(|value| {
1511                    let trimmed = value.trim().to_string();
1512                    if trimmed.is_empty() || trimmed.trim_start_matches('.').is_empty() {
1513                        Err(de::Error::custom(
1514                            "extension must include characters other than leading dots",
1515                        ))
1516                    } else {
1517                        Ok(trimmed)
1518                    }
1519                })
1520                .collect()
1521        })
1522        .transpose()
1523}
1524
1525fn deserialize_opt_lsp_servers<'de, D>(
1526    deserializer: D,
1527) -> Result<Option<BTreeMap<String, RawLspServerEntry>>, D::Error>
1528where
1529    D: Deserializer<'de>,
1530{
1531    let value = Option::<BTreeMap<String, RawLspServerEntry>>::deserialize(deserializer)?;
1532    value
1533        .map(|entries| {
1534            entries
1535                .into_iter()
1536                .map(|(key, value)| {
1537                    let trimmed = key.trim().to_string();
1538                    if trimmed.is_empty() {
1539                        Err(de::Error::custom(
1540                            "lsp.servers keys must be non-empty strings",
1541                        ))
1542                    } else {
1543                        Ok((trimmed, value))
1544                    }
1545                })
1546                .collect()
1547        })
1548        .transpose()
1549}
1550
1551fn deserialize_opt_versions_map<'de, D>(
1552    deserializer: D,
1553) -> Result<Option<HashMap<String, String>>, D::Error>
1554where
1555    D: Deserializer<'de>,
1556{
1557    let value = Option::<HashMap<String, String>>::deserialize(deserializer)?;
1558    value
1559        .map(|entries| {
1560            entries
1561                .into_iter()
1562                .map(|(key, value)| {
1563                    let trimmed_key = key.trim().to_string();
1564                    let trimmed_value = value.trim().to_string();
1565                    if trimmed_key.is_empty() || trimmed_value.is_empty() {
1566                        Err(de::Error::custom(
1567                            "lsp.versions keys and values must be non-empty strings",
1568                        ))
1569                    } else {
1570                        Ok((trimmed_key, trimmed_value))
1571                    }
1572                })
1573                .collect()
1574        })
1575        .transpose()
1576}
1577
1578fn deserialize_opt_usize<'de, D>(deserializer: D) -> Result<Option<usize>, D::Error>
1579where
1580    D: Deserializer<'de>,
1581{
1582    let value = Option::<u64>::deserialize(deserializer)?;
1583    value
1584        .map(|value| usize::try_from(value).map_err(|_| de::Error::custom("value is too large")))
1585        .transpose()
1586}
1587
1588fn deserialize_opt_positive_u64<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
1589where
1590    D: Deserializer<'de>,
1591{
1592    let value = Option::<u64>::deserialize(deserializer)?;
1593    match value {
1594        Some(0) => Err(de::Error::custom("must be a positive integer")),
1595        other => Ok(other),
1596    }
1597}
1598
1599fn deserialize_opt_positive_usize<'de, D>(deserializer: D) -> Result<Option<usize>, D::Error>
1600where
1601    D: Deserializer<'de>,
1602{
1603    let value = deserialize_opt_positive_u64(deserializer)?;
1604    value
1605        .map(|value| usize::try_from(value).map_err(|_| de::Error::custom("value is too large")))
1606        .transpose()
1607}
1608
1609fn deserialize_opt_timeout_secs<'de, D>(deserializer: D) -> Result<Option<u32>, D::Error>
1610where
1611    D: Deserializer<'de>,
1612{
1613    let value = Option::<u64>::deserialize(deserializer)?;
1614    match value {
1615        Some(value) if !(1..=600).contains(&value) => {
1616            Err(de::Error::custom("timeout must be in 1..=600 seconds"))
1617        }
1618        Some(value) => u32::try_from(value)
1619            .map(Some)
1620            .map_err(|_| de::Error::custom("timeout is too large")),
1621        None => Ok(None),
1622    }
1623}
1624
1625fn deserialize_opt_bridge_request_timeout_ms<'de, D>(
1626    deserializer: D,
1627) -> Result<Option<u64>, D::Error>
1628where
1629    D: Deserializer<'de>,
1630{
1631    let value = Option::<u64>::deserialize(deserializer)?;
1632    match value {
1633        Some(value) if value < 1_000 => Err(de::Error::custom(
1634            "bridge.request_timeout_ms must be at least 1000",
1635        )),
1636        other => Ok(other),
1637    }
1638}
1639
1640fn deserialize_opt_nonnegative_f64<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
1641where
1642    D: Deserializer<'de>,
1643{
1644    let value = Option::<f64>::deserialize(deserializer)?;
1645    match value {
1646        Some(value) if value < 0.0 => Err(de::Error::custom("must be non-negative")),
1647        other => Ok(other),
1648    }
1649}
1650
1651fn deserialize_opt_drill_down_items<'de, D>(deserializer: D) -> Result<Option<usize>, D::Error>
1652where
1653    D: Deserializer<'de>,
1654{
1655    let value = Option::<u64>::deserialize(deserializer)?;
1656    match value {
1657        Some(value) if value == 0 || value > 100 => {
1658            Err(de::Error::custom("max_drill_down_items must be in 1..=100"))
1659        }
1660        Some(value) => usize::try_from(value)
1661            .map(Some)
1662            .map_err(|_| de::Error::custom("max_drill_down_items is too large")),
1663        None => Ok(None),
1664    }
1665}
1666
1667#[cfg(test)]
1668mod tests {
1669    use super::*;
1670
1671    fn tier(tier: &str, doc: &str) -> ConfigTier {
1672        ConfigTier {
1673            tier: tier.to_string(),
1674            source: format!("/tmp/{tier}/aft.jsonc"),
1675            doc: doc.to_string(),
1676        }
1677    }
1678
1679    fn drop_keys(result: &ResolveResult) -> Vec<String> {
1680        result
1681            .dropped
1682            .iter()
1683            .map(|dropped| dropped.key.clone())
1684            .collect()
1685    }
1686
1687    /// Security invariant (Oracle drift decision): nested objects are non-strict
1688    /// (match TS z.object — unknown nested keys are stripped, object survives),
1689    /// but the TOP-LEVEL RawAftConfig stays strict. A privileged process-state
1690    /// field is top-level, so a project tier trying to smuggle one still hits the
1691    /// strict top-level → that tier fails full parse, and partial-parse drops the
1692    /// unknown key. It can NEVER reach Config.
1693    #[test]
1694    fn nested_unknown_keys_are_stripped_but_top_level_privileged_keys_cannot_smuggle() {
1695        // Nested unknown key: stripped, object survives — parity with TS (golden
1696        // `bash_unknown_nested_key`). `bash: { unknown_key }` resolves like
1697        // `bash: {}` → object form → bash ENABLED (object presence beats the
1698        // minimal surface default). The point: the unknown key did not fail the
1699        // parse — the object survived and resolved.
1700        let nested = resolve_config(&[tier(
1701            "user",
1702            r#"{ "tool_surface": "minimal", "bash": { "unknown_key": true } }"#,
1703        )]);
1704        assert!(nested.config.experimental_bash_rewrite);
1705        assert!(nested.config.experimental_bash_compress);
1706        assert!(nested.config.experimental_bash_background);
1707
1708        // Top-level privileged process-state field (storage_dir) from a PROJECT
1709        // tier: not in RawAftConfig → full parse fails → partial-parse drops it.
1710        // It must never appear in Config (Config keeps its default storage_dir).
1711        let smuggle = resolve_config(&[
1712            tier("user", r#"{ "search_index": true }"#),
1713            tier(
1714                "project",
1715                r#"{ "storage_dir": "/tmp/evil", "bash_permissions": true, "search_index": false }"#,
1716            ),
1717        ]);
1718        // The valid project key (search_index) still applies via partial-parse...
1719        assert!(!smuggle.config.search_index);
1720        // ...but the smuggled process-state fields never reach Config.
1721        assert!(smuggle.config.storage_dir.is_none());
1722        assert!(!smuggle.config.bash_permissions);
1723    }
1724
1725    #[test]
1726    fn config_resolve_empty_tiers_applies_bash_surface_default() {
1727        // No config file ⇒ empty config object, which still flows through the
1728        // resolver and picks up the bash surface default (recommended ⇒ on),
1729        // matching the TS pipeline (golden fixture `empty`). NOT Config::default()
1730        // — that would leave bash off, diverging from TS.
1731        let result = resolve_config(&[]);
1732        let default_config = Config::default();
1733
1734        assert!(result.dropped.is_empty());
1735        // Non-bash fields stay at runtime default.
1736        assert_eq!(result.config.format_on_edit, default_config.format_on_edit);
1737        assert_eq!(result.config.search_index, default_config.search_index);
1738        assert_eq!(
1739            result.config.semantic_search,
1740            default_config.semantic_search
1741        );
1742        assert_eq!(result.config.semantic, default_config.semantic);
1743        assert_eq!(
1744            result.config.inspect.enabled,
1745            default_config.inspect.enabled
1746        );
1747        assert_eq!(result.config.lsp_servers.len(), 0);
1748        // Bash surface default: recommended ⇒ rewrite/compress/background all on.
1749        assert!(result.config.experimental_bash_rewrite);
1750        assert!(result.config.experimental_bash_compress);
1751        assert!(result.config.experimental_bash_background);
1752    }
1753
1754    #[test]
1755    fn config_resolve_user_only_config_applies_fields() {
1756        let result = resolve_config(&[tier(
1757            "user",
1758            r#"{
1759              "$schema": "https://example.test/aft.schema.json",
1760              "format_on_edit": false,
1761              "formatter_timeout_secs": 42,
1762              "type_checker_timeout_secs": 43,
1763              "validate_on_edit": "full",
1764              "formatter": { "rust": "rustfmt", "typescript": "prettier" },
1765              "checker": { "rust": "cargo", "typescript": "tsc" },
1766              "restrict_to_project_root": true,
1767              "search_index": true,
1768              "semantic_search": true,
1769              "callgraph_store": false,
1770              "callgraph_chunk_size": 17,
1771              "url_fetch_allow_private": true,
1772              "semantic": {
1773                "backend": "openai_compatible",
1774                "model": "  user-model  ",
1775                "base_url": "https://semantic.example.test",
1776                "api_key_env": "AFT_API_KEY",
1777                "timeout_ms": 12345,
1778                "query_timeout_ms": 2345,
1779                "max_batch_size": 12,
1780                "max_files": 3456
1781              },
1782              "inspect": { "enabled": false, "diagnostics_timeout_ms": 15000 },
1783              "experimental": { "lsp_ty": true },
1784              "lsp": {
1785                "servers": {
1786                  "rust": { "extensions": [".rs"], "binary": "rust-analyzer" }
1787                },
1788                "disabled": ["Python"],
1789                "python": "pyright"
1790              },
1791              "bash": { "rewrite": false, "compress": true, "background": false,
1792                        "long_running_reminder_enabled": false,
1793                        "long_running_reminder_interval_ms": 123000 }
1794            }"#,
1795        )]);
1796
1797        assert!(result.dropped.is_empty());
1798        assert!(!result.config.format_on_edit);
1799        assert_eq!(result.config.formatter_timeout_secs, 42);
1800        assert_eq!(result.config.type_checker_timeout_secs, 43);
1801        assert_eq!(result.config.validate_on_edit.as_deref(), Some("full"));
1802        assert_eq!(
1803            result.config.formatter.get("rust").map(String::as_str),
1804            Some("rustfmt")
1805        );
1806        assert_eq!(
1807            result.config.checker.get("typescript").map(String::as_str),
1808            Some("tsc")
1809        );
1810        assert!(result.config.restrict_to_project_root);
1811        assert!(result.config.search_index);
1812        assert!(result.config.semantic_search);
1813        assert!(!result.config.callgraph_store);
1814        assert_eq!(result.config.callgraph_chunk_size, 17);
1815        assert!(result.config.url_fetch_allow_private);
1816        assert_eq!(
1817            result.config.semantic.backend,
1818            SemanticBackend::OpenAiCompatible
1819        );
1820        assert_eq!(result.config.semantic.model, "user-model");
1821        assert_eq!(
1822            result.config.semantic.base_url.as_deref(),
1823            Some("https://semantic.example.test")
1824        );
1825        assert_eq!(
1826            result.config.semantic.api_key_env.as_deref(),
1827            Some("AFT_API_KEY")
1828        );
1829        assert_eq!(result.config.semantic.timeout_ms, 12345);
1830        assert_eq!(result.config.semantic.query_timeout_ms, 2345);
1831        assert_eq!(result.config.semantic.max_batch_size, 12);
1832        assert_eq!(result.config.semantic.max_files, 3456);
1833        assert!(!result.config.inspect.enabled);
1834        assert_eq!(result.config.inspect.diagnostics_timeout_ms, 15_000);
1835        assert!(!result.config.experimental_lsp_ty);
1836        assert!(result.config.disabled_lsp.contains("ty"));
1837        assert_eq!(result.config.lsp_servers.len(), 1);
1838        assert_eq!(result.config.lsp_servers[0].id, "rust");
1839        assert_eq!(
1840            result.config.lsp_servers[0].extensions,
1841            vec!["rs".to_string()]
1842        );
1843        assert_eq!(result.config.lsp_servers[0].binary, "rust-analyzer");
1844        assert_eq!(result.config.lsp_servers[0].args, Vec::<String>::new());
1845        assert_eq!(
1846            result.config.lsp_servers[0].root_markers,
1847            vec![".git".to_string()]
1848        );
1849        assert!(!result.config.experimental_bash_rewrite);
1850        assert!(result.config.experimental_bash_compress);
1851        assert!(!result.config.experimental_bash_background);
1852        assert!(!result.config.bash_long_running_reminder_enabled);
1853        assert_eq!(result.config.bash_long_running_reminder_interval_ms, 123000);
1854    }
1855
1856    #[test]
1857    fn edit_mode_resolves_at_user_and_project_tiers_with_project_precedence() {
1858        let hashline = resolve_config(&[
1859            tier("user", r#"{"edit_mode":"default"}"#),
1860            tier("project", r#"{"edit_mode":"hashline"}"#),
1861        ]);
1862        assert!(hashline.config.hashline_enabled);
1863        assert!(hashline.dropped.is_empty());
1864
1865        let default = resolve_config(&[
1866            tier("user", r#"{"edit_mode":"hashline"}"#),
1867            tier("project", r#"{"edit_mode":"default"}"#),
1868        ]);
1869        assert!(!default.config.hashline_enabled);
1870        assert!(default.dropped.is_empty());
1871    }
1872
1873    #[test]
1874    fn unknown_edit_mode_warns_and_falls_back_without_dropping_valid_keys() {
1875        let result = resolve_config(&[tier(
1876            "project",
1877            r#"{"edit_mode":"future","format_on_edit":true}"#,
1878        )]);
1879
1880        assert!(!result.config.hashline_enabled);
1881        assert!(result.config.format_on_edit);
1882        assert_eq!(result.warnings.len(), 1);
1883        assert_eq!(result.warnings[0].code, "invalid_edit_mode");
1884        assert_eq!(result.warnings[0].key, "edit_mode");
1885        assert_eq!(result.warnings[0].tier, "project");
1886        assert_eq!(result.warnings[0].value, "future");
1887    }
1888
1889    #[test]
1890    fn semantic_query_timeout_clamps_to_interactive_budget_range() {
1891        let below_min =
1892            resolve_config(&[tier("user", r#"{ "semantic": { "query_timeout_ms": 1 } }"#)]);
1893        assert_eq!(
1894            below_min.config.semantic.query_timeout_ms,
1895            MIN_SEMANTIC_QUERY_TIMEOUT_MS
1896        );
1897
1898        let above_max = resolve_config(&[tier(
1899            "user",
1900            r#"{ "semantic": { "query_timeout_ms": 50000 } }"#,
1901        )]);
1902        assert_eq!(
1903            above_max.config.semantic.query_timeout_ms,
1904            MAX_SEMANTIC_QUERY_TIMEOUT_MS
1905        );
1906    }
1907
1908    #[test]
1909    fn inspect_diagnostics_timeout_clamps_to_blocking_phase_range() {
1910        let below_min = resolve_config(&[tier(
1911            "user",
1912            r#"{ "inspect": { "diagnostics_timeout_ms": 1 } }"#,
1913        )]);
1914        assert_eq!(
1915            below_min.config.inspect.diagnostics_timeout_ms,
1916            MIN_INSPECT_DIAGNOSTICS_TIMEOUT_MS
1917        );
1918
1919        let above_max = resolve_config(&[tier(
1920            "user",
1921            r#"{ "inspect": { "diagnostics_timeout_ms": 700000 } }"#,
1922        )]);
1923        assert_eq!(
1924            above_max.config.inspect.diagnostics_timeout_ms,
1925            MAX_INSPECT_DIAGNOSTICS_TIMEOUT_MS
1926        );
1927    }
1928
1929    #[test]
1930    fn project_inspect_diagnostics_timeout_can_raise_but_never_lower_user_value() {
1931        let lower = resolve_config(&[
1932            tier(
1933                "user",
1934                r#"{ "inspect": { "diagnostics_timeout_ms": 180000 } }"#,
1935            ),
1936            tier(
1937                "project",
1938                r#"{ "inspect": { "diagnostics_timeout_ms": 90000 } }"#,
1939            ),
1940        ]);
1941        assert_eq!(lower.config.inspect.diagnostics_timeout_ms, 180_000);
1942
1943        let higher = resolve_config(&[
1944            tier(
1945                "user",
1946                r#"{ "inspect": { "diagnostics_timeout_ms": 180000 } }"#,
1947            ),
1948            tier(
1949                "project",
1950                r#"{ "inspect": { "diagnostics_timeout_ms": 240000 } }"#,
1951            ),
1952        ]);
1953        assert_eq!(higher.config.inspect.diagnostics_timeout_ms, 240_000);
1954    }
1955
1956    #[test]
1957    fn config_resolve_project_allowed_search_index_wins() {
1958        let result = resolve_config(&[
1959            tier("user", r#"{ "search_index": false }"#),
1960            tier("project", r#"{ "search_index": true }"#),
1961        ]);
1962
1963        assert!(result.config.search_index);
1964        assert!(result.dropped.is_empty());
1965    }
1966
1967    #[test]
1968    fn worktree_ram_overlay_resolves_at_user_and_project_tiers() {
1969        assert!(!resolve_config(&[]).config.worktree.ram_overlay);
1970
1971        let user = resolve_config(&[tier("user", r#"{ "worktree": { "ram_overlay": true } }"#)]);
1972        assert!(user.config.worktree.ram_overlay);
1973        assert!(user.dropped.is_empty());
1974
1975        let project = resolve_config(&[
1976            tier("user", r#"{ "worktree": { "ram_overlay": false } }"#),
1977            tier("project", r#"{ "worktree": { "ram_overlay": true } }"#),
1978        ]);
1979        assert!(project.config.worktree.ram_overlay);
1980        assert!(project.dropped.is_empty());
1981    }
1982
1983    #[test]
1984    fn project_sandbox_can_add_read_denies_but_not_enable_or_add_writes() {
1985        let result = resolve_config(&[
1986            tier(
1987                "user",
1988                r#"{
1989                  "sandbox": {
1990                    "enabled": true,
1991                    "write_allow": ["/user/write"],
1992                    "read_deny": ["/user/secret"]
1993                  }
1994                }"#,
1995            ),
1996            tier(
1997                "project",
1998                r#"{
1999                  "sandbox": {
2000                    "enabled": false,
2001                    "write_allow": ["/project/write"],
2002                    "read_deny": ["/project/secret", "/user/secret"]
2003                  }
2004                }"#,
2005            ),
2006        ]);
2007
2008        assert!(result.config.sandbox.enabled);
2009        assert_eq!(
2010            result.config.sandbox.write_allow,
2011            vec![PathBuf::from("/user/write")]
2012        );
2013        assert_eq!(
2014            result.config.sandbox.read_deny,
2015            vec![
2016                PathBuf::from("/user/secret"),
2017                PathBuf::from("/project/secret")
2018            ]
2019        );
2020        assert_eq!(
2021            drop_keys(&result),
2022            vec![
2023                "sandbox.enabled".to_string(),
2024                "sandbox.write_allow".to_string()
2025            ]
2026        );
2027    }
2028
2029    #[test]
2030    fn project_can_enable_sandbox_but_never_disable_it() {
2031        // Hardening is one-way: a repo may opt itself into kernel confinement.
2032        let opt_in = resolve_config(&[
2033            tier("user", r#"{}"#),
2034            tier("project", r#"{ "sandbox": { "enabled": true } }"#),
2035        ]);
2036        assert!(opt_in.config.sandbox.enabled);
2037        assert!(
2038            !drop_keys(&opt_in).contains(&"sandbox.enabled".to_string()),
2039            "project enabled:true is an accepted opt-in, not a dropped key"
2040        );
2041
2042        // The weakening direction stays user-only: project false cannot win.
2043        let opt_out = resolve_config(&[
2044            tier("user", r#"{ "sandbox": { "enabled": true } }"#),
2045            tier("project", r#"{ "sandbox": { "enabled": false } }"#),
2046        ]);
2047        assert!(opt_out.config.sandbox.enabled);
2048        assert!(drop_keys(&opt_out).contains(&"sandbox.enabled".to_string()));
2049    }
2050
2051    #[test]
2052    fn config_resolve_project_user_only_keys_are_dropped_and_user_values_win() {
2053        let result = resolve_config(&[
2054            tier(
2055                "user",
2056                r#"{
2057                  "restrict_to_project_root": true,
2058                  "url_fetch_allow_private": true,
2059                  "formatter_timeout_secs": 11,
2060                  "type_checker_timeout_secs": 33,
2061                  "auto_update": true,
2062                  "bridge": { "request_timeout_ms": 3000, "hang_threshold": 3 },
2063                  "semantic": {
2064                    "backend": "openai_compatible",
2065                    "base_url": "https://user.example.test",
2066                    "api_key_env": "USER_KEY",
2067                    "model": "user-model",
2068                    "query_timeout_ms": 900
2069                  },
2070                  "lsp": {
2071                    "servers": {
2072                      "rust": { "extensions": [".rs"], "binary": "rust-analyzer" }
2073                    },
2074                    "disabled": ["user-disabled"],
2075                    "versions": { "typescript-language-server": "1.0.0" },
2076                    "auto_install": true,
2077                    "grace_days": 7
2078                  }
2079                }"#,
2080            ),
2081            tier(
2082                "project",
2083                r#"{
2084                  "restrict_to_project_root": false,
2085                  "url_fetch_allow_private": false,
2086                  "formatter_timeout_secs": 22,
2087                  "type_checker_timeout_secs": 44,
2088                  "auto_update": false,
2089                  "bridge": { "request_timeout_ms": 4000, "hang_threshold": 4 },
2090                  "semantic": {
2091                    "backend": "ollama",
2092                    "base_url": "https://project.example.test",
2093                    "api_key_env": "PROJECT_KEY",
2094                    "model": "project-model",
2095                    "timeout_ms": 2222,
2096                    "query_timeout_ms": 2222
2097                  },
2098                  "lsp": {
2099                    "servers": {
2100                      "rust": { "extensions": [".evil"], "binary": "evil-lsp" }
2101                    },
2102                    "disabled": ["project-disabled"],
2103                    "versions": { "evil-lsp": "9.9.9" },
2104                    "auto_install": false,
2105                    "grace_days": 1,
2106                    "python": "ty"
2107                  }
2108                }"#,
2109            ),
2110        ]);
2111
2112        assert!(result.config.restrict_to_project_root);
2113        assert!(result.config.url_fetch_allow_private);
2114        assert_eq!(result.config.formatter_timeout_secs, 11);
2115        assert_eq!(result.config.type_checker_timeout_secs, 33);
2116        assert_eq!(
2117            result.config.semantic.backend,
2118            SemanticBackend::OpenAiCompatible
2119        );
2120        assert_eq!(
2121            result.config.semantic.base_url.as_deref(),
2122            Some("https://user.example.test")
2123        );
2124        assert_eq!(
2125            result.config.semantic.api_key_env.as_deref(),
2126            Some("USER_KEY")
2127        );
2128        assert_eq!(result.config.semantic.model, "project-model");
2129        assert_eq!(result.config.semantic.timeout_ms, 2222);
2130        assert_eq!(result.config.semantic.query_timeout_ms, 900);
2131        assert_eq!(result.config.lsp_servers.len(), 1);
2132        assert_eq!(result.config.lsp_servers[0].binary, "rust-analyzer");
2133        assert!(result.config.disabled_lsp.contains("user-disabled"));
2134        assert!(!result.config.disabled_lsp.contains("project-disabled"));
2135        assert!(result.config.disabled_lsp.contains("python"));
2136        assert!(result.config.experimental_lsp_ty);
2137
2138        let keys = drop_keys(&result);
2139        let expected = [
2140            "restrict_to_project_root",
2141            "url_fetch_allow_private",
2142            "formatter_timeout_secs",
2143            "type_checker_timeout_secs",
2144            "auto_update",
2145            "bridge",
2146            "semantic.backend",
2147            "semantic.base_url",
2148            "semantic.api_key_env",
2149            "semantic.query_timeout_ms",
2150            "lsp.servers",
2151            "lsp.versions",
2152            "lsp.auto_install",
2153            "lsp.grace_days",
2154            "lsp.disabled",
2155        ];
2156        for key in expected {
2157            assert!(keys.contains(&key.to_string()), "missing dropped key {key}");
2158        }
2159        assert_eq!(keys.len(), expected.len());
2160        assert!(result
2161            .dropped
2162            .iter()
2163            .all(|dropped| dropped.tier == "project"));
2164    }
2165
2166    #[test]
2167    fn config_resolve_bash_ladder_and_merge_parity() {
2168        let true_result = resolve_config(&[tier("user", r#"{ "bash": true }"#)]);
2169        assert!(true_result.config.experimental_bash_rewrite);
2170        assert!(true_result.config.experimental_bash_compress);
2171        assert!(true_result.config.experimental_bash_background);
2172
2173        let false_result = resolve_config(&[tier("user", r#"{ "bash": false }"#)]);
2174        assert!(!false_result.config.experimental_bash_rewrite);
2175        assert!(!false_result.config.experimental_bash_compress);
2176        assert!(!false_result.config.experimental_bash_background);
2177
2178        let object_default_result = resolve_config(&[tier("user", r#"{ "bash": {} }"#)]);
2179        assert!(object_default_result.config.experimental_bash_rewrite);
2180        assert!(object_default_result.config.experimental_bash_compress);
2181        assert!(object_default_result.config.experimental_bash_background);
2182
2183        let object_partial_result =
2184            resolve_config(&[tier("user", r#"{ "bash": { "compress": false } }"#)]);
2185        assert!(object_partial_result.config.experimental_bash_rewrite);
2186        assert!(!object_partial_result.config.experimental_bash_compress);
2187        assert!(object_partial_result.config.experimental_bash_background);
2188
2189        let legacy_result = resolve_config(&[tier(
2190            "user",
2191            r#"{ "experimental": { "bash": { "rewrite": true } } }"#,
2192        )]);
2193        assert!(legacy_result.config.experimental_bash_rewrite);
2194        assert!(!legacy_result.config.experimental_bash_compress);
2195        assert!(!legacy_result.config.experimental_bash_background);
2196
2197        let surface_default_result = resolve_config(&[tier("user", r#"{}"#)]);
2198        assert!(surface_default_result.config.experimental_bash_rewrite);
2199        assert!(surface_default_result.config.experimental_bash_compress);
2200        assert!(surface_default_result.config.experimental_bash_background);
2201
2202        let minimal_surface_result =
2203            resolve_config(&[tier("user", r#"{ "tool_surface": "minimal" }"#)]);
2204        assert!(!minimal_surface_result.config.experimental_bash_rewrite);
2205        assert!(!minimal_surface_result.config.experimental_bash_compress);
2206        assert!(!minimal_surface_result.config.experimental_bash_background);
2207
2208        let merged_result = resolve_config(&[
2209            tier("user", r#"{ "bash": true }"#),
2210            tier("project", r#"{ "bash": { "compress": false } }"#),
2211        ]);
2212        assert!(merged_result.config.experimental_bash_rewrite);
2213        assert!(!merged_result.config.experimental_bash_compress);
2214        assert!(merged_result.config.experimental_bash_background);
2215
2216        let false_then_object_result = resolve_config(&[
2217            tier("user", r#"{ "bash": false }"#),
2218            tier("project", r#"{ "bash": { "compress": true } }"#),
2219        ]);
2220        assert!(!false_then_object_result.config.experimental_bash_rewrite);
2221        assert!(false_then_object_result.config.experimental_bash_compress);
2222        assert!(!false_then_object_result.config.experimental_bash_background);
2223    }
2224
2225    #[test]
2226    fn config_resolve_bash_foreground_wait_clamps_to_floor() {
2227        let Some(raw) = parse_tier(&tier(
2228            "user",
2229            r#"{ "bash": { "foreground_wait_window_ms": 1, "subagent_background": true } }"#,
2230        )) else {
2231            panic!("test tier should parse");
2232        };
2233        let bash = resolve_bash_config(&raw);
2234
2235        assert_eq!(
2236            bash.foreground_wait_window_ms,
2237            FOREGROUND_WAIT_WINDOW_MIN_MS
2238        );
2239        assert!(bash.subagent_background);
2240
2241        let result = resolve_config(&[tier(
2242            "user",
2243            r#"{ "bash": { "foreground_wait_window_ms": 1 } }"#,
2244        )]);
2245        assert_eq!(
2246            result.config.foreground_wait_window_ms,
2247            FOREGROUND_WAIT_WINDOW_MIN_MS
2248        );
2249
2250        // Unset → the default wait-window (matches the plugin's
2251        // FOREGROUND_WAIT_WINDOW_DEFAULT_MS = 15_000). This locks the default,
2252        // not just the floor, so a future edit can't silently shorten the
2253        // server-side promotion window once the plugin orchestrates through Rust.
2254        let defaulted = resolve_config(&[tier("user", r#"{ "bash": true }"#)]);
2255        assert_eq!(
2256            defaulted.config.foreground_wait_window_ms,
2257            FOREGROUND_WAIT_WINDOW_DEFAULT_MS
2258        );
2259        assert_eq!(FOREGROUND_WAIT_WINDOW_DEFAULT_MS, 15_000);
2260    }
2261
2262    #[test]
2263    fn config_resolve_partial_parse_drops_invalid_section_and_keeps_valid_sections() {
2264        let result = resolve_config(&[tier(
2265            "user",
2266            r#"{
2267              "semantic": { "timeout_ms": 0 },
2268              "search_index": true,
2269              "format_on_edit": false
2270            }"#,
2271        )]);
2272
2273        assert!(result.config.search_index);
2274        assert!(!result.config.format_on_edit);
2275        assert_eq!(result.config.semantic, SemanticBackendConfig::default());
2276        assert!(result.dropped.is_empty());
2277    }
2278
2279    #[test]
2280    fn config_resolve_unknown_top_level_key_is_dropped_but_rest_survives() {
2281        let result = resolve_config(&[tier(
2282            "user",
2283            r#"{ "not_a_real_key": true, "search_index": true }"#,
2284        )]);
2285
2286        assert!(result.config.search_index);
2287        assert!(result.dropped.is_empty());
2288    }
2289
2290    #[test]
2291    fn resolve_config_onto_resets_core_fields_no_cross_bind_inheritance() {
2292        // Cross-bind escalation regression: a first (trusted) bind sets a
2293        // capability field; a second bind that omits it must NOT inherit it.
2294        // This is the configure-path entry (seeded from a prior config), where
2295        // reset-onto-default — unlike the old overlay — makes the resolved core
2296        // config a pure function of the CURRENT bind's tiers.
2297        let mut config = Config::default();
2298
2299        // Bind 1 (trusted user tier) sets url_fetch_allow_private + a custom LSP
2300        // server + restrict_to_project_root.
2301        let dropped1 = resolve_config_onto(
2302            &[tier(
2303                "user",
2304                r#"{
2305                  "url_fetch_allow_private": true,
2306                  "restrict_to_project_root": true,
2307                  "lsp": { "servers": { "rust": { "extensions": [".rs"], "binary": "rust-analyzer" } } }
2308                }"#,
2309            )],
2310            &mut config,
2311        );
2312        assert!(dropped1.is_empty());
2313        assert!(config.url_fetch_allow_private);
2314        assert!(config.restrict_to_project_root);
2315        assert_eq!(config.lsp_servers.len(), 1);
2316
2317        // Bind 2 omits all three. With reset semantics they return to DEFAULT —
2318        // the second bind cannot inherit the first bind's capabilities.
2319        let _ = resolve_config_onto(&[tier("user", r#"{ "search_index": true }"#)], &mut config);
2320        assert!(
2321            !config.url_fetch_allow_private,
2322            "url_fetch_allow_private must reset to default, not inherit prior bind"
2323        );
2324        assert!(
2325            !config.restrict_to_project_root,
2326            "restrict_to_project_root must reset to default"
2327        );
2328        assert!(
2329            config.lsp_servers.is_empty(),
2330            "lsp_servers must reset to default, not inherit prior bind's custom server"
2331        );
2332        assert!(config.search_index, "this bind's own field still applies");
2333    }
2334
2335    #[test]
2336    fn resolve_config_onto_empty_tiers_resets_to_default() {
2337        // The empty-tier path must still reset (it routes through the same
2338        // always-run resolution in handle_configure). A bind with no tiers after
2339        // a privileged bind must drop the privileged config.
2340        let mut config = Config::default();
2341        let _ = resolve_config_onto(
2342            &[tier("user", r#"{ "url_fetch_allow_private": true }"#)],
2343            &mut config,
2344        );
2345        assert!(config.url_fetch_allow_private);
2346
2347        let _ = resolve_config_onto(&[], &mut config);
2348        assert!(
2349            !config.url_fetch_allow_private,
2350            "empty-tier bind must reset core config to default"
2351        );
2352    }
2353
2354    #[test]
2355    fn resolve_config_onto_preserves_process_state_fields() {
2356        // Process-state fields (not part of RawAftConfig) are carried across the
2357        // reset so plugin-mode behavior is unchanged (the plugin re-sends them
2358        // via flat configure params right after this call).
2359        let mut config = Config {
2360            storage_dir: Some(std::path::PathBuf::from("/tmp/aft-store")),
2361            lsp_paths_extra: vec![std::path::PathBuf::from("/tmp/lsp-bin")],
2362            bash_permissions: true,
2363            project_root: Some(std::path::PathBuf::from("/tmp/proj")),
2364            ..Default::default()
2365        };
2366
2367        let _ = resolve_config_onto(&[tier("user", r#"{ "search_index": true }"#)], &mut config);
2368
2369        assert_eq!(
2370            config.storage_dir,
2371            Some(std::path::PathBuf::from("/tmp/aft-store"))
2372        );
2373        assert_eq!(
2374            config.lsp_paths_extra,
2375            vec![std::path::PathBuf::from("/tmp/lsp-bin")]
2376        );
2377        assert!(config.bash_permissions);
2378        assert_eq!(
2379            config.project_root,
2380            Some(std::path::PathBuf::from("/tmp/proj"))
2381        );
2382        assert!(config.search_index);
2383    }
2384
2385    #[test]
2386    fn config_resolve_jsonc_comments_and_trailing_commas_parse() {
2387        let result = resolve_config(&[tier(
2388            "user",
2389            r#"{
2390              // line comment
2391              "search_index": true,
2392              "formatter": {
2393                "rust": "rustfmt", /* block comment */
2394              },
2395            }"#,
2396        )]);
2397
2398        assert!(result.config.search_index);
2399        assert_eq!(
2400            result.config.formatter.get("rust").map(String::as_str),
2401            Some("rustfmt")
2402        );
2403    }
2404}