Skip to main content

aft/
config_resolve.rs

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