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