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