Skip to main content

supercode_harness/
configfile.rs

1//! §3 "The Single Config File" (`docs/composable-harness/COMPOSABLE-HARNESS-DESIGN.md`)
2//! — P1 of the composable-harness migration (design §5.2, phase **P1**).
3//!
4//! `HarnessConfig` is the one schema described in §3.1: `schema_version` +
5//! `extends` + `[core]` (+ its subtables) + `[capabilities.*]` +
6//! `[experimental]`. It is a plain serde struct with no format-specific
7//! logic, so it parses identically from TOML ([`HarnessConfig::from_toml_str`],
8//! the CLI's format) or JSON ([`HarnessConfig::from_json_str`], the SDK
9//! mirror §3.0 describes as superseding the old 9-field `ConfigProfile`).
10//!
11//! **P1 scope** (design §5.2's exact wording): the config *surface*, not the
12//! module *runtime*. Fields with no `Config` runtime home yet are captured
13//! typed-but-unconsumed with a `P3/P4:` doc-comment rather than inventing
14//! behavior ahead of the phase that consumes them. `extends` is parsed but
15//! **not** resolved — preset resolution (§3.5, the preset table itself in
16//! §4) is P2. `[capabilities.*]` module *settings* are likewise parsed but
17//! not consumed — module runtime wiring is P3 (design's explicit framing:
18//! "consumed later phases").
19//!
20//! **Naming note (P1 judgment call).** `crates/harness/src/config.rs` already
21//! defines a small `ConfigFile { profiles: HashMap<String, ConfigProfile> }`
22//! — a *named-profile table* (the SDK's `--profile`/`from_profile_file`
23//! mechanism). That shape is not what §3.1 describes (one resolved harness,
24//! not a table of named alternatives), so this module introduces the new
25//! type under a distinct name, `HarnessConfig`, rather than repurposing or
26//! renaming the existing `ConfigFile`. This is the least-breaking path: zero
27//! changes to `Config::from_profile_file` or its existing test
28//! (`crates/harness/tests/agent_loop.rs:640-652`).
29//!
30//! **`[core.model]` schema conflict (P1 judgment call).** §3.1 literally
31//! shows a scalar `core.model` (the model id string, line 582) *and* a table
32//! `[core.model]` a few lines later (`allow_switch`, line 611-612). Those are
33//! not simultaneously representable in one TOML document — a table cannot
34//! redefine a key already set as a string in the same parent table (verified
35//! empirically: both `tomllib` and the `toml` crate reject it as "cannot
36//! overwrite a value"). Rather than silently working around a spec bug, this
37//! is exposed under a distinct table name, `[core.model_switch]`
38//! ([`CoreModelSwitchConfig`]), until the design doc is corrected upstream.
39
40use std::collections::{BTreeMap, HashMap};
41
42use serde::{Deserialize, Serialize};
43
44use crate::config::{ApprovalPolicy, Config, ConfigBuilder, ConfigProfile, ToolOverrideProfile};
45use crate::tools::SandboxPolicy;
46
47fn default_schema_version() -> u32 {
48    1
49}
50
51/// BP-9 (§1.8 "env substitution in values", D6 row "Env/command
52/// substitution in config values"): expand substitution references in `s`
53/// against the process environment and the filesystem. Applied at
54/// [`HarnessConfig::to_config_profile`] to the string-valued `[core]`
55/// fields that plausibly vary per deployment — `base_url`, `system_prompt`,
56/// `append_system_prompt`, `additional_dirs`, `extra_headers` values, and
57/// `extra_body` string values (judgment call, §1.8's "in values" wording
58/// names no exhaustive field list; `api_key_env`/`api_key_cmd`/
59/// `api_key_command` are deliberately EXCLUDED — the first is already an
60/// env var NAME not a value, the other two are commands the shell/exec
61/// layer resolves when it runs them, see the call site's comment).
62///
63/// Three forms are recognized, matching the catalog's D6 semantics
64/// (`${VAR}`, `{file:…}`, `!command`) with the third deliberately REFUSED:
65///
66/// * `${VAR}` — the process environment. An unset variable is left LITERAL
67///   (`${VAR}` stays in the output) rather than silently substituted with
68///   an empty string, so a config author sees immediately that something
69///   didn't resolve instead of silently getting a blank `base_url`.
70/// * `${VAR:-default}` — the shell's own "use `default` when `VAR` is unset
71///   OR empty" operator (cc§7's `.mcp.json` form). Because the default makes
72///   the author's intent explicit, THIS form never leaves a literal behind.
73/// * `{file:/path/to/secret}` — the file's contents with trailing newlines
74///   trimmed (oc§6's form). An unreadable path is left LITERAL, the same
75///   fail-visible posture as an unset `${VAR}`.
76///
77/// `!command` (pi§6's form) is NOT expanded here and never will be: a
78/// config VALUE that silently executes a command turns every layer that can
79/// set that value into arbitrary code execution. The one sanctioned door is
80/// the explicitly-named credential helper (`core.api_key_cmd` /
81/// `core.api_key_command`), which is `[project-forbidden]` and runs only in
82/// the credential-resolution path. [`command_substitution_refusals`] reports
83/// a `!`-prefixed value as a resolve-time warning naming that reason.
84pub fn expand_env_vars(s: &str) -> String {
85    let mut out = String::with_capacity(s.len());
86    let mut i = 0usize;
87    while i < s.len() {
88        let rest = &s[i..];
89        if let Some(end) = rest.strip_prefix("${").and_then(|r| r.find('}')) {
90            let literal = &rest[..2 + end + 1];
91            out.push_str(&expand_env_ref(&rest[2..2 + end], literal));
92            i += literal.len();
93            continue;
94        }
95        if let Some(end) = rest.strip_prefix(FILE_REF_PREFIX).and_then(|r| r.find('}')) {
96            let literal = &rest[..FILE_REF_PREFIX.len() + end + 1];
97            out.push_str(&expand_file_ref(
98                &rest[FILE_REF_PREFIX.len()..FILE_REF_PREFIX.len() + end],
99                literal,
100            ));
101            i += literal.len();
102            continue;
103        }
104        // Not a reference start (including an unterminated `${`/`{file:`):
105        // copy one character verbatim and keep scanning.
106        let ch = rest.chars().next().expect("non-empty remainder");
107        out.push(ch);
108        i += ch.len_utf8();
109    }
110    out
111}
112
113/// The `{file:…}` reference opener. A `const` so the scanner above and
114/// [`is_safe_project_dir`]'s rejection agree on one spelling.
115pub(crate) const FILE_REF_PREFIX: &str = "{file:";
116
117/// `${VAR}` / `${VAR:-default}`. `literal` is the whole reference as
118/// written, returned unchanged when a bare `${VAR}` doesn't resolve.
119fn expand_env_ref(inner: &str, literal: &str) -> String {
120    match inner.split_once(":-") {
121        Some((name, default)) => match std::env::var(name) {
122            Ok(v) if !v.is_empty() => v,
123            _ => default.to_string(),
124        },
125        None => std::env::var(inner).unwrap_or_else(|_| literal.to_string()),
126    }
127}
128
129/// `{file:PATH}` — the file's contents, trailing newlines trimmed (a secret
130/// file written by `printf`/`echo` should not carry its own newline into a
131/// header value). Unreadable → the literal, like an unset `${VAR}`.
132fn expand_file_ref(path: &str, literal: &str) -> String {
133    match std::fs::read_to_string(path) {
134        Ok(text) => text.trim_end_matches(['\n', '\r']).to_string(),
135        Err(_) => literal.to_string(),
136    }
137}
138
139/// Every substitution-eligible `[core]` value, as `(dotted key, value)` —
140/// the exact set [`HarnessConfig::to_config_profile`] runs
141/// [`expand_env_vars`] over, so the refusal scan below cannot drift from
142/// the expansion itself.
143fn substitutable_values(hc: &HarnessConfig) -> Vec<(String, &str)> {
144    let c = &hc.core;
145    let mut out: Vec<(String, &str)> = Vec::new();
146    for (key, value) in [
147        ("core.base_url", c.base_url.as_deref()),
148        ("core.system_prompt", c.system_prompt.as_deref()),
149        (
150            "core.append_system_prompt",
151            c.append_system_prompt.as_deref(),
152        ),
153    ] {
154        if let Some(v) = value {
155            out.push((key.to_string(), v));
156        }
157    }
158    if let Some(dirs) = &c.additional_dirs {
159        for (i, d) in dirs.iter().enumerate() {
160            out.push((format!("core.additional_dirs[{i}]"), d.as_str()));
161        }
162    }
163    if let Some(headers) = &c.extra_headers {
164        for (k, v) in headers {
165            out.push((format!("core.extra_headers.{k}"), v.as_str()));
166        }
167    }
168    if let Some(body) = &c.extra_body {
169        for (k, v) in body {
170            if let serde_json::Value::String(s) = v {
171                out.push((format!("core.extra_body.{k}"), s.as_str()));
172            }
173        }
174    }
175    out
176}
177
178/// BP-9 (D6 row): the `!command` substitution form, reported rather than
179/// run. Returns one warning per config value whose text begins with `!` —
180/// pi§6 spells a credential/config command that way, and a reader coming
181/// from pi would otherwise believe the command ran and silently ship a
182/// literal `!op read …` as their `base_url`/header. Naming the refusal (and
183/// the sanctioned door) is the whole point: the value is NEVER executed.
184pub fn command_substitution_refusals(hc: &HarnessConfig) -> Vec<String> {
185    substitutable_values(hc)
186        .into_iter()
187        .filter(|(_, v)| v.trim_start().starts_with('!'))
188        .map(|(key, _)| {
189            format!(
190                "`{key}` uses the `!command` substitution form, which supercode refuses: a config \
191                 value must never execute a command (§3.3 trust boundary). The value is used \
192                 verbatim; for credentials use the named helper `core.api_key_cmd` / \
193                 `core.api_key_command` instead"
194            )
195        })
196        .collect()
197}
198
199/// The top-level schema (§3.1): one TOML/JSON document that fully determines
200/// the harness's shape (§3.0: "Everything the harness does is a function of
201/// the resolved file").
202#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
203pub struct HarnessConfig {
204    /// BP-9 (D6 row "Published JSON schema for config"): the editor-facing
205    /// `$schema` pointer. Purely declarative — the resolver never fetches
206    /// or validates against it; it exists so an editor (VS Code, Zed,
207    /// Helix, anything with a JSON/TOML schema store) can be pointed at
208    /// `docs/schema/supercode-config.schema.json` from inside the file it
209    /// validates, exactly the way cc§6 publishes its settings schema.
210    /// Accepting the key is the whole feature: without a field for it, the
211    /// strict resolver would reject the very line that makes the file
212    /// editor-validated. Never merged into behavior — [`Self::overlay`]
213    /// keeps the higher layer's pointer only for round-tripping.
214    #[serde(rename = "$schema", default)]
215    pub schema: Option<String>,
216    /// Schema version; `1` is the only version P1 understands.
217    #[serde(default = "default_schema_version")]
218    pub schema_version: u32,
219    /// Built-in preset name, or (user/global layer only, §3.3) a file path.
220    /// Parsed but NOT resolved in P1 — preset resolution is §3.5 / P2.
221    #[serde(default)]
222    pub extends: Option<String>,
223    /// `[core]` — obligation knobs (§1). Per §3.0, the region is always
224    /// present in a resolved config even when every knob inside it is
225    /// defaulted; `#[serde(default)]` gives an absent `[core]` table the
226    /// same all-defaulted shape.
227    #[serde(default)]
228    pub core: CoreSection,
229    /// `[capabilities.*]` — the §2 modules, keyed by capability name.
230    /// Parsed (the surface) but not consumed (the runtime) in P1 — see the
231    /// module doc comment.
232    #[serde(default)]
233    pub capabilities: BTreeMap<String, CapabilityConfig>,
234    /// `[experimental]` — obligation 8 feature flags, staged gates not yet
235    /// promoted to `[core]`. Untyped: P1 only carries the table through.
236    /// LOW-1 (P3 review): a project-layer file may never set ANY key in
237    /// this table — `sanitize_for_project` strips it whole, since future
238    /// flags added here aren't guaranteed narrowing-only the way
239    /// `module_registry` is today. User/global layer only.
240    #[serde(default)]
241    pub experimental: serde_json::Map<String, serde_json::Value>,
242}
243
244impl Default for HarnessConfig {
245    fn default() -> Self {
246        HarnessConfig {
247            schema: None,
248            schema_version: default_schema_version(),
249            extends: None,
250            core: CoreSection::default(),
251            capabilities: BTreeMap::new(),
252            experimental: serde_json::Map::new(),
253        }
254    }
255}
256
257/// `[core]` (§3.1 lines 581-609 + the named subtables that follow).
258#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
259pub struct CoreSection {
260    /// `Config.model` (config.rs).
261    pub model: Option<String>,
262    /// `Config.base_url` (config.rs). `[project-forbidden]` (§3.3).
263    pub base_url: Option<String>,
264    /// `Config.api_key_env` (config.rs). `[project-forbidden]` (§3.3).
265    pub api_key_env: Option<String>,
266    /// NEW: credential helper (`!command` form, pi§6 / D6 row).
267    /// `[project-forbidden]`. P4: consumed by the CLI's credential
268    /// resolution (`userconfig::resolve_api_key`) — captured, not yet wired.
269    pub api_key_cmd: Option<String>,
270    /// BP-9 (D6 row "Credential helpers / keyring", cx§6 `auth{command}`,
271    /// cc§6 `apiKeyHelper`): an ARGV credential helper — the program is
272    /// exec'd directly with the remaining entries as arguments, and its
273    /// stdout (trimmed) is the API key. `[project-forbidden]`, the same
274    /// credential-redirection trust boundary as `api_key_cmd`.
275    ///
276    /// Distinct from `api_key_cmd` on purpose: that one is a SHELL string
277    /// (`sh -c "…"`, so the shell's own quoting/expansion applies), this
278    /// one is an exec'd argv with no shell in the path — the form cx and
279    /// cc both publish, and the safe one to accept from a config file the
280    /// user typed by hand (no word-splitting surprises, no `$(…)`).
281    /// Consumed by `Agent::new` before `api_key_cmd`.
282    pub api_key_command: Option<Vec<String>>,
283    /// BP-9 (D6 row "Auto-update + channels", cx§10 "startup check"):
284    /// whether the CLI performs a background "is there a newer release?"
285    /// check at startup. OPT-IN — absent/`false` means the CLI never
286    /// reaches the network on startup, which is today's behavior and the
287    /// only defensible default for a tool that runs in CI and on airgapped
288    /// boxes. `supercode update` itself is unaffected (an explicit command
289    /// is always allowed to check).
290    pub update_check: Option<bool>,
291    /// `Config.effort` (config.rs).
292    pub effort: Option<String>,
293    /// `Config.temperature` (config.rs).
294    pub temperature: Option<f32>,
295    /// `Config.max_tokens` (config.rs).
296    pub max_tokens: Option<u32>,
297    /// `Config.max_iterations` (config.rs).
298    pub max_iterations: Option<usize>,
299    /// `Config.max_total_output_tokens` (config.rs); `0`/absent = off.
300    pub max_total_output_tokens: Option<u64>,
301    /// `Config.max_tool_output_bytes` (config.rs).
302    pub max_tool_output_bytes: Option<usize>,
303    /// BP-7 (catalog §4a "Turn/budget caps", cc's `--max-budget-usd`):
304    /// `Config.max_budget_usd` — the SPEND cap. `0`/absent = off.
305    pub max_budget_usd: Option<f64>,
306    /// BP-7 (catalog §4a "Turn/budget caps"): `Config.max_steps` — the
307    /// STEP cap (tool calls executed), distinct from `max_iterations`
308    /// (model round-trips). `0`/absent = off.
309    pub max_steps: Option<usize>,
310    /// BP-7: `Config.price_input_per_mtok` — dollars per million input
311    /// tokens, overriding `crate::pricing`'s built-in table for this
312    /// model. Set with `price_output_per_mtok` or not at all.
313    pub price_input_per_mtok: Option<f64>,
314    /// BP-7: `Config.price_output_per_mtok` — dollars per million output
315    /// tokens.
316    pub price_output_per_mtok: Option<f64>,
317    /// NEW: universal parallel tool-call execution (catalog:59). P4e:
318    /// consumed by `Agent::run_tools_concurrently` — see
319    /// `Config::parallel_tool_calls`'s doc comment.
320    pub parallel_tool_calls: Option<bool>,
321    /// BP-2 (`core.tool_output_spill`, catalog:58) — see
322    /// `Config::tool_output_spill`'s doc comment.
323    pub tool_output_spill: Option<bool>,
324    /// NEW: shell-env snapshotting (catalog:338).
325    /// P3/P4: consumed by the bash tool module.
326    pub shell_env_snapshot: Option<bool>,
327    /// `Config.system_prompt` (config.rs). `[project-forbidden]` (§3.3).
328    pub system_prompt: Option<String>,
329    /// NEW: append lever (D2 row 1). `[project-forbidden]`.
330    /// P4: consumed by prompt assembly, alongside `system_prompt`.
331    pub append_system_prompt: Option<String>,
332    /// `Config.load_project_context` (config.rs).
333    pub project_context: Option<bool>,
334    /// NEW: environment block (catalog §4a).
335    /// P4: consumed by prompt assembly.
336    pub env_context: Option<bool>,
337    /// NEW: synthetic nudge blocks (catalog:91).
338    /// P4: consumed by prompt assembly.
339    pub context_injections: Option<bool>,
340    /// NEW: on-demand subdir instruction loading (catalog:84).
341    /// P4: consumed by the skills/instructions subsystem.
342    pub nested_instructions: Option<bool>,
343    /// NEW: `@path` / `instructions[]` imports (catalog:85).
344    /// P4: consumed by the skills/instructions subsystem.
345    pub instruction_imports: Option<bool>,
346    /// NEW: directory-walk stop markers (catalog:232).
347    /// P4: consumed by project-context discovery.
348    pub project_root_markers: Option<Vec<String>>,
349    /// NEW: live-apply config edits (catalog:221). ASPIRATIONAL /
350    /// UNIMPLEMENTED (P4e assessment): a genuine config-file-watch +
351    /// live-reload subsystem — detecting the resolved file changing on
352    /// disk, re-resolving the full `extends`/layering chain, and safely
353    /// swapping a live `Agent`'s `Config` mid-run without corrupting
354    /// in-flight state — is M+ (an architecturally significant addition
355    /// per catalog:221's "COMMON row" classification, not a small runtime
356    /// gap), not the S-sized "NEW: small" a config-plumbing-only key would
357    /// be. This field parses and round-trips through every merge/overlay
358    /// step (so a config file setting it is never silently dropped or
359    /// misinterpreted) but has NO consumer: setting it does nothing. Needs
360    /// explicit scheduling as its own unit (P5+), not a half-built watcher
361    /// here.
362    pub hot_reload: Option<bool>,
363    /// P4b (design §5.2 "P4" "instruction-walk nuances", cx§2
364    /// `project_doc_max_bytes` analog, §3.1 `core.project_doc_max_bytes`):
365    /// hygiene cap on the total bytes of assembled instruction-file content
366    /// — see `Config::project_doc_max_bytes`. Consumed by prompt assembly.
367    pub project_doc_max_bytes: Option<usize>,
368    /// BP-4 (catalog:87 "Instruction-file hygiene controls", cc§2
369    /// `claudeMdExcludes`, `core.project_doc_excludes`): glob/path patterns
370    /// naming instruction files to skip — see `Config::project_doc_excludes`.
371    pub project_doc_excludes: Option<Vec<String>>,
372    /// BP-4 (catalog:87, cc§2 "HTML comment stripping",
373    /// `core.project_doc_strip_comments`): drop `<!-- … -->` spans from
374    /// instruction files before injection — see
375    /// `Config::project_doc_strip_comments`.
376    pub project_doc_strip_comments: Option<bool>,
377    /// BP-5 (catalog D2 "@-file mentions / attachments", cc§2/cx§2):
378    /// expand `@path` tokens in a prompt into the file's contents — see
379    /// `Config::file_mentions`.
380    pub file_mentions: Option<bool>,
381    /// BP-5 (catalog D2 "Output style / personality module", cc§7/cx§2):
382    /// the named response-style layer — see `Config::output_style`.
383    pub output_style: Option<String>,
384    /// BP-5 (catalog D2 "Path-scoped rules", cc§2 `.claude/rules/*.md`):
385    /// load rule files, `paths:`-scoped ones on demand — see
386    /// `Config::path_rules`.
387    pub path_rules: Option<bool>,
388    /// `Config.additional_dirs` (config.rs). Project files may only ADD
389    /// under the repo root (§3.3) — enforced by `sanitize_for_project`'s
390    /// `is_safe_project_dir` check (LOW-1, Fable-5 P4a review), which strips
391    /// absolute/`~`/`..`-escaping/`${VAR}`-expanding entries from a project
392    /// layer before this is expanded (`to_config_profile`). User/global
393    /// layers are unrestricted.
394    pub additional_dirs: Option<Vec<String>>,
395    /// `Config.extra_headers` (config.rs). `[project-forbidden]`: exfil
396    /// channel (§3.3).
397    pub extra_headers: Option<HashMap<String, String>>,
398    /// `Config.extra_body` (config.rs). `[project-forbidden]` (§3.3).
399    pub extra_body: Option<serde_json::Map<String, serde_json::Value>>,
400    /// P4c (design §5.2 "P4", §5.2 P4 "doom-loop breaker", oc `doom_loop`
401    /// UNIQUE row, catalog D3): repeated-identical-tool-call threshold — see
402    /// `Config::doom_loop_threshold`. `None`/absent = off (today's
403    /// behavior).
404    pub doom_loop_threshold: Option<u32>,
405
406    /// `[core.model_switch]` — see the module-level doc comment on the
407    /// `[core.model]` naming conflict.
408    #[serde(default)]
409    pub model_switch: CoreModelSwitchConfig,
410    /// `[core.retry]` (obligation 1; pi§3 naming).
411    #[serde(default)]
412    pub retry: CoreRetryConfig,
413    /// `[core.tools]` — registry shaping.
414    #[serde(default)]
415    pub tools: CoreToolsConfig,
416    /// `[core.skills]` (obligation 4, D-7).
417    #[serde(default)]
418    pub skills: CoreSkillsConfig,
419    /// `[core.prompts]` — maps directly onto `Config.prompts` (config.rs);
420    /// a table merged key-wise onto the built-ins, not a wholesale replace
421    /// (§3.3), via `ConfigBuilder::apply_profile`.
422    #[serde(default)]
423    pub prompts: BTreeMap<String, String>,
424    /// `[core.compaction]` (obligation 5).
425    #[serde(default)]
426    pub compaction: CoreCompactionConfig,
427    /// `[core.session]` (obligation 6).
428    #[serde(default)]
429    pub session: CoreSessionConfig,
430    /// `[core.steering]` (obligation 7; pi§3 semantics).
431    #[serde(default)]
432    pub steering: CoreSteeringConfig,
433    /// `[core.output]` (obligation 9).
434    #[serde(default)]
435    pub output: CoreOutputConfig,
436}
437
438/// `[core.model_switch]` (design's `[core.model]`; see the naming-conflict
439/// doc comment above).
440#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
441pub struct CoreModelSwitchConfig {
442    /// NEW core subsystem: mid-session switch + persisted `model_change`
443    /// records (§1.10). P4: consumed by the agentic loop + session store.
444    pub allow_switch: Option<bool>,
445    /// BP-13 (`core.model_switch.notice`): when the model changes
446    /// mid-session, splice a short user-role notice into the conversation
447    /// so the NEW model reads the handoff instead of inferring it — Codex's
448    /// own mid-session behavior ("switch instructions injected", cx§9).
449    /// Claude Code changes the model silently, so this defaults to off and
450    /// each preset says which harness it is imitating.
451    pub notice: Option<bool>,
452}
453
454/// `[core.retry]`. P4: consumed by a request-retry loop that doesn't exist
455/// as a `Config` field yet (obligation 1; pi§3 naming).
456#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
457pub struct CoreRetryConfig {
458    /// Whether the retry loop is on.
459    pub enabled: Option<bool>,
460    /// Maximum retry attempts.
461    pub max_retries: Option<u32>,
462    /// Base backoff delay in milliseconds (doubles per pi§3 semantics).
463    pub base_delay_ms: Option<u64>,
464}
465
466/// `[core.tools]` — registry shaping (§3.1 line 619; replaces
467/// `with_builtins()` hardcoding, `tools/mod.rs:179-192`, in **P3**).
468#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
469pub struct CoreToolsConfig {
470    /// The default-active tool names. P3: consumed by `ToolRegistry`
471    /// construction (`with_builtins()` today is unconditional).
472    pub enabled: Option<Vec<String>>,
473    /// Global schema tier — mirrors `ConfigProfile::schema_tier`; this ONE
474    /// *is* resolved in P1 via [`HarnessConfig::to_config_profile`], since
475    /// `Config.tool_schema_tier` already exists.
476    pub schema_tier: Option<String>,
477    /// `[core.tools.read_file]`.
478    #[serde(default)]
479    pub read_file: ReadFileToolConfig,
480    /// `[core.tools.edit_file]`.
481    #[serde(default)]
482    pub edit_file: EditFileToolConfig,
483    /// `[core.tools.bash]` — the one per-tool table P1 resolves into a real
484    /// `ToolOverride` (minus `timeout_secs`, see [`BashToolConfig`]).
485    #[serde(default)]
486    pub bash: BashToolConfig,
487}
488
489/// `[core.tools.read_file]`. P3/P4: `multimodal` has no `ToolOverride` home
490/// yet (catalog §4a small).
491#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
492pub struct ReadFileToolConfig {
493    /// Whether `read_file` may return image content (catalog §4a).
494    pub multimodal: Option<bool>,
495    /// BP-2: whether `read_file` numbers its output `cat -n` style
496    /// (catalog:26) — see [`crate::Config::read_file_line_numbers`].
497    pub line_numbers: Option<bool>,
498}
499
500/// `[core.tools.edit_file]`. P3/P4: `require_read_before_edit`/
501/// `notebook_aware` have no `ToolOverride` home yet (S6/S12 catalog rows 32,
502/// 40 — `ToolContext` state). `schema_tier` DOES resolve (P2 addition,
503/// mirroring `[core.tools.bash].schema_tier`'s existing P1 handling in
504/// [`HarnessConfig::to_config_profile`]) — needed for `token-saver`'s own
505/// C9 resolution (§2.2: "per-tool `Full` override survives a global
506/// `minimal`", design §4.5) to actually materialize into the resolved
507/// [`Config`] rather than silently parsing-and-dropping the one field the
508/// preset relies on.
509#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
510pub struct EditFileToolConfig {
511    /// Require a prior `read_file` on the same path before an edit is
512    /// accepted (unique CC row, catalog:32).
513    pub require_read_before_edit: Option<bool>,
514    /// Notebook-cell-aware editing (unique CC row "NotebookEdit", catalog:40).
515    pub notebook_aware: Option<bool>,
516    /// Per-tool schema tier override — `ToolOverride::schema_tier` for
517    /// `edit_file` (config.rs; C9, catalog §5 conflict 9).
518    pub schema_tier: Option<String>,
519}
520
521/// `[core.tools.bash]` — maps onto a real [`crate::config::ToolOverride`]
522/// (`enabled`/`description`/`schema_tier`/`timeout_secs`) via
523/// [`HarnessConfig::to_config_profile`] (P4e closes the `timeout_secs` gap
524/// S14 flagged — `BashTool`'s timeout is consumed via
525/// `tools::ToolContext::bash_timeout_secs`, threaded from
526/// `agent::build_tool_context`, not the `ToolOverride` struct directly,
527/// since `Tool::execute` only sees a `ToolContext`, not the resolved
528/// `Config`/`ToolOverride` map — see that field's doc comment).
529#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
530pub struct BashToolConfig {
531    /// `ToolOverride::enabled` for `bash`.
532    pub enabled: Option<bool>,
533    /// `ToolOverride::description` for `bash`.
534    pub description: Option<String>,
535    /// `ToolOverride::schema_tier` for `bash`.
536    pub schema_tier: Option<String>,
537    /// `ToolOverride::timeout_secs` for `bash` (P4e).
538    pub timeout_secs: Option<u64>,
539}
540
541/// `[core.skills]` (obligation 4, D-7). P3/P4: a NEW subsystem extending
542/// `Config.prompts`; not yet consumed.
543#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
544pub struct CoreSkillsConfig {
545    /// Whether the skills subsystem is on.
546    pub enabled: Option<bool>,
547    /// Extra roots merged over user+project skill defaults.
548    pub dirs: Option<Vec<String>>,
549    /// BP-6: whose documented skill-root table the loop discovers SKILL.md
550    /// packages from — a `HarnessId` spelling (`claude-code`, `codex`, …).
551    pub harness: Option<String>,
552    /// BP-6 (cx§7): also load a skill's body when a message merely
553    /// DESCRIBES it, not only on an explicit `$slug` mention. Off by
554    /// default — an implicit match spends a body's tokens unasked.
555    pub implicit_match: Option<bool>,
556    /// BP-5 (cc§7 "Dynamic context injection"): execute `` !`cmd` `` inside
557    /// a skill/command body at load time, through the permissions engine.
558    /// Off by default — see `Config::skills_shell_injection`.
559    pub shell_injection: Option<bool>,
560}
561
562/// `[core.compaction]` (obligation 5). `after_messages` maps to the real
563/// `Config.compact_after_messages`, resolved in P1; the rest are P4 NEW
564/// pressure-trigger fields.
565#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
566pub struct CoreCompactionConfig {
567    /// P4: no master gate exists yet on `Config` — `after_messages =
568    /// Some(0)` (or absent) is today's only "off" signal.
569    pub enabled: Option<bool>,
570    /// `Config.compact_after_messages` (config.rs).
571    pub after_messages: Option<usize>,
572    /// P4 NEW (pi§2 shape).
573    pub reserve_tokens: Option<usize>,
574    /// P4 NEW.
575    pub keep_recent_tokens: Option<usize>,
576    /// P4: `SpanSummary` side-call gate (reduce.rs:274-289; D-9 small-model
577    /// fallback) — not yet consumed here.
578    pub summarize: Option<bool>,
579    /// P4b (design §5.2 "P4" "compaction pressure trigger + focus
580    /// instructions", §3.1 `core.compaction.focus_instructions`, catalog D2
581    /// "no instruction steering" gap) — see
582    /// `Config::compaction_focus_instructions`.
583    pub focus_instructions: Option<String>,
584}
585
586/// `[core.session]` (obligation 6). P3/P4: entirely NEW — no `Config`
587/// field represents a session store location/policy today.
588#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
589pub struct CoreSessionConfig {
590    /// Default session-store location.
591    pub dir: Option<String>,
592    /// Session naming/rename (S14).
593    pub name: Option<String>,
594    /// `false` = ephemeral (D5 row; cc/cx/pi have it).
595    pub persist: Option<bool>,
596    /// Retention window in days.
597    pub retention_days: Option<u32>,
598    /// Human transcript export format: `text` | `html` (catalog:283).
599    pub export_format: Option<String>,
600    /// Auto-title/session-summary (catalog:150; D-9 small-model consumer).
601    pub auto_title: Option<bool>,
602    /// Capture git branch/sha on write (catalog:331).
603    pub git_metadata: Option<bool>,
604    /// BP-8 (catalog:150 "Append-only durable transcript"): flush every
605    /// message to `<name>.journal.jsonl` the moment it is produced, instead
606    /// of only rewriting `<name>.jsonl` at the end of a turn.
607    pub append_only: Option<bool>,
608    /// BP-8 (catalog:154 "Queued-prompt persistence"): record pending
609    /// steering / follow-up inputs in the journal so they survive a
610    /// restart. Requires `append_only` (the journal IS the record).
611    pub queue_persist: Option<bool>,
612}
613
614/// `[core.steering]` (obligation 7; pi§3 semantics). P4: NEW, no `Config`
615/// field yet.
616#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
617pub struct CoreSteeringConfig {
618    /// `all` | `one-at-a-time`.
619    pub steering_mode: Option<String>,
620    /// `all` | `one-at-a-time`.
621    pub follow_up_mode: Option<String>,
622}
623
624/// `[core.output]` (obligation 9). P3/P4: `Config.event_sink` is code-only
625/// ("Callbacks/handlers are code-only", config.rs); this is its declarative
626/// equivalent, not yet wired to anything.
627#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
628pub struct CoreOutputConfig {
629    /// `text` | `json` (JSONL event stream over `EventSink`).
630    pub format: Option<String>,
631}
632
633/// `[capabilities.<name>]` (§2 modules). Every module table carries
634/// `enabled` plus module-specific settings. P1 captures the settings as an
635/// untyped catch-all: the modules themselves are P3+ ("consumed later
636/// phases" per design §5.2's P1 description) — this struct is the config
637/// *surface* for them, not their runtime.
638#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
639pub struct CapabilityConfig {
640    /// Module master switch (§3.0: "every table has `enabled`").
641    pub enabled: Option<bool>,
642    /// Everything else the module's table carries (e.g.
643    /// `[capabilities.permissions] approval = "..."`), captured but not
644    /// consumed in P1.
645    #[serde(flatten)]
646    pub settings: serde_json::Map<String, serde_json::Value>,
647}
648
649/// F7 fix: `schema_version` previously parsed any `u32` silently — a future
650/// (or simply typo'd) version number would be interpreted under TODAY's
651/// field meanings with no warning at all, exactly the kind of silent
652/// misinterpretation §3.5 step 5's "fail SAFE" precedent exists to prevent
653/// elsewhere in this migration. Only `1` is understood in P1.
654#[derive(Debug)]
655pub enum HarnessConfigError {
656    /// The document isn't valid TOML, or doesn't match the schema.
657    Toml(toml::de::Error),
658    /// The document isn't valid JSON, or doesn't match the schema.
659    Json(serde_json::Error),
660    /// The document parsed fine, but named a `schema_version` this build
661    /// doesn't understand.
662    UnsupportedSchemaVersion(u32),
663}
664
665impl std::fmt::Display for HarnessConfigError {
666    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
667        match self {
668            HarnessConfigError::Toml(e) => write!(f, "{e}"),
669            HarnessConfigError::Json(e) => write!(f, "{e}"),
670            HarnessConfigError::UnsupportedSchemaVersion(v) => write!(
671                f,
672                "unsupported schema_version {v}; this build only understands schema_version = 1"
673            ),
674        }
675    }
676}
677
678impl std::error::Error for HarnessConfigError {}
679
680impl HarnessConfig {
681    /// Parse from TOML text — the CLI's format (`.supercode.toml` /
682    /// `config.toml`).
683    pub fn from_toml_str(s: &str) -> Result<Self, HarnessConfigError> {
684        let hc: HarnessConfig = toml::from_str(s).map_err(HarnessConfigError::Toml)?;
685        hc.check_schema_version()?;
686        Ok(hc)
687    }
688
689    /// Parse from JSON text — the SDK mirror (§3.0).
690    pub fn from_json_str(s: &str) -> Result<Self, HarnessConfigError> {
691        let hc: HarnessConfig = serde_json::from_str(s).map_err(HarnessConfigError::Json)?;
692        hc.check_schema_version()?;
693        Ok(hc)
694    }
695
696    /// F7: reject an unknown `schema_version` rather than silently
697    /// interpreting it under P1's `[core]`/`[capabilities]` field meanings.
698    fn check_schema_version(&self) -> Result<(), HarnessConfigError> {
699        if self.schema_version != 1 {
700            return Err(HarnessConfigError::UnsupportedSchemaVersion(
701                self.schema_version,
702            ));
703        }
704        Ok(())
705    }
706
707    /// Resolve the `[core]` region (§3.1) into a [`ConfigProfile`] — the
708    /// same per-key overlay type [`ConfigBuilder::apply_profile`] already
709    /// knows how to fold (§3.3: scalars replace, tables merge, arrays
710    /// replace). `[capabilities.*]` is deliberately NOT read here (P1 scope:
711    /// module settings are P3 consumption); `extends` is deliberately NOT
712    /// followed (P2: preset resolution, §3.5).
713    pub fn to_config_profile(&self) -> ConfigProfile {
714        let c = &self.core;
715
716        let mut tool_overrides = HashMap::new();
717        if c.tools.bash.enabled.is_some()
718            || c.tools.bash.description.is_some()
719            || c.tools.bash.schema_tier.is_some()
720            || c.tools.bash.timeout_secs.is_some()
721        {
722            tool_overrides.insert(
723                "bash".to_string(),
724                ToolOverrideProfile {
725                    enabled: c.tools.bash.enabled,
726                    description: c.tools.bash.description.clone(),
727                    schema_tier: c.tools.bash.schema_tier.clone(),
728                    // P4e (§3.1 `core.tools.bash.timeout_secs`, S14): reaches
729                    // `Config.tool_overrides["bash"].timeout_secs`, which
730                    // `agent::build_tool_context` folds into
731                    // `ToolContext::bash_timeout_secs` for `BashTool::execute`.
732                    timeout_secs: c.tools.bash.timeout_secs,
733                },
734            );
735        }
736        // P2 addition: `edit_file`'s `schema_tier` resolves the same way
737        // `bash`'s does (see the `EditFileToolConfig` doc comment) —
738        // `require_read_before_edit`/`notebook_aware` still have no
739        // `ToolOverride` field (P3/P4), so they're excluded here.
740        if c.tools.edit_file.schema_tier.is_some() {
741            tool_overrides.insert(
742                "edit_file".to_string(),
743                ToolOverrideProfile {
744                    enabled: None,
745                    description: None,
746                    schema_tier: c.tools.edit_file.schema_tier.clone(),
747                    timeout_secs: None,
748                },
749            );
750        }
751
752        ConfigProfile {
753            model: c.model.clone(),
754            // P4 (§1.8 "env substitution in values"): `${VAR}` expansion —
755            // see `expand_env_vars`'s doc comment for the exact fields this
756            // applies to and why. `base_url` is the flagship case (D6 row:
757            // route to a different endpoint per environment without a
758            // separate config file per deployment).
759            base_url: c.base_url.as_deref().map(expand_env_vars),
760            api_key_env: c.api_key_env.clone(),
761            // NOT expanded: this is a COMMAND string (§1.8 D6 row), and the
762            // shell that runs it (`sh -c`, `agent.rs::run_api_key_cmd`)
763            // already expands `${VAR}`/`$VAR` itself — expanding it again
764            // here would double-substitute and could leak a resolved value
765            // into a place that then gets logged/echoed as plain config
766            // text instead of running through the shell's own environment.
767            api_key_cmd: c.api_key_cmd.clone(),
768            // BP-9: same reasoning as `api_key_cmd` — an argv helper is
769            // exec'd, never string-substituted, so expanding it here would
770            // resolve a secret into plain config text.
771            api_key_command: c.api_key_command.clone(),
772            update_check: c.update_check,
773            system_prompt: c.system_prompt.as_deref().map(expand_env_vars),
774            // P4 (§3.1 `core.append_system_prompt`, D2 row 1): additive,
775            // never a replacement — see `ConfigBuilder::apply_profile`'s
776            // composition. Same env-substitution treatment as
777            // `system_prompt` above.
778            append_system_prompt: c.append_system_prompt.as_deref().map(expand_env_vars),
779            temperature: c.temperature,
780            max_tokens: c.max_tokens,
781            effort: c.effort.clone(),
782            // §3.1: sandbox/approval live under `[capabilities.permissions]`,
783            // not `[core]` — module-settings consumption is P3, so this
784            // `[core]`-only resolver leaves them unset.
785            sandbox: None,
786            approval: None,
787            project_context: c.project_context,
788            max_iterations: c.max_iterations,
789            additional_dirs: c
790                .additional_dirs
791                .as_ref()
792                .map(|dirs| dirs.iter().map(|d| expand_env_vars(d)).collect()),
793            compact_after_messages: c.compaction.after_messages,
794            // §3.1: lives under `[capabilities.cache]` — P3 consumption.
795            cache_plan: None,
796            cache_warnings: None,
797            // §3.1: lives under `[capabilities.deferred_tools]` — P3.
798            tool_advertising: None,
799            tool_advertising_core: None,
800            schema_tier: c.tools.schema_tier.clone(),
801            // §3.1: lives under `[capabilities.permissions]` — set by
802            // `materialize_config` after this `[core]`-only resolver runs.
803            auto_approved_tools: None,
804            tool_deny_patterns: None,
805            tool_allow_patterns: None,
806            extra_headers: c.extra_headers.as_ref().map(|headers| {
807                headers
808                    .iter()
809                    .map(|(k, v)| (k.clone(), expand_env_vars(v)))
810                    .collect()
811            }),
812            extra_body: c.extra_body.as_ref().map(|body| {
813                body.iter()
814                    .map(|(k, v)| {
815                        let v = match v {
816                            serde_json::Value::String(s) => {
817                                serde_json::Value::String(expand_env_vars(s))
818                            }
819                            other => other.clone(),
820                        };
821                        (k.clone(), v)
822                    })
823                    .collect()
824            }),
825            max_tool_output_bytes: c.max_tool_output_bytes,
826            max_total_output_tokens: c.max_total_output_tokens,
827            max_budget_usd: c.max_budget_usd,
828            max_steps: c.max_steps,
829            price_input_per_mtok: c.price_input_per_mtok,
830            price_output_per_mtok: c.price_output_per_mtok,
831            prompts: if c.prompts.is_empty() {
832                None
833            } else {
834                Some(
835                    c.prompts
836                        .iter()
837                        .map(|(k, v)| (k.clone(), v.clone()))
838                        .collect(),
839                )
840            },
841            tool_overrides: if tool_overrides.is_empty() {
842                None
843            } else {
844                Some(tool_overrides)
845            },
846            // P4b: obligations 1/4/5/6/7 — see each field's doc comment on
847            // `ConfigProfile`/`Config` for the exact §3.1 key it maps.
848            env_context: c.env_context,
849            project_root_markers: c.project_root_markers.clone(),
850            // BP-4 (§3.1 "0/absent = uncapped"): `0` is the schema's own
851            // spelling for "this preset caps nothing" (cc-parity says it
852            // explicitly — CC documents no byte cap on CLAUDE.md), so it
853            // must NOT reach `Config` as a zero-byte cap that truncates
854            // every instruction file to nothing.
855            project_doc_max_bytes: c.project_doc_max_bytes.filter(|n| *n > 0),
856            project_doc_excludes: c.project_doc_excludes.clone(),
857            project_doc_strip_comments: c.project_doc_strip_comments,
858            instruction_imports: c.instruction_imports,
859            retry_enabled: c.retry.enabled,
860            retry_max_retries: c.retry.max_retries,
861            retry_base_delay_ms: c.retry.base_delay_ms,
862            compaction_reserve_tokens: c.compaction.reserve_tokens.map(|n| n as u64),
863            compaction_keep_recent_tokens: c.compaction.keep_recent_tokens.map(|n| n as u64),
864            compaction_focus_instructions: c.compaction.focus_instructions.clone(),
865            auto_title: c.session.auto_title,
866            steering_mode: c.steering.steering_mode.clone(),
867            follow_up_mode: c.steering.follow_up_mode.clone(),
868            // P4c: obligations 2/4/10 — see each field's doc comment on
869            // `ConfigProfile`/`Config` for the exact §3.1 key it maps.
870            read_file_multimodal: c.tools.read_file.multimodal,
871            // BP-2 (catalog:26/:58): the `cat -n` gutter and the
872            // recoverable tool-output spill door.
873            read_file_line_numbers: c.tools.read_file.line_numbers,
874            tool_output_spill: c.tool_output_spill,
875            edit_file_require_read_before_edit: c.tools.edit_file.require_read_before_edit,
876            edit_file_notebook_aware: c.tools.edit_file.notebook_aware,
877            shell_env_snapshot: c.shell_env_snapshot,
878            doom_loop_threshold: c.doom_loop_threshold,
879            nested_instructions: c.nested_instructions,
880            model_switch_allow_switch: c.model_switch.allow_switch,
881            model_switch_notice: c.model_switch.notice,
882            // P4e: obligations 1/4/5/6 — see each field's doc comment on
883            // `ConfigProfile`/`Config` for the exact §3.1 key it maps.
884            context_injections: c.context_injections,
885            compaction_enabled: c.compaction.enabled,
886            // BP-1: `[core.compaction] summarize` was parsed into
887            // `CoreCompactionConfig` and then dropped on the floor here —
888            // every preset sets it and nothing downstream could ever read
889            // it. Materialized onto `Config::compaction_summarize` now.
890            compaction_summarize: c.compaction.summarize,
891            parallel_tool_calls: c.parallel_tool_calls,
892            session_git_metadata: c.session.git_metadata,
893            session_dir: c.session.dir.clone(),
894            session_persist: c.session.persist,
895            session_name: c.session.name.clone(),
896            session_retention_days: c.session.retention_days,
897            session_export_format: c.session.export_format.clone(),
898            session_append_only: c.session.append_only,
899            session_queue_persist: c.session.queue_persist,
900        }
901    }
902
903    /// Resolve straight into a [`Config`] via
904    /// [`ConfigBuilder::apply_profile`] — a convenience for embedders/tests
905    /// that don't need the intermediate profile. Ignores `extends` (P2) and
906    /// every `[capabilities.*]` module (P3+); P1 is the `[core]` config
907    /// surface only (design §5.2).
908    pub fn resolve_core(&self) -> Config {
909        ConfigBuilder::default()
910            .apply_profile(&self.to_config_profile())
911            .build()
912    }
913
914    /// §3.3 overlay: `over` wins wherever it sets a value. Scalars replace,
915    /// tables merge key-wise (recursively for `[capabilities.*]` settings),
916    /// arrays replace wholesale — the same semantics
917    /// [`ConfigBuilder::apply_profile`] already uses for the `[core]`
918    /// region, generalized here to the whole `HarnessConfig` (§3.5 step 3's
919    /// "fold the chain … with the §3.3 overlay semantics").
920    pub fn overlay(&self, over: &HarnessConfig) -> HarnessConfig {
921        HarnessConfig {
922            schema: over.schema.clone().or_else(|| self.schema.clone()),
923            schema_version: over.schema_version,
924            extends: over.extends.clone().or_else(|| self.extends.clone()),
925            core: merge_core(&self.core, &over.core),
926            capabilities: merge_capabilities(&self.capabilities, &over.capabilities),
927            experimental: {
928                let mut e = self.experimental.clone();
929                merge_json_object(&mut e, &over.experimental);
930                e
931            },
932        }
933    }
934}
935
936macro_rules! merge_opt {
937    ($base:expr, $over:expr, $field:ident) => {
938        $over.$field.clone().or_else(|| $base.$field.clone())
939    };
940}
941
942fn merge_core(base: &CoreSection, over: &CoreSection) -> CoreSection {
943    CoreSection {
944        model: merge_opt!(base, over, model),
945        base_url: merge_opt!(base, over, base_url),
946        api_key_env: merge_opt!(base, over, api_key_env),
947        api_key_cmd: merge_opt!(base, over, api_key_cmd),
948        api_key_command: merge_opt!(base, over, api_key_command),
949        update_check: merge_opt!(base, over, update_check),
950        effort: merge_opt!(base, over, effort),
951        temperature: merge_opt!(base, over, temperature),
952        max_tokens: merge_opt!(base, over, max_tokens),
953        max_iterations: merge_opt!(base, over, max_iterations),
954        max_total_output_tokens: merge_opt!(base, over, max_total_output_tokens),
955        max_budget_usd: merge_opt!(base, over, max_budget_usd),
956        max_steps: merge_opt!(base, over, max_steps),
957        price_input_per_mtok: merge_opt!(base, over, price_input_per_mtok),
958        price_output_per_mtok: merge_opt!(base, over, price_output_per_mtok),
959        max_tool_output_bytes: merge_opt!(base, over, max_tool_output_bytes),
960        parallel_tool_calls: merge_opt!(base, over, parallel_tool_calls),
961        tool_output_spill: merge_opt!(base, over, tool_output_spill),
962        shell_env_snapshot: merge_opt!(base, over, shell_env_snapshot),
963        system_prompt: merge_opt!(base, over, system_prompt),
964        append_system_prompt: merge_opt!(base, over, append_system_prompt),
965        project_context: merge_opt!(base, over, project_context),
966        env_context: merge_opt!(base, over, env_context),
967        context_injections: merge_opt!(base, over, context_injections),
968        nested_instructions: merge_opt!(base, over, nested_instructions),
969        instruction_imports: merge_opt!(base, over, instruction_imports),
970        project_root_markers: merge_opt!(base, over, project_root_markers),
971        hot_reload: merge_opt!(base, over, hot_reload),
972        project_doc_max_bytes: merge_opt!(base, over, project_doc_max_bytes),
973        project_doc_excludes: merge_opt!(base, over, project_doc_excludes),
974        project_doc_strip_comments: merge_opt!(base, over, project_doc_strip_comments),
975        file_mentions: merge_opt!(base, over, file_mentions),
976        output_style: merge_opt!(base, over, output_style),
977        path_rules: merge_opt!(base, over, path_rules),
978        doom_loop_threshold: merge_opt!(base, over, doom_loop_threshold),
979        additional_dirs: merge_opt!(base, over, additional_dirs),
980        extra_headers: match (&base.extra_headers, &over.extra_headers) {
981            (Some(b), Some(o)) => {
982                let mut m = b.clone();
983                m.extend(o.clone());
984                Some(m)
985            }
986            (None, Some(o)) => Some(o.clone()),
987            (b, None) => b.clone(),
988        },
989        extra_body: match (&base.extra_body, &over.extra_body) {
990            (Some(b), Some(o)) => {
991                let mut m = b.clone();
992                for (k, v) in o {
993                    m.insert(k.clone(), v.clone());
994                }
995                Some(m)
996            }
997            (None, Some(o)) => Some(o.clone()),
998            (b, None) => b.clone(),
999        },
1000        model_switch: CoreModelSwitchConfig {
1001            allow_switch: merge_opt!(base.model_switch, over.model_switch, allow_switch),
1002            notice: merge_opt!(base.model_switch, over.model_switch, notice),
1003        },
1004        retry: CoreRetryConfig {
1005            enabled: merge_opt!(base.retry, over.retry, enabled),
1006            max_retries: merge_opt!(base.retry, over.retry, max_retries),
1007            base_delay_ms: merge_opt!(base.retry, over.retry, base_delay_ms),
1008        },
1009        tools: CoreToolsConfig {
1010            enabled: merge_opt!(base.tools, over.tools, enabled),
1011            schema_tier: merge_opt!(base.tools, over.tools, schema_tier),
1012            read_file: ReadFileToolConfig {
1013                multimodal: merge_opt!(base.tools.read_file, over.tools.read_file, multimodal),
1014                line_numbers: merge_opt!(base.tools.read_file, over.tools.read_file, line_numbers),
1015            },
1016            edit_file: EditFileToolConfig {
1017                require_read_before_edit: merge_opt!(
1018                    base.tools.edit_file,
1019                    over.tools.edit_file,
1020                    require_read_before_edit
1021                ),
1022                notebook_aware: merge_opt!(
1023                    base.tools.edit_file,
1024                    over.tools.edit_file,
1025                    notebook_aware
1026                ),
1027                schema_tier: merge_opt!(base.tools.edit_file, over.tools.edit_file, schema_tier),
1028            },
1029            bash: BashToolConfig {
1030                enabled: merge_opt!(base.tools.bash, over.tools.bash, enabled),
1031                description: merge_opt!(base.tools.bash, over.tools.bash, description),
1032                schema_tier: merge_opt!(base.tools.bash, over.tools.bash, schema_tier),
1033                timeout_secs: merge_opt!(base.tools.bash, over.tools.bash, timeout_secs),
1034            },
1035        },
1036        skills: CoreSkillsConfig {
1037            enabled: merge_opt!(base.skills, over.skills, enabled),
1038            dirs: merge_opt!(base.skills, over.skills, dirs),
1039            harness: merge_opt!(base.skills, over.skills, harness),
1040            implicit_match: merge_opt!(base.skills, over.skills, implicit_match),
1041            shell_injection: merge_opt!(base.skills, over.skills, shell_injection),
1042        },
1043        prompts: {
1044            let mut p = base.prompts.clone();
1045            for (k, v) in &over.prompts {
1046                p.insert(k.clone(), v.clone());
1047            }
1048            p
1049        },
1050        compaction: CoreCompactionConfig {
1051            enabled: merge_opt!(base.compaction, over.compaction, enabled),
1052            after_messages: merge_opt!(base.compaction, over.compaction, after_messages),
1053            reserve_tokens: merge_opt!(base.compaction, over.compaction, reserve_tokens),
1054            keep_recent_tokens: merge_opt!(base.compaction, over.compaction, keep_recent_tokens),
1055            summarize: merge_opt!(base.compaction, over.compaction, summarize),
1056            focus_instructions: merge_opt!(base.compaction, over.compaction, focus_instructions),
1057        },
1058        session: CoreSessionConfig {
1059            dir: merge_opt!(base.session, over.session, dir),
1060            name: merge_opt!(base.session, over.session, name),
1061            persist: merge_opt!(base.session, over.session, persist),
1062            retention_days: merge_opt!(base.session, over.session, retention_days),
1063            export_format: merge_opt!(base.session, over.session, export_format),
1064            auto_title: merge_opt!(base.session, over.session, auto_title),
1065            git_metadata: merge_opt!(base.session, over.session, git_metadata),
1066            append_only: merge_opt!(base.session, over.session, append_only),
1067            queue_persist: merge_opt!(base.session, over.session, queue_persist),
1068        },
1069        steering: CoreSteeringConfig {
1070            steering_mode: merge_opt!(base.steering, over.steering, steering_mode),
1071            follow_up_mode: merge_opt!(base.steering, over.steering, follow_up_mode),
1072        },
1073        output: CoreOutputConfig {
1074            format: merge_opt!(base.output, over.output, format),
1075        },
1076    }
1077}
1078
1079/// Recursive key-wise JSON-object merge (§3.3 "tables merge key-wise"):
1080/// nested objects merge recursively; everything else (scalars, arrays)
1081/// replaces wholesale when `over` sets it.
1082fn merge_json_object(
1083    base: &mut serde_json::Map<String, serde_json::Value>,
1084    over: &serde_json::Map<String, serde_json::Value>,
1085) {
1086    for (k, v) in over {
1087        match (base.get_mut(k), v) {
1088            (Some(serde_json::Value::Object(b)), serde_json::Value::Object(o)) => {
1089                merge_json_object(b, o);
1090            }
1091            _ => {
1092                base.insert(k.clone(), v.clone());
1093            }
1094        }
1095    }
1096}
1097
1098/// `[capabilities.*]` merge (§3.3): per capability name, `enabled` replaces
1099/// and `settings` merges key-wise recursively (via `merge_json_object`) —
1100/// this is what lets `extends = "cc-parity"` plus a single
1101/// `capabilities.permissions.approval = "…"` override win without clobbering
1102/// the rest of the preset's `permissions` table (design §3.5 closing:
1103/// "per-key override layering means a preset is never all-or-nothing").
1104fn merge_capabilities(
1105    base: &BTreeMap<String, CapabilityConfig>,
1106    over: &BTreeMap<String, CapabilityConfig>,
1107) -> BTreeMap<String, CapabilityConfig> {
1108    let mut out = base.clone();
1109    for (name, ov) in over {
1110        match out.get_mut(name) {
1111            Some(existing) => {
1112                existing.enabled = ov.enabled.or(existing.enabled);
1113                merge_json_object(&mut existing.settings, &ov.settings);
1114            }
1115            None => {
1116                out.insert(name.clone(), ov.clone());
1117            }
1118        }
1119    }
1120    out
1121}
1122
1123/// Merge the project-layer reduction module without allowing an untrusted
1124/// repository to widen an explicit trusted disable. Reduction is the one
1125/// capability a project may enable when the trusted layer is silent, but an
1126/// explicit `false` on either the module master switch or a documented pass
1127/// gate is narrowing and therefore dominates `true` from the other layer.
1128/// Settings still deep-merge so a sibling project key cannot discard trusted
1129/// gates that it did not mention.
1130pub fn merge_reduction_capability(
1131    trusted: Option<&CapabilityConfig>,
1132    project: Option<&CapabilityConfig>,
1133) -> Option<CapabilityConfig> {
1134    fn narrowing_bool(trusted: Option<bool>, project: Option<bool>) -> Option<bool> {
1135        match (trusted, project) {
1136            (Some(false), _) | (_, Some(false)) => Some(false),
1137            (_, Some(true)) => Some(true),
1138            (Some(true), None) => Some(true),
1139            (None, None) => None,
1140        }
1141    }
1142
1143    const BOOLEAN_GATES: &[&str] = &[
1144        "stale_reads",
1145        "diff_reads",
1146        "duplicates",
1147        "tool_input_elision",
1148        "supersede",
1149        "normalize_output",
1150        "image_redaction",
1151        "span_summaries",
1152        "handoff",
1153    ];
1154
1155    match (trusted, project) {
1156        (None, None) => None,
1157        (Some(t), None) => Some(t.clone()),
1158        (None, Some(p)) => Some(p.clone()),
1159        (Some(t), Some(p)) => {
1160            let mut merged = t.clone();
1161            merged.enabled = narrowing_bool(t.enabled, p.enabled);
1162            merge_json_object(&mut merged.settings, &p.settings);
1163            for key in BOOLEAN_GATES {
1164                let trusted_value = t.settings.get(*key).and_then(|v| v.as_bool());
1165                let project_value = p.settings.get(*key).and_then(|v| v.as_bool());
1166                if let Some(value) = narrowing_bool(trusted_value, project_value) {
1167                    merged
1168                        .settings
1169                        .insert((*key).to_string(), serde_json::Value::Bool(value));
1170                }
1171            }
1172            Some(merged)
1173        }
1174    }
1175}
1176
1177/// Read a nested string-array setting by dotted PATH segments (e.g.
1178/// `&["rules", "deny"]`, `&["rules", "ask"]`, `&["protected_paths",
1179/// "paths"]`) — shared by [`merge_permissions_capability`] below. Generalizes
1180/// the P4a `deny_array` helper (originally hardcoded to `rules.deny` alone)
1181/// so the P5-1 `rules.ask`/`protected_paths.paths` siblings can reuse the
1182/// SAME union-not-replace project-merge protection — see that function's
1183/// doc comment on why a bare array-replace is unsafe for any of these three.
1184fn nested_str_array(
1185    settings: &serde_json::Map<String, serde_json::Value>,
1186    path: &[&str],
1187) -> Vec<String> {
1188    let Some((last, dirs)) = path.split_last() else {
1189        return Vec::new();
1190    };
1191    let mut cur = settings;
1192    for seg in dirs {
1193        match cur.get(*seg).and_then(|v| v.as_object()) {
1194            Some(m) => cur = m,
1195            None => return Vec::new(),
1196        }
1197    }
1198    cur.get(*last)
1199        .and_then(|v| v.as_array())
1200        .map(|a| {
1201            a.iter()
1202                .filter_map(|x| x.as_str().map(String::from))
1203                .collect()
1204        })
1205        .unwrap_or_default()
1206}
1207
1208/// Overwrite the nested string-array setting at `path` (creating
1209/// intermediate tables as needed) — the write-side counterpart of
1210/// [`nested_str_array`].
1211fn set_nested_str_array(
1212    settings: &mut serde_json::Map<String, serde_json::Value>,
1213    path: &[&str],
1214    value: Vec<String>,
1215) {
1216    let Some((last, dirs)) = path.split_last() else {
1217        return;
1218    };
1219    let mut cur = settings;
1220    for seg in dirs {
1221        let entry = cur
1222            .entry((*seg).to_string())
1223            .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
1224        if !entry.is_object() {
1225            *entry = serde_json::Value::Object(serde_json::Map::new());
1226        }
1227        cur = entry.as_object_mut().expect("just ensured object above");
1228    }
1229    cur.insert(
1230        (*last).to_string(),
1231        serde_json::Value::Array(value.into_iter().map(serde_json::Value::String).collect()),
1232    );
1233}
1234
1235/// Union two arrays read via [`nested_str_array`] and write the result back
1236/// via [`set_nested_str_array`] — a project may only ADD entries at `path`,
1237/// never remove or shrink the trusted layer's (see
1238/// [`merge_permissions_capability`]'s doc comment for the argument that this
1239/// is safe/narrowing for `rules.deny`, `rules.ask`, and
1240/// `protected_paths.paths` alike: an entry at any of these three can only
1241/// make a decision STRICTER, never looser, so a project adding one is
1242/// always legal, and a project silently REMOVING one via array-replace is
1243/// exactly the widening this closes). No-op (skips the write) when both
1244/// sides are empty, so a `HarnessConfig` with no permissions table at all
1245/// round-trips with zero spurious `rules`/`protected_paths` tables created.
1246fn union_nested_str_array(
1247    trusted: &serde_json::Map<String, serde_json::Value>,
1248    project: &serde_json::Map<String, serde_json::Value>,
1249    merged: &mut serde_json::Map<String, serde_json::Value>,
1250    path: &[&str],
1251) {
1252    let trusted_vals = nested_str_array(trusted, path);
1253    let project_vals = nested_str_array(project, path);
1254    if trusted_vals.is_empty() && project_vals.is_empty() {
1255        return;
1256    }
1257    let mut union = trusted_vals;
1258    for v in project_vals {
1259        if !union.contains(&v) {
1260            union.push(v);
1261        }
1262    }
1263    set_nested_str_array(merged, path, union);
1264}
1265
1266/// Merge a project-layer `capabilities.permissions` table onto the trusted
1267/// (user/global) layer's — the single canonical merge BOTH the CLI route
1268/// (`crates/cli/src/userconfig.rs::overlay_project`) and this core resolver
1269/// (`resolve_top`, below) call, so the two routes cannot diverge the way the
1270/// independent Fable-5 review of P4a found (proven attacks, both against the
1271/// hard approval floor `Config::needs_approval` gives `rules.deny` — true
1272/// even under `ApprovalPolicy::Never`):
1273///
1274/// - **Attack A (whole-table replace):** a per-capability `insert` (what the
1275///   CLI's `overlay_project` used to do, and what a naive per-name merge
1276///   would still do here) lets a hostile project's `[capabilities.
1277///   permissions]` table — even one `sanitize_for_project`/
1278///   `sanitized_for_project` strips down to an EMPTY table because every key
1279///   it set was forbidden — wholesale REPLACE the trusted layer's populated
1280///   table, silently wiping `rules.deny` and everything else the user set.
1281///   Fixed by deep-merging into a CLONE of the trusted table (via
1282///   `merge_json_object`) rather than ever substituting the project's.
1283/// - **Attack B (array-replace widens deny):** `merge_json_object`'s "arrays
1284///   replace wholesale" rule (§3.3 "tables merge key-wise… arrays replace")
1285///   is correct for `rules.allow` (a widening `allow` is already stripped
1286///   from a sanitized project layer by P1/P4a) but WRONG for `rules.deny`: a
1287///   project's own `deny = […]` would otherwise REPLACE, not add to, the
1288///   trusted layer's list — e.g. user `deny = ["bash*"]` + project
1289///   `deny = ["harmless*"]` merging to `["harmless*"]` is a real widening
1290///   (the floor that blocks `bash*` vanishes). Fixed by unioning
1291///   `rules.deny` explicitly after the deep merge: a project may only ADD
1292///   deny entries, never remove or shrink the trusted layer's — deny
1293///   strictly grows.
1294/// - **Attack B', P5-1 extension:** the identical array-replace hazard
1295///   applies to TWO more keys the P5-1 permissions engine newly consumes:
1296///   `rules.ask` (module 11) and `protected_paths.paths` (module 13). Both
1297///   are narrowing-only by the SAME argument as `deny` — an `ask` entry can
1298///   only make a decision STRICTER (it is checked before `allow`, and can
1299///   never override a `deny`), and a protected path is an unconditional
1300///   deny floor for read+write — so a project may only ADD to either, never
1301///   silently wipe the trusted layer's via `protected_paths.paths = []`/
1302///   `rules.ask = []`. Fixed the same way: union both, right alongside
1303///   `rules.deny`, immediately below.
1304///
1305/// `rules.allow` and every other key keep plain deep-merge/replace
1306/// semantics: this function does not re-derive the sanitizer's trust
1307/// decisions (that's `sanitize_for_project`/`sanitized_for_project`'s job),
1308/// it only guarantees the MERGE step can't reintroduce a widening those
1309/// sanitizers already ruled out.
1310///
1311/// No behavior change for the common case: with no project `permissions`
1312/// table, this returns the trusted layer's table unchanged.
1313pub fn merge_permissions_capability(
1314    trusted: Option<&CapabilityConfig>,
1315    project: Option<&CapabilityConfig>,
1316) -> Option<CapabilityConfig> {
1317    match (trusted, project) {
1318        (None, None) => None,
1319        (Some(t), None) => Some(t.clone()),
1320        (None, Some(p)) => Some(p.clone()),
1321        (Some(t), Some(p)) => {
1322            let mut merged = t.clone();
1323            merged.enabled = p.enabled.or(t.enabled);
1324            merge_json_object(&mut merged.settings, &p.settings);
1325            // CRITICAL fix (P5-10 security reopen): `merge_json_object`'s
1326            // generic type-mismatch rule ("everything else replaces
1327            // wholesale when `over` sets it") is UNSAFE specifically for
1328            // `sandbox`, because the bare-string shorthand `sandbox = "X"`
1329            // is §3.1-defined as identical to the table form `sandbox =
1330            // { tier = "X" }`. When the trusted layer used the bare form and
1331            // the project supplied the table form (now a normal,
1332            // non-adversarial shape since P5-10's `escalation`/`env_policy`/
1333            // `network` subkeys live only in the table), the generic merge
1334            // above REPLACED the trusted string wholesale with the
1335            // project's object — even a project object with NO `tier` at
1336            // all (either because a hostile `tier` was already stripped by
1337            // `sanitize_for_project`, or because the project only set a
1338            // benign subkey like `env_policy`) — silently erasing the base
1339            // tier and falling back to the `DangerFullAccess` default with
1340            // no warning. Recompute `sandbox` via [`merge_sandbox_value`],
1341            // which normalizes BOTH sides to canonical table form before
1342            // deep-merging, so a tier-less project overlay can never erase
1343            // the base's tier.
1344            match merge_sandbox_value(t.settings.get("sandbox"), p.settings.get("sandbox")) {
1345                Some(v) => {
1346                    merged.settings.insert("sandbox".to_string(), v);
1347                }
1348                None => {
1349                    merged.settings.remove("sandbox");
1350                }
1351            }
1352            union_nested_str_array(
1353                &t.settings,
1354                &p.settings,
1355                &mut merged.settings,
1356                &["rules", "deny"],
1357            );
1358            union_nested_str_array(
1359                &t.settings,
1360                &p.settings,
1361                &mut merged.settings,
1362                &["rules", "ask"],
1363            );
1364            union_nested_str_array(
1365                &t.settings,
1366                &p.settings,
1367                &mut merged.settings,
1368                &["protected_paths", "paths"],
1369            );
1370            Some(merged)
1371        }
1372    }
1373}
1374
1375/// Canonicalize + deep-merge the `capabilities.permissions.sandbox` value
1376/// across the trusted/project layers — the type-safe replacement for
1377/// running it through the generic `merge_json_object` (see
1378/// [`merge_permissions_capability`]'s doc comment on the CRITICAL P5-10
1379/// security-reopen fix this closes). §3.1 defines the bare-string shorthand
1380/// `sandbox = "X"` as identical to the table form `sandbox = { tier = "X" }`
1381/// — this function normalizes BOTH sides to that table form first, then
1382/// deep-merges key-wise, so:
1383///
1384/// - a trusted bare-string tier survives a project table overlay that omits
1385///   `tier` entirely (the silent-widen-to-`DangerFullAccess` hole);
1386/// - a project's own `tier`/`escalation`/`env_policy`/`network`/`enabled`
1387///   subkeys still take effect and are still subject to
1388///   [`clamp_project_permissions`]'s separate rank-vs-base-layer clamp
1389///   below (this function only fixes the MERGE representation, not the
1390///   monotonic-tightening policy decision).
1391fn merge_sandbox_value(
1392    base: Option<&serde_json::Value>,
1393    project: Option<&serde_json::Value>,
1394) -> Option<serde_json::Value> {
1395    fn to_table(v: &serde_json::Value) -> serde_json::Map<String, serde_json::Value> {
1396        match v {
1397            serde_json::Value::String(s) => {
1398                let mut m = serde_json::Map::new();
1399                m.insert("tier".to_string(), serde_json::Value::String(s.clone()));
1400                m
1401            }
1402            serde_json::Value::Object(o) => o.clone(),
1403            _ => serde_json::Map::new(),
1404        }
1405    }
1406    match (base, project) {
1407        (None, None) => None,
1408        (Some(b), None) => Some(b.clone()),
1409        (None, Some(p)) => Some(p.clone()),
1410        (Some(b), Some(p)) => {
1411            let mut merged = to_table(b);
1412            let proj_table = to_table(p);
1413            merge_json_object(&mut merged, &proj_table);
1414            Some(serde_json::Value::Object(merged))
1415        }
1416    }
1417}
1418
1419// ---------------------------------------------------------------------------
1420// §3.3 project sanitization for `HarnessConfig` (P2's resolver-native mirror
1421// of `crates/cli/src/userconfig.rs`'s `sanitized_for_project` — that
1422// function keeps gating the CLI's existing `FileConfig`-based `load()` path
1423// unchanged; this is the parallel, additive rule for the new
1424// `HarnessConfig`-based §3.5 resolver, same monotonic-tightening contract:
1425// "a project file may only NARROW the harness, never widen or redirect it"
1426// (§3.3), sanitize-before-merge (§3.5 step 4).
1427// ---------------------------------------------------------------------------
1428
1429/// Parse a `sandbox` string the same way
1430/// `crates/cli/src/main.rs::parse_sandbox` does (alias-normalizing:
1431/// `_`/`-`/case-insensitive, `full` as a `danger_full_access` alias) — a
1432/// small, deliberate duplication rather than a cross-crate dependency (`cli`
1433/// already depends on `core`, not the reverse), documented here so the two
1434/// copies can be kept in lock-step if the alias set ever changes.
1435pub(crate) fn parse_sandbox_str(s: &str) -> Option<SandboxPolicy> {
1436    match s.replace('_', "-").to_ascii_lowercase().as_str() {
1437        "read-only" | "readonly" => Some(SandboxPolicy::ReadOnly),
1438        "workspace-write" | "workspace" => Some(SandboxPolicy::WorkspaceWrite),
1439        "danger-full-access" | "full" => Some(SandboxPolicy::DangerFullAccess),
1440        _ => None,
1441    }
1442}
1443
1444/// Parse an `approval` string, same alias treatment as [`parse_sandbox_str`].
1445/// P5-1: `"model_requested"` is now a REAL, recognized fourth
1446/// [`ApprovalPolicy`] variant (design §3.2 S8, built this unit) — cx-parity
1447/// resolves to its intended posture instead of the pre-P5-1 fail-safe to
1448/// [`ApprovalPolicy::Untrusted`]. Any OTHER unrecognized string still fails
1449/// safe to `Untrusted`, never silently to `Never` (the existing
1450/// `apply_profile` precedent, config.rs). The §2.2 C6 check ALSO reads the
1451/// RAW string directly (not through this parser) for its own
1452/// `"model_requested"` judgment-call diagnostic — see `validate_modules`;
1453/// that check is unaffected by this change (it never depended on this
1454/// parser returning `None`).
1455pub(crate) fn parse_approval_str(s: &str) -> Option<ApprovalPolicy> {
1456    match s.replace('_', "-").to_ascii_lowercase().as_str() {
1457        "never" => Some(ApprovalPolicy::Never),
1458        "on-request" | "onrequest" => Some(ApprovalPolicy::OnRequest),
1459        "untrusted" => Some(ApprovalPolicy::Untrusted),
1460        "model-requested" | "modelrequested" => Some(ApprovalPolicy::ModelRequested),
1461        _ => None,
1462    }
1463}
1464
1465/// §3.3: the loosest possible sandbox value — the one a project file may
1466/// never set (tightening to anything else is legal).
1467fn is_loosening_sandbox_str(s: &str) -> bool {
1468    parse_sandbox_str(s) == Some(SandboxPolicy::DangerFullAccess)
1469}
1470
1471/// §3.3: the loosest possible approval value.
1472fn is_loosening_approval_str(s: &str) -> bool {
1473    parse_approval_str(s) == Some(ApprovalPolicy::Never)
1474}
1475
1476/// Strictness rank — LOWER is stricter (§3.3's explicit order, same as
1477/// `userconfig.rs::sandbox_rank`).
1478pub(crate) fn sandbox_rank(p: SandboxPolicy) -> u8 {
1479    match p {
1480        SandboxPolicy::ReadOnly => 0,
1481        SandboxPolicy::WorkspaceWrite => 1,
1482        SandboxPolicy::DangerFullAccess => 2,
1483    }
1484}
1485
1486/// Strictness rank — LOWER is stricter (§3.3's explicit order, same as
1487/// `userconfig.rs::approval_rank`, which is intentionally NOT updated for
1488/// `ModelRequested` — see `parse_approval_str`'s doc comment on the CLI
1489/// crate being out of this unit's scope; the CLI's own copy simply never
1490/// parses the string, so it never reaches this rank at all).
1491///
1492/// P5-1: `ModelRequested` sits BETWEEN `OnRequest` and `Never` — it is not
1493/// the absolute floor `Never` is (under `Never` literally nothing is ever
1494/// asked; under `ModelRequested` an escalation attempt still can be, per
1495/// `ApprovalPolicy::ModelRequested`'s doc comment on Codex's real posture),
1496/// but it prompts less often in practice than `OnRequest`'s client-side
1497/// allowlist check. This keeps `Never` the one value §3.3's monotonic clamp
1498/// (`is_loosening_approval_str`) singles out as the absolute forbidden
1499/// floor.
1500pub(crate) fn approval_rank(p: ApprovalPolicy) -> u8 {
1501    match p {
1502        ApprovalPolicy::Untrusted => 0,
1503        ApprovalPolicy::OnRequest => 1,
1504        ApprovalPolicy::ModelRequested => 2,
1505        ApprovalPolicy::Never => 3,
1506    }
1507}
1508
1509/// Read `capabilities.permissions`'s effective sandbox setting — either the
1510/// bare-string shorthand (`capabilities.permissions.sandbox = "…"`) or the
1511/// table form's `tier` (`capabilities.permissions.sandbox.tier = "…"`, §3.1
1512/// module 12). Returns the RAW string (not yet parsed), for sanitization and
1513/// C6 diagnostics.
1514fn permissions_sandbox_raw(hc: &HarnessConfig) -> Option<String> {
1515    let cap = hc.capabilities.get("permissions")?;
1516    match cap.settings.get("sandbox")? {
1517        serde_json::Value::String(s) => Some(s.clone()),
1518        serde_json::Value::Object(o) => o.get("tier").and_then(|v| v.as_str()).map(String::from),
1519        _ => None,
1520    }
1521}
1522
1523/// Read `capabilities.permissions.approval`'s raw string value.
1524fn permissions_approval_raw(hc: &HarnessConfig) -> Option<String> {
1525    hc.capabilities
1526        .get("permissions")
1527        .and_then(|cap| cap.settings.get("approval"))
1528        .and_then(|v| v.as_str())
1529        .map(String::from)
1530}
1531
1532/// The effective [`SandboxPolicy`] `capabilities.permissions` resolves to —
1533/// [`SandboxPolicy::DangerFullAccess`] (the [`Config::default`] floor,
1534/// config.rs) when unset or unparseable, matching `apply_profile`'s
1535/// fail-safe-to-`ReadOnly` precedent is intentionally NOT reused here: C3
1536/// (§2.2) needs the ACTUAL default posture (today's `danger_full_access`,
1537/// tools/mod.rs:40-42), not a hypothetical safe fallback, to detect the real
1538/// exposure a bare `supercode` invocation has.
1539fn effective_sandbox(hc: &HarnessConfig) -> SandboxPolicy {
1540    permissions_sandbox_raw(hc)
1541        .as_deref()
1542        .and_then(parse_sandbox_str)
1543        .unwrap_or(SandboxPolicy::DangerFullAccess)
1544}
1545
1546/// The effective [`ApprovalPolicy`] `capabilities.permissions` resolves to —
1547/// [`ApprovalPolicy::Never`] (the [`Config::default`] floor) when unset,
1548/// same rationale as [`effective_sandbox`]. P5-1: cx-parity's
1549/// `"model_requested"` is now a recognized value (resolves to
1550/// [`ApprovalPolicy::ModelRequested`]); any OTHER unparseable-but-present
1551/// value still fails safe to [`ApprovalPolicy::Untrusted`], matching
1552/// `apply_profile`'s precedent.
1553fn effective_approval(hc: &HarnessConfig) -> ApprovalPolicy {
1554    match permissions_approval_raw(hc) {
1555        None => ApprovalPolicy::Never,
1556        Some(raw) => parse_approval_str(&raw).unwrap_or(ApprovalPolicy::Untrusted),
1557    }
1558}
1559
1560/// Capability tables a project file may never set AT ALL (§3.3): arbitrary
1561/// command execution, config-borne code execution, or a listener.
1562///
1563/// P5-12 (§2 module 14 `trust`, D-10): `trust` joined this list alongside
1564/// its own dependents (`hooks`/`plugins`) — a project asserting its OWN
1565/// trust level (e.g. `[capabilities.trust] default = "always"`) would
1566/// self-declare the exact gate D-10 exists to keep out of an untrusted
1567/// repo's hands, defeating the entire point. Only the user/global layer (or
1568/// a preset extended from it) may ever decide this.
1569const PROJECT_FORBIDDEN_CAPABILITY_TABLES: &[&str] =
1570    &["hooks", "plugins", "server", "integrations", "trust"];
1571
1572/// Capability names a project file may flip `enabled = true` on by default
1573/// (§3.3 S9 "Default disposition"): narrows-only, never spends or widens.
1574const PROJECT_ALLOWED_CAPABILITY_ENABLE: &[&str] = &["reduction"];
1575
1576/// LOW-1 (Fable-5 P4a review): is `d` a `core.additional_dirs` entry a
1577/// PROJECT layer is allowed to add? Rejects anything that could resolve
1578/// outside the repo root: absolute paths, `~`-relative paths, any path with
1579/// a `..` component, and any `${VAR}` env-expansion (unbounded — the
1580/// variable could hold anything, including an absolute path elsewhere on
1581/// disk). A relative path with no `..` segments always stays under the
1582/// directory it's resolved against, so it's safe to add.
1583fn is_safe_project_dir(d: &str) -> bool {
1584    // BP-9: `{file:…}` joins `${VAR}` on the rejected list for exactly the
1585    // same reason — its expansion is unbounded (the file's contents could
1586    // be any absolute path), and a project layer must never be able to make
1587    // the harness READ an arbitrary file just by naming it here.
1588    if d.contains("${") || d.contains(FILE_REF_PREFIX) {
1589        return false;
1590    }
1591    if d.starts_with('~') {
1592        return false;
1593    }
1594    let path = std::path::Path::new(d);
1595    if path.is_absolute() {
1596        return false;
1597    }
1598    !path
1599        .components()
1600        .any(|c| matches!(c, std::path::Component::ParentDir))
1601}
1602
1603/// §3.3's monotonic-tightening rule for a project-layer `HarnessConfig`:
1604/// strip/narrow everything an untrusted repo must not control, recording
1605/// what it touched. Mirrors `userconfig.rs::sanitized_for_project`'s
1606/// contract on the new unified schema (see the module note above).
1607pub fn sanitize_for_project(hc: &HarnessConfig) -> (HarnessConfig, Vec<String>) {
1608    let mut dropped = Vec::new();
1609    let mut out = hc.clone();
1610
1611    if out.core.base_url.take().is_some() {
1612        dropped.push("core.base_url".to_string());
1613    }
1614    if out.core.api_key_env.take().is_some() {
1615        dropped.push("core.api_key_env".to_string());
1616    }
1617    if out.core.api_key_cmd.take().is_some() {
1618        dropped.push("core.api_key_cmd".to_string());
1619    }
1620    // BP-9: the argv credential helper is the same trust class as the shell
1621    // one above — an untrusted repo must never choose the program whose
1622    // stdout becomes your API key.
1623    if out.core.api_key_command.take().is_some() {
1624        dropped.push("core.api_key_command".to_string());
1625    }
1626    // BP-9: `update_check` reaches the network at startup. A repo turning
1627    // that on for you is a (narrow) beacon, and §3.3's rule is that a
1628    // project layer may only ever NARROW — so it may not enable it. It may
1629    // still turn it OFF (the `Some(false)` case falls through untouched).
1630    if out.core.update_check == Some(true) {
1631        out.core.update_check = None;
1632        dropped.push("core.update_check".to_string());
1633    }
1634    if out.core.extra_headers.take().is_some() {
1635        dropped.push("core.extra_headers".to_string());
1636    }
1637    if out.core.extra_body.take().is_some() {
1638        dropped.push("core.extra_body".to_string());
1639    }
1640    if out.core.system_prompt.take().is_some() {
1641        dropped.push("core.system_prompt".to_string());
1642    }
1643    if out.core.append_system_prompt.take().is_some() {
1644        dropped.push("core.append_system_prompt".to_string());
1645    }
1646    // P4b: `focus_instructions` is free text injected into conversation
1647    // history as a system-authored marker every time compaction fires,
1648    // visible to and steering the model — the exact same prompt-injection
1649    // risk class as `system_prompt`/`append_system_prompt` above (§3.3), so
1650    // it gets the same treatment even though the REST of `[core.compaction]`
1651    // (enabled/after_messages/reserve_tokens/keep_recent_tokens/summarize)
1652    // is narrowing-only and stays project-legal.
1653    if out.core.compaction.focus_instructions.take().is_some() {
1654        dropped.push("core.compaction.focus_instructions".to_string());
1655    }
1656    // BP-4: `core.project_doc_excludes` decides WHICH instruction files
1657    // reach the system prompt, including the user's own trusted global
1658    // tier (`~/.config/supercode/CLAUDE.md`) — a project layer that could
1659    // set it would be able to SUPPRESS the user's standing instructions
1660    // and leave only its own repo-authored ones, which is the
1661    // prompt-injection trust boundary above by subtraction rather than
1662    // addition. Same treatment; the byte cap and comment strip stay
1663    // project-legal (both only ever REMOVE repo-authored content).
1664    if out.core.project_doc_excludes.take().is_some() {
1665        dropped.push("core.project_doc_excludes".to_string());
1666    }
1667    // MEDIUM (independent Fable-5 review of P4d): `core.prompts` is merged
1668    // onto the built-in/user prompt table KEY-WISE by
1669    // `ConfigBuilder::apply_profile` (see `CoreConfig::prompts`'s doc
1670    // comment above), not appended — so unlike `additional_dirs` below,
1671    // there is no "safe, narrowing" entry to keep. A project layer setting
1672    // `[core.prompts]\ncode-review = "malicious {args}"` doesn't just ADD a
1673    // new `/name` prompt, it OVERWRITES a trusted built-in (or user-set)
1674    // prompt template outright, silently substituting attacker text into
1675    // the user's own `/code-review` invocation. Same prompt-injection trust
1676    // boundary as `system_prompt`/`append_system_prompt`/
1677    // `compaction.focus_instructions` above (§3.3) — strip the WHOLE table,
1678    // project-forbidden, fail-closed. Only the user/global layer may set
1679    // prompt templates.
1680    if !out.core.prompts.is_empty() {
1681        out.core.prompts.clear();
1682        dropped.push("core.prompts".to_string());
1683    }
1684
1685    // LOW (security, independent Fable-5 review of P4e): `[core.session]`'s
1686    // OPERATIONAL fields steer WHERE/WHAT/HOW the trusted session store
1687    // behaves, not just this conversation's content — a different trust
1688    // class than a narrowing-only knob. `dir` redirects every session-
1689    // transcript WRITE `run`/`chat` performs to an arbitrary path (repo sets
1690    // `dir = "/tmp/evil"` or anywhere the process can write — exfil, or an
1691    // overwrite of another session's files); `retention_days` steers what
1692    // `sessions prune` PERMANENTLY DELETES (a repo could set it to `1` to
1693    // quietly shred the user's session history, or the reviewer's own
1694    // "retention_days=0 project-forbidden" scenario to try to disable
1695    // pruning entirely — either way, deletion policy is not a repo's call).
1696    // `name`/`persist`/`export_format`/`git_metadata` ride along in the same
1697    // strip: none of them narrow anything either (a repo picking the
1698    // session's name, whether it's written to disk at all, its export
1699    // shape, or whether git provenance is captured is all still "the repo
1700    // steering the trusted store", not "the repo asking for less"). Only
1701    // `auto_title` is left alone: it can only change a title STRING
1702    // attached to a session that already lives under the user's own store
1703    // at a path/name the user (or the user/global layer) controls — no
1704    // path redirection, no deletion, no capability widening — so it stays
1705    // on the Project-ALLOWED side of the monotonic-tightening line. Same
1706    // one-shot-warning pattern (`dropped`) as every other stripped key
1707    // above; user/global layers keep full control of all of `core.session`.
1708    if out.core.session.dir.take().is_some() {
1709        dropped.push("core.session.dir".to_string());
1710    }
1711    if out.core.session.name.take().is_some() {
1712        dropped.push("core.session.name".to_string());
1713    }
1714    if out.core.session.persist.take().is_some() {
1715        dropped.push("core.session.persist".to_string());
1716    }
1717    if out.core.session.retention_days.take().is_some() {
1718        dropped.push("core.session.retention_days".to_string());
1719    }
1720    if out.core.session.export_format.take().is_some() {
1721        dropped.push("core.session.export_format".to_string());
1722    }
1723    if out.core.session.git_metadata.take().is_some() {
1724        dropped.push("core.session.git_metadata".to_string());
1725    }
1726
1727    // LOW-1 (Fable-5 P4a review): `core.additional_dirs` is `${VAR}`-expanded
1728    // unconditionally at `to_config_profile` time with no upper bound on
1729    // where the expansion can point — the doc comment on the field itself
1730    // (`additional_dirs: Option<Vec<String>>` above) says "not enforced
1731    // here… enforced [downstream]", but nothing downstream actually enforced
1732    // it either, so a project layer could set `additional_dirs =
1733    // ["${HOME}/.ssh"]` (or a bare `/etc`, or `../../etc`) and escape the
1734    // repo root entirely. §3.3: a project file "may only ADD under the repo
1735    // root" — since this resolver works over raw TOML text with no
1736    // filesystem root of its own to check against, that's enforced
1737    // structurally: reject any entry that's absolute, starts with `~`,
1738    // contains a `..` component, or contains `${` (any env-expansion is
1739    // unbounded, so it's treated the same as "escaping outside root"). The
1740    // user/global layer is unrestricted (same trust boundary as `sandbox`/
1741    // `approval`: only the untrusted project layer is clamped).
1742    if let Some(dirs) = &out.core.additional_dirs {
1743        let (kept, rejected): (Vec<String>, Vec<String>) =
1744            dirs.iter().cloned().partition(|d| is_safe_project_dir(d));
1745        if !rejected.is_empty() {
1746            dropped.push(format!("core.additional_dirs ({})", rejected.join(", ")));
1747            out.core.additional_dirs = if kept.is_empty() { None } else { Some(kept) };
1748        }
1749    }
1750
1751    // `extends`: a built-in NAME stays legal; anything else is treated as a
1752    // path — "a repo-supplied preset file is config injection through the
1753    // back door" (§3.3). A whitelist membership check against the six
1754    // reserved names (rather than the CLI's path-shaped-string heuristic)
1755    // means nothing can slip through as "not a path" that isn't actually a
1756    // known preset.
1757    if let Some(e) = &out.extends {
1758        if crate::presets::lookup(e).is_none() {
1759            dropped.push("extends (path)".to_string());
1760            out.extends = None;
1761        }
1762    }
1763
1764    // LOW-1 (independent Fable-5 review of P3): `[experimental]` is a
1765    // mode-switching table (§5.3 risk 2's `module_registry` gate, and any
1766    // future flag added under it), not a plain settings table — today it
1767    // happens to be narrowing-only (`module_registry` off is always safe),
1768    // but §3.3's monotonic-tightening principle wants project configs
1769    // categorically unable to toggle experimental/mode-switching behavior,
1770    // since a LATER flag added under this table might not be
1771    // narrowing-only. Strip the WHOLE table (not a per-key allow/deny like
1772    // `capabilities.permissions` above) — same fail-closed posture as
1773    // `hooks`/`plugins`/`server`/`integrations`: experimental gates are
1774    // user/global-layer only.
1775    if !out.experimental.is_empty() {
1776        out.experimental.clear();
1777        dropped.push("experimental".to_string());
1778    }
1779
1780    for name in PROJECT_FORBIDDEN_CAPABILITY_TABLES {
1781        if out.capabilities.remove(*name).is_some() {
1782            dropped.push(format!("capabilities.{name}"));
1783        }
1784    }
1785
1786    if let Some(cap) = out.capabilities.get_mut("mcp") {
1787        if cap.settings.remove("servers").is_some() {
1788            dropped.push("capabilities.mcp.servers".to_string());
1789        }
1790        if matches!(
1791            cap.settings.get("serve"),
1792            Some(serde_json::Value::Bool(true))
1793        ) {
1794            cap.settings.remove("serve");
1795            dropped.push("capabilities.mcp.serve".to_string());
1796        }
1797    }
1798
1799    if let Some(cap) = out.capabilities.get_mut("notify") {
1800        if cap.settings.remove("email").is_some() {
1801            dropped.push("capabilities.notify.email".to_string());
1802        }
1803    }
1804
1805    // BP-13 (§3.3 monotonic tightening, catalog D9 "Org model allowlists /
1806    // effort caps"): the model RESTRICTION keys are stripped from a project
1807    // layer, per key rather than by forbidding the whole table — a repo may
1808    // still declare its own aliases and per-model rules (narrowing, or
1809    // simply naming), but it can never widen or lift a restriction the
1810    // user/global layer set, which is the only thing that makes such a
1811    // restriction worth setting.
1812    if let Some(cap) = out.capabilities.get_mut("model_catalog") {
1813        for key in ["allowed_models", "denied_models", "max_effort"] {
1814            if cap.settings.remove(key).is_some() {
1815                dropped.push(format!("capabilities.model_catalog.{key}"));
1816            }
1817        }
1818    }
1819
1820    // P5-11 (§2 module 28 `lsp`, D-10): `capabilities.lsp.servers.*` is
1821    // config-borne code execution (a `command`/`args` pair a project file
1822    // could point at anything on `PATH`) — the exact same injection class
1823    // as `capabilities.mcp.servers` just above, so it gets the identical
1824    // strip-the-whole-table treatment regardless of `enabled` (the generic
1825    // default-disposition loop below already blocks a project file from
1826    // flipping `enabled = true` at all, since `lsp` isn't on the
1827    // Project-ALLOWED list — this additionally blocks server DEFINITIONS
1828    // from ever reaching a base layer that already has `enabled = true`,
1829    // e.g. from `oc-parity`).
1830    if let Some(cap) = out.capabilities.get_mut("lsp") {
1831        if cap.settings.remove("servers").is_some() {
1832            dropped.push("capabilities.lsp.servers".to_string());
1833        }
1834    }
1835
1836    // P5-11 (§2 module 29 `formatters`, D-10, C10 sibling): every key
1837    // under `capabilities.formatters` OTHER than the two recognized
1838    // scalars (`diff_back`/`timeout_secs`) is a formatter DEFINITION —
1839    // `command`/`args`, the same D-10 injection class as `lsp.servers`
1840    // above. Unlike `lsp`, formatter definitions are SIBLINGS of `enabled`
1841    // (design's own schema shape), not nested under one sub-key, so each
1842    // one is checked and stripped individually. `timeout_secs` is
1843    // narrowing-safe either direction is left alone. `diff_back = false`
1844    // is the C10-UNSAFE direction (silences the annotation that lets the
1845    // model notice a formatter rewrote its file) — same "never let a
1846    // project assert the unsafe value" posture as
1847    // `capabilities.permissions.sandbox.enabled = false` above; `true` (or
1848    // simply omitted) passes through untouched.
1849    if let Some(cap) = out.capabilities.get_mut("formatters") {
1850        let formatter_keys: Vec<String> = cap
1851            .settings
1852            .keys()
1853            .filter(|k| !matches!(k.as_str(), "diff_back" | "timeout_secs"))
1854            .cloned()
1855            .collect();
1856        for key in formatter_keys {
1857            cap.settings.remove(&key);
1858            dropped.push(format!("capabilities.formatters.{key}"));
1859        }
1860        if matches!(
1861            cap.settings.get("diff_back"),
1862            Some(serde_json::Value::Bool(false))
1863        ) {
1864            cap.settings.remove("diff_back");
1865            dropped.push("capabilities.formatters.diff_back".to_string());
1866        }
1867    }
1868
1869    if let Some(cap) = out.capabilities.get_mut("permissions") {
1870        match cap.settings.get("sandbox").cloned() {
1871            Some(serde_json::Value::String(sb)) if is_loosening_sandbox_str(&sb) => {
1872                cap.settings.remove("sandbox");
1873                dropped.push("capabilities.permissions.sandbox".to_string());
1874            }
1875            Some(serde_json::Value::Object(_)) => {
1876                if let Some(tbl) = cap
1877                    .settings
1878                    .get_mut("sandbox")
1879                    .and_then(|v| v.as_object_mut())
1880                {
1881                    if let Some(tier) = tbl.get("tier").and_then(|v| v.as_str()).map(String::from) {
1882                        if is_loosening_sandbox_str(&tier) {
1883                            tbl.remove("tier");
1884                            dropped.push("capabilities.permissions.sandbox.tier".to_string());
1885                        }
1886                    }
1887                    // P5-10: `enabled` (OS-level enforcement engaged) only
1888                    // ever TIGHTENS by turning enforcement ON — an explicit
1889                    // project `enabled = false` is the one loosening
1890                    // direction (it can defeat a base layer's `enabled =
1891                    // true`) and is unconditionally dropped, REGARDLESS of
1892                    // the base layer's own value (no base comparison
1893                    // needed: "never let a project assert false" is
1894                    // correct whether the base is `true`, `false`, or
1895                    // unset). `enabled = true` passes through untouched.
1896                    if matches!(tbl.get("enabled"), Some(serde_json::Value::Bool(false))) {
1897                        tbl.remove("enabled");
1898                        dropped.push("capabilities.permissions.sandbox.enabled".to_string());
1899                    }
1900                    // P5-10: `escalation`/`env_policy` graduate from an
1901                    // unconditional strip to the SAME two-stage treatment
1902                    // `tier`/`approval` already get — catch the single
1903                    // absolute-loosest value here (fails safe even if the
1904                    // downstream relative clamp were ever skipped), leave
1905                    // anything else for `clamp_project_permissions`'
1906                    // proper rank-vs-base-layer comparison (a project CAN
1907                    // legitimately tighten these now that they carry real
1908                    // behavior — P5-1's own `sandbox`/`approval` precedent
1909                    // for "let a narrowing project value through").
1910                    if let Some(esc) = tbl.get("escalation").and_then(|v| v.as_str()) {
1911                        if crate::sandbox::SandboxEscalation::parse(esc)
1912                            == Some(crate::sandbox::SandboxEscalation::Allow)
1913                        {
1914                            tbl.remove("escalation");
1915                            dropped.push("capabilities.permissions.sandbox.escalation".to_string());
1916                        }
1917                    }
1918                    if let Some(ep) = tbl.get("env_policy").and_then(|v| v.as_str()) {
1919                        if crate::sandbox::SandboxEnvPolicy::parse(ep)
1920                            == Some(crate::sandbox::SandboxEnvPolicy::Inherit)
1921                        {
1922                            tbl.remove("env_policy");
1923                            dropped.push("capabilities.permissions.sandbox.env_policy".to_string());
1924                        }
1925                    }
1926                    // P5-10: `network.allow_domains`/`.deny_domains` have
1927                    // no established strictness ORDER this resolver can
1928                    // safely clamp against yet: `allow_domains` growing can
1929                    // WIDEN reachability (from a base's empty/unrestricted
1930                    // list), and a project-supplied `deny_domains` REPLACING
1931                    // (not unioning with) the trusted layer's own list risks
1932                    // silently dropping an entry the trusted layer relied
1933                    // on if a future merge step ever folds it in naively —
1934                    // same "no safe-to-trust ordering yet" rationale as
1935                    // `auto_approved_tools`/`rules.allow` below. Both are
1936                    // stripped from a project layer outright (fail-closed);
1937                    // only the coarse `network.enabled` boolean gets the
1938                    // never-assert-false treatment (same as the table's own
1939                    // `enabled` above), since ANY narrower per-domain intent
1940                    // needs the platform primitive this build brief already
1941                    // names as out of reach on this kernel class anyway.
1942                    if let Some(net) = tbl.get_mut("network").and_then(|v| v.as_object_mut()) {
1943                        for k in ["allow_domains", "deny_domains"] {
1944                            if net.remove(k).is_some() {
1945                                dropped
1946                                    .push(format!("capabilities.permissions.sandbox.network.{k}"));
1947                            }
1948                        }
1949                        if matches!(net.get("enabled"), Some(serde_json::Value::Bool(false))) {
1950                            net.remove("enabled");
1951                            dropped.push(
1952                                "capabilities.permissions.sandbox.network.enabled".to_string(),
1953                            );
1954                        }
1955                    }
1956                }
1957            }
1958            _ => {}
1959        }
1960        if let Some(ap) = cap.settings.get("approval").and_then(|v| v.as_str()) {
1961            if is_loosening_approval_str(ap) {
1962                cap.settings.remove("approval");
1963                dropped.push("capabilities.permissions.approval".to_string());
1964            }
1965        }
1966        if cap.settings.remove("auto_approved_tools").is_some() {
1967            dropped.push("capabilities.permissions.auto_approved_tools".to_string());
1968        }
1969        if let Some(rules) = cap
1970            .settings
1971            .get_mut("rules")
1972            .and_then(|v| v.as_object_mut())
1973        {
1974            if rules.remove("allow").is_some() {
1975                dropped.push("capabilities.permissions.rules.allow".to_string());
1976            }
1977        }
1978    }
1979
1980    // Default disposition (S9): opting IN to any module not on the
1981    // allowlist is forbidden by default; disabling (narrowing) is always
1982    // left alone. This also covers `tools_web`/`tools_background`/
1983    // `telemetry`/`session_share`'s `enabled = true` (§3.3's named exfil/
1984    // detached-execution rows) without a redundant per-name list.
1985    for (name, cap) in out.capabilities.iter_mut() {
1986        if cap.enabled == Some(true) && !PROJECT_ALLOWED_CAPABILITY_ENABLE.contains(&name.as_str())
1987        {
1988            cap.enabled = None;
1989            dropped.push(format!("capabilities.{name}.enabled"));
1990        }
1991    }
1992
1993    (out, dropped)
1994}
1995
1996/// §3.3's monotonic clamp, applied specifically at the project-layer merge
1997/// (not the general [`HarnessConfig::overlay`], which is also used for the
1998/// preset chain and the user layer — a user's OWN config extending a preset
1999/// and then setting a looser value is fine; only the UNTRUSTED project layer
2000/// is clamped). Mirrors `userconfig.rs`'s `clamp_sandbox`/`clamp_approval`
2001/// (F2/F3 fix precedent): even a project value that survived
2002/// [`sanitize_for_project`] (because it isn't the single GLOBAL loosest
2003/// value) must still be no looser than the base layer's OWN effective
2004/// posture — e.g. a project setting `workspace_write` when the base layer
2005/// has `read_only` is a real widening and must be clamped back.
2006///
2007/// `pub` (P5-10): also called directly by `crates/cli/src/userconfig.rs`'s
2008/// `overlay_project` (via a throwaway `HarnessConfig` wrapping just the
2009/// `capabilities` map, the same `core_probe` trick
2010/// `sanitized_for_project`'s own doc comment already uses for `[core]`) so
2011/// the CLI's plain `.supercode.toml` route gets the SAME `escalation`/
2012/// `env_policy` relative-rank clamp as the SDK's `HarnessConfig` resolver,
2013/// rather than a second, potentially-drifting reimplementation.
2014pub fn clamp_project_permissions(
2015    base: &HarnessConfig,
2016    sanitized_project: &HarnessConfig,
2017    merged: &mut HarnessConfig,
2018) -> Vec<String> {
2019    let mut clamped = Vec::new();
2020    let base_sandbox = effective_sandbox(base);
2021    let base_approval = effective_approval(base);
2022
2023    if let Some(raw) = permissions_sandbox_raw(sanitized_project) {
2024        if let Some(parsed) = parse_sandbox_str(&raw) {
2025            if sandbox_rank(parsed) > sandbox_rank(base_sandbox) {
2026                clamped.push("capabilities.permissions.sandbox".to_string());
2027                set_sandbox_tier(merged, permissions_sandbox_raw(base));
2028            }
2029        }
2030    }
2031    if let Some(raw) = permissions_approval_raw(sanitized_project) {
2032        if let Some(parsed) = parse_approval_str(&raw) {
2033            if approval_rank(parsed) > approval_rank(base_approval) {
2034                clamped.push("capabilities.permissions.approval".to_string());
2035                set_permissions_approval_raw(merged, permissions_approval_raw(base));
2036            }
2037        }
2038    }
2039    // P5-10 (§2 module 12): `escalation`/`env_policy` get the exact same
2040    // rank-vs-base-layer clamp as `sandbox`/`approval` above — the TABLE
2041    // form only (the bare tier shorthand can't express either key at all,
2042    // so `sanitized_project`/`base` both read `None` for a bare-form
2043    // config and this is a no-op, same as `permissions_sandbox_raw`'s own
2044    // bare-vs-table handling elsewhere in this file). The "unset" floor for
2045    // each mirrors [`crate::sandbox::SandboxEscalation`]/[`crate::sandbox::
2046    // SandboxEnvPolicy`]'s own `Default` (`Deny`/`Inherit` respectively) —
2047    // the SAME values `permissions_sandbox_escalation`/
2048    // `permissions_sandbox_env_policy` already fall back to, so this clamp
2049    // agrees with what `materialize_config` will actually resolve.
2050    let base_escalation = permissions_sandbox_escalation_raw(base)
2051        .as_deref()
2052        .and_then(crate::sandbox::SandboxEscalation::parse)
2053        .unwrap_or_default();
2054    if let Some(raw) = permissions_sandbox_escalation_raw(sanitized_project) {
2055        if let Some(parsed) = crate::sandbox::SandboxEscalation::parse(&raw) {
2056            if parsed.rank() > base_escalation.rank() {
2057                clamped.push("capabilities.permissions.sandbox.escalation".to_string());
2058                set_permissions_sandbox_escalation_raw(
2059                    merged,
2060                    permissions_sandbox_escalation_raw(base),
2061                );
2062            }
2063        }
2064    }
2065    let base_env_policy = permissions_sandbox_env_policy_raw(base)
2066        .as_deref()
2067        .and_then(crate::sandbox::SandboxEnvPolicy::parse)
2068        .unwrap_or_default();
2069    if let Some(raw) = permissions_sandbox_env_policy_raw(sanitized_project) {
2070        if let Some(parsed) = crate::sandbox::SandboxEnvPolicy::parse(&raw) {
2071            if parsed.rank() > base_env_policy.rank() {
2072                clamped.push("capabilities.permissions.sandbox.env_policy".to_string());
2073                set_permissions_sandbox_env_policy_raw(
2074                    merged,
2075                    permissions_sandbox_env_policy_raw(base),
2076                );
2077            }
2078        }
2079    }
2080    // CRITICAL backstop (P5-10 security reopen, belt-and-suspenders on top
2081    // of `merge_sandbox_value`'s merge-representation fix): the invariant
2082    // that actually matters is the RESOLVED/EFFECTIVE sandbox tier, not
2083    // whether `sanitized_project` happened to carry a raw `tier` string —
2084    // the presence-based check above is a no-op whenever the project's
2085    // table omitted `tier` entirely (a hostile tier already stripped by
2086    // `sanitize_for_project`, or a benign tier-less overlay), which is
2087    // exactly the shape that let a widening slip through before. Check the
2088    // MERGED config's actual effective tier directly and clamp it back
2089    // whenever it's looser than the base's, regardless of which code path
2090    // produced it — this makes the monotonic-tightening invariant
2091    // form-agnostic and independent of any single merge/sanitize call site.
2092    let merged_sandbox = effective_sandbox(merged);
2093    if sandbox_rank(merged_sandbox) > sandbox_rank(base_sandbox)
2094        && !clamped
2095            .iter()
2096            .any(|c| c == "capabilities.permissions.sandbox")
2097    {
2098        clamped.push("capabilities.permissions.sandbox".to_string());
2099        set_sandbox_tier(merged, permissions_sandbox_raw(base));
2100    }
2101    clamped
2102}
2103
2104/// Read `capabilities.permissions.sandbox.escalation`'s raw string — TABLE
2105/// form only (§3.1: the bare `sandbox = "<tier>"` shorthand can't express
2106/// this key). See [`clamp_project_permissions`]'s doc comment.
2107fn permissions_sandbox_escalation_raw(hc: &HarnessConfig) -> Option<String> {
2108    hc.capabilities
2109        .get("permissions")?
2110        .settings
2111        .get("sandbox")?
2112        .as_object()?
2113        .get("escalation")?
2114        .as_str()
2115        .map(String::from)
2116}
2117
2118/// Read `capabilities.permissions.sandbox.env_policy`'s raw string — same
2119/// TABLE-form-only treatment as [`permissions_sandbox_escalation_raw`].
2120fn permissions_sandbox_env_policy_raw(hc: &HarnessConfig) -> Option<String> {
2121    hc.capabilities
2122        .get("permissions")?
2123        .settings
2124        .get("sandbox")?
2125        .as_object()?
2126        .get("env_policy")?
2127        .as_str()
2128        .map(String::from)
2129}
2130
2131/// Overwrite (or clear) `capabilities.permissions.sandbox.escalation` —
2132/// used to revert a clamped project override back to the base layer's own
2133/// setting, same rationale as [`set_sandbox_tier`]. Only
2134/// touches the TABLE form (creating one if the entry didn't already exist
2135/// as an object — a clamp only ever fires when the PROJECT supplied the
2136/// table form in the first place, since the bare shorthand has no
2137/// `escalation` key to clamp).
2138fn set_permissions_sandbox_escalation_raw(hc: &mut HarnessConfig, value: Option<String>) {
2139    set_permissions_sandbox_subkey_raw(hc, "escalation", value);
2140}
2141
2142/// Same as [`set_permissions_sandbox_escalation_raw`] for `env_policy`.
2143fn set_permissions_sandbox_env_policy_raw(hc: &mut HarnessConfig, value: Option<String>) {
2144    set_permissions_sandbox_subkey_raw(hc, "env_policy", value);
2145}
2146
2147/// Shared body for [`set_permissions_sandbox_escalation_raw`]/
2148/// [`set_permissions_sandbox_env_policy_raw`].
2149fn set_permissions_sandbox_subkey_raw(hc: &mut HarnessConfig, key: &str, value: Option<String>) {
2150    let cap = hc
2151        .capabilities
2152        .entry("permissions".to_string())
2153        .or_default();
2154    let entry = cap
2155        .settings
2156        .entry("sandbox".to_string())
2157        .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
2158    if !entry.is_object() {
2159        // The project supplied the bare-string shorthand (no object to set
2160        // a sub-key on) — nothing to clamp back onto since it couldn't
2161        // have carried this key in the first place; leave it untouched.
2162        return;
2163    }
2164    let obj = entry.as_object_mut().expect("just checked is_object");
2165    match value {
2166        Some(v) => {
2167            obj.insert(key.to_string(), serde_json::Value::String(v));
2168        }
2169        None => {
2170            obj.remove(key);
2171        }
2172    }
2173}
2174
2175/// Overwrite (or clear) `capabilities.permissions.sandbox`'s TIER — used to
2176/// revert a clamped project override back to the base layer's own setting.
2177///
2178/// P5-10 fix: unlike the old bare-string-only clamp this replaces, `sandbox`
2179/// can now carry legitimate sibling subkeys (`enabled`/`escalation`/
2180/// `env_policy`/`network`) alongside `tier` that the project may have
2181/// validly tightened in the SAME merge — wholesale-replacing the whole
2182/// value with a bare string would silently discard those. When the merged
2183/// value is already table form, only the `tier` subkey is overwritten,
2184/// preserving every other subkey; only when it's the bare-string shorthand
2185/// (or absent) does this fall back to setting/clearing the bare string, same
2186/// as before (there's no table to preserve subkeys on).
2187fn set_sandbox_tier(hc: &mut HarnessConfig, value: Option<String>) {
2188    let cap = hc
2189        .capabilities
2190        .entry("permissions".to_string())
2191        .or_default();
2192    if let Some(serde_json::Value::Object(obj)) = cap.settings.get_mut("sandbox") {
2193        match value {
2194            Some(v) => {
2195                obj.insert("tier".to_string(), serde_json::Value::String(v));
2196            }
2197            None => {
2198                obj.remove("tier");
2199            }
2200        }
2201        return;
2202    }
2203    match value {
2204        Some(v) => {
2205            cap.settings
2206                .insert("sandbox".to_string(), serde_json::Value::String(v));
2207        }
2208        None => {
2209            cap.settings.remove("sandbox");
2210        }
2211    }
2212}
2213
2214/// Same as [`set_sandbox_tier`] for `approval` (bare-string-only field, no
2215/// table form exists to preserve subkeys on).
2216fn set_permissions_approval_raw(hc: &mut HarnessConfig, value: Option<String>) {
2217    let cap = hc
2218        .capabilities
2219        .entry("permissions".to_string())
2220        .or_default();
2221    match value {
2222        Some(v) => {
2223            cap.settings
2224                .insert("approval".to_string(), serde_json::Value::String(v));
2225        }
2226        None => {
2227            cap.settings.remove("approval");
2228        }
2229    }
2230}
2231
2232// ---------------------------------------------------------------------------
2233// §3.5 step 6: module resolution — §2.1's dependency graph and §2.2's
2234// conflict matrix encoded AS DATA the resolver consumes, per the design's
2235// explicit instruction ("Deps `→`... Conflicts `⚡`..."), rather than
2236// hardcoded if-chains scattered through the crate.
2237// ---------------------------------------------------------------------------
2238
2239/// The 31 top-level `[capabilities.<name>]` table names (§2's 35 modules,
2240/// minus the 4 that nest as sub-tables of a family: `permissions.rules`,
2241/// `permissions.sandbox`, `permissions.protected_paths` nest under
2242/// `permissions`; `mcp.server` is the `capabilities.mcp.serve` bool, not a
2243/// separate top-level table).
2244pub const MODULE_NAMES: &[&str] = &[
2245    "tools_search",
2246    "tools_apply_patch",
2247    "tools_persistent_shell",
2248    "tools_background",
2249    "tools_web",
2250    "tools_question",
2251    "todos",
2252    "plan_mode",
2253    "subagents",
2254    "permissions",
2255    "trust",
2256    "mcp",
2257    "hooks",
2258    "plugins",
2259    "memory",
2260    "checkpoint",
2261    "session_tree",
2262    "session_share",
2263    "reduction",
2264    "deferred_tools",
2265    "cache",
2266    "model_catalog",
2267    "model_oauth",
2268    "lsp",
2269    "formatters",
2270    "tui",
2271    "server",
2272    "notify",
2273    "structured_output",
2274    "telemetry",
2275    "integrations",
2276];
2277
2278/// The 3 nested sub-modules under `capabilities.permissions` (module family
2279/// 10-13, §2 table) — dotted paths [`module_enabled`] understands.
2280pub const NESTED_MODULE_NAMES: &[&str] = &[
2281    "permissions.rules",
2282    "permissions.sandbox",
2283    "permissions.protected_paths",
2284];
2285
2286/// Whether a module (a top-level name, or a dotted `top.sub` path for the
2287/// [`NESTED_MODULE_NAMES`]) is enabled in a resolved `HarnessConfig`.
2288pub fn module_enabled(hc: &HarnessConfig, module: &str) -> bool {
2289    let mut parts = module.splitn(2, '.');
2290    let top = parts.next().unwrap_or("");
2291    let Some(cap) = hc.capabilities.get(top) else {
2292        return false;
2293    };
2294    match parts.next() {
2295        None => cap.enabled.unwrap_or(false),
2296        Some(sub) => cap
2297            .settings
2298            .get(sub)
2299            .and_then(|v| v.as_object())
2300            .and_then(|o| o.get("enabled"))
2301            .and_then(|v| v.as_bool())
2302            .unwrap_or(false),
2303    }
2304}
2305
2306/// Read a boolean setting nested under a capability's table (e.g.
2307/// `subagents.background`, `reduction.span_summaries`) — `false` if the
2308/// module or the key is absent. `pub(crate)`: also used by
2309/// [`crate::modules::ModuleId::is_active`] for the module-16
2310/// (`mcp.server` → `capabilities.mcp.serve`) schema-collapse case.
2311pub(crate) fn module_setting_bool(hc: &HarnessConfig, top: &str, key: &str) -> bool {
2312    hc.capabilities
2313        .get(top)
2314        .and_then(|c| c.settings.get(key))
2315        .and_then(|v| v.as_bool())
2316        .unwrap_or(false)
2317}
2318
2319/// Read a string setting nested under a capability's table.
2320fn module_setting_str<'a>(hc: &'a HarnessConfig, top: &str, key: &str) -> Option<&'a str> {
2321    hc.capabilities
2322        .get(top)
2323        .and_then(|c| c.settings.get(key))
2324        .and_then(|v| v.as_str())
2325}
2326
2327/// The effective `[core.tools] enabled` list — the §3.1 default four when
2328/// unset (`core.tools.enabled` has no built-in default of its own; the
2329/// schema's stated default is the §1.2 "default-active four").
2330fn effective_tools_enabled(hc: &HarnessConfig) -> Vec<String> {
2331    hc.core.tools.enabled.clone().unwrap_or_else(|| {
2332        ["read_file", "bash", "edit_file", "write_file"]
2333            .iter()
2334            .map(|s| s.to_string())
2335            .collect()
2336    })
2337}
2338
2339/// Every named module's activation state (§3.5 step 7's "module-activation
2340/// set") — [`MODULE_NAMES`] plus [`NESTED_MODULE_NAMES`], each mapped to
2341/// [`module_enabled`]'s verdict.
2342fn activation_set(hc: &HarnessConfig) -> BTreeMap<String, bool> {
2343    let mut set = BTreeMap::new();
2344    for name in MODULE_NAMES {
2345        set.insert((*name).to_string(), module_enabled(hc, name));
2346    }
2347    for name in NESTED_MODULE_NAMES {
2348        set.insert((*name).to_string(), module_enabled(hc, name));
2349    }
2350    set
2351}
2352
2353/// A resolver diagnostic (§3.5 step 6): a warning is advisory (attached to
2354/// [`Resolved::warnings`]); a hard-dependency or conflict failure is a
2355/// [`ResolveError`].
2356///
2357/// **Scope note (documented, not a gap the golden tests miss):** this
2358/// implements every dependency/conflict edge §2.1/§2.2 name that is
2359/// mechanically checkable from config data alone AND that the design's own
2360/// §4.6 mechanical re-validation table shows firing (or cleanly passing)
2361/// for at least one of the six reserved presets: D-1 (subagents
2362/// background→approvals), D-3 (permissions.rules→approvals), D-4
2363/// (lsp→edit/write), D-7 (skills→read_file|bash, warn-degrade), D-9
2364/// (span_summaries/memory→small_model, fallback-warn), D-10
2365/// (hooks/plugins→trust), plan_mode→rules|sandbox, mcp.server→mcp.client,
2366/// tools_question→tui|server, C1, C3, C4, C6. D-8 is never checked
2367/// (rehydrate is always-on core, §1.13/S1). Two §2.1 edges are deliberately
2368/// NOT enforced as resolver warnings even though prose names them
2369/// (`checkpoint`'s "full-coverage" sandbox qualifier; `mcp.client.elicitation
2370/// →tools.question`, satisfied-by-`tui` in every preset that needs it):
2371/// §4.6's own verdict table treats both as narrative residuals in the
2372/// design DOCUMENT, not as warnings the mechanical resolver itself must
2373/// emit — implementing them as active checks would fire un-named warnings
2374/// on cc-parity/oc-parity that contradict §4.6's stated clean verdicts for
2375/// those two presets. Left for a future pass if the design promotes them to
2376/// resolver-checked rows.
2377///
2378/// D-9's trigger set is deliberately narrowed to `reduction.span_summaries`
2379/// and `memory.enabled` — NOT `core.compaction.summarize`, even though
2380/// §2.1's literal text lists all three. `compaction.summarize = true` is
2381/// the near-universal default across every preset (all six set it, or
2382/// inherit it from `pi-core`), and falling back to the main model for
2383/// compaction summaries is unremarkable — §4.6 never names a D-9 warning
2384/// for ANY of the six presets, including `pi-core`/`cx-parity`/`oc-parity`,
2385/// which all set `compaction.summarize = true` with no `small_model`
2386/// configured. Including `compaction.summarize` in the trigger set would
2387/// therefore produce three un-named warnings contradicting §4.6's clean
2388/// verdicts for those presets; narrowing to the two dependents whose
2389/// fallback the design's own validation table treats as meaningful resolves
2390/// the contradiction.
2391fn validate_modules(
2392    hc: &HarnessConfig,
2393    preset_baseline: Option<&HarnessConfig>,
2394) -> Result<Vec<String>, ResolveError> {
2395    let mut warnings = Vec::new();
2396    let tools_enabled = effective_tools_enabled(hc);
2397    let has = |name: &str| tools_enabled.iter().any(|t| t == name);
2398
2399    // ---- hard dependencies (§2.1) ----
2400
2401    // D-1: subagents background-mode → permissions.approvals.
2402    if module_enabled(hc, "subagents") && module_setting_bool(hc, "subagents", "background") {
2403        require(
2404            module_enabled(hc, "permissions"),
2405            "subagents (background)",
2406            "permissions",
2407        )?;
2408    }
2409    // D-3: permissions.rules → permissions.approvals.
2410    if module_enabled(hc, "permissions.rules") {
2411        require(
2412            module_enabled(hc, "permissions"),
2413            "permissions.rules",
2414            "permissions",
2415        )?;
2416    }
2417    // D-4: lsp → core.tools(edit/write).
2418    if module_enabled(hc, "lsp") {
2419        require(
2420            has("edit_file") && has("write_file"),
2421            "lsp",
2422            "core.tools.enabled (edit_file, write_file)",
2423        )?;
2424    }
2425    // D-10: hooks(project-scope), plugins → trust.
2426    if module_enabled(hc, "hooks") {
2427        require(module_enabled(hc, "trust"), "hooks", "trust")?;
2428    }
2429    if module_enabled(hc, "plugins") {
2430        require(module_enabled(hc, "trust"), "plugins", "trust")?;
2431    }
2432    // plan_mode → permissions.rules | permissions.sandbox.
2433    if module_enabled(hc, "plan_mode") {
2434        require(
2435            module_enabled(hc, "permissions.rules") || module_enabled(hc, "permissions.sandbox"),
2436            "plan_mode",
2437            "permissions.rules or permissions.sandbox",
2438        )?;
2439    }
2440    // mcp.server → mcp.client.
2441    if module_setting_bool(hc, "mcp", "serve") {
2442        require(module_enabled(hc, "mcp"), "mcp (serve)", "mcp")?;
2443    }
2444    // tools.question, permissions.approvals(ask-UI) → tui | server.
2445    if module_enabled(hc, "tools_question") {
2446        require(
2447            module_enabled(hc, "tui") || module_enabled(hc, "server"),
2448            "tools_question",
2449            "tui or server",
2450        )?;
2451    }
2452
2453    // D-7 (S3-amended): core.skills → core.tools.read_file | core.tools.bash.
2454    // No viable read pathway at all is a hard-dep failure; bash-only
2455    // degrades to a warning, not an error.
2456    if hc.core.skills.enabled == Some(true) {
2457        let has_read = has("read_file");
2458        let has_bash = has("bash");
2459        if !has_read && !has_bash {
2460            return Err(ResolveError::MissingDependency {
2461                module: "core.skills".to_string(),
2462                requires: "core.tools.enabled (read_file or bash)".to_string(),
2463            });
2464        }
2465        if !has_read && has_bash {
2466            warnings.push(
2467                "D-7: core.skills is active with only `bash` as the read pathway (no \
2468                 dedicated read_file); progressive disclosure degrades to bash-only reads \
2469                 (§2.1 D-7, resolver warns rather than errors)"
2470                    .to_string(),
2471            );
2472        }
2473        // BP-6: `[core.skills] harness` names whose documented root table
2474        // the loop scans. A name with no skills root supercode reads is a
2475        // hard failure, not a silent empty discovery — the same
2476        // refuse-by-name contract `supercode skills list --harness` keeps.
2477        if let Some(harness) = hc.core.skills.harness.as_deref() {
2478            if !crate::skills::SKILL_HARNESSES.contains(&harness) {
2479                return Err(ResolveError::MissingDependency {
2480                    module: "core.skills".to_string(),
2481                    requires: format!(
2482                        "core.skills.harness to name a harness with a skills root ({}), not `{harness}`",
2483                        crate::skills::SKILL_HARNESSES.join(", ")
2484                    ),
2485                });
2486            }
2487        }
2488    }
2489
2490    // D-9 (fallback → warning): reduction.span_summaries / memory →
2491    // model_catalog.small_model. See the narrowing rationale on this
2492    // function's doc comment.
2493    let span_summaries_on =
2494        module_enabled(hc, "reduction") && module_setting_bool(hc, "reduction", "span_summaries");
2495    let memory_on = module_enabled(hc, "memory");
2496    if span_summaries_on || memory_on {
2497        let small_model = module_setting_str(hc, "model_catalog", "small_model").unwrap_or("");
2498        if small_model.is_empty() {
2499            warnings.push(
2500                "D-9: a small-model-consuming feature (reduction.span_summaries and/or \
2501                 memory) is enabled with no capabilities.model_catalog.small_model set — \
2502                 falls back to the main model (§2.1 D-9)"
2503                    .to_string(),
2504            );
2505        }
2506    }
2507
2508    // BP-13 (D9 "Org model allowlists / effort caps"), CONFIG LAYER: the
2509    // allow/deny lists and the effort cap bind here, in the same resolution
2510    // pass every other routing decision is made in. `crate::model_catalog`
2511    // owns the matching; this is only where the verdict becomes an error.
2512    //
2513    // Boundary, stated rather than implied: this is the config layer. It
2514    // binds every model the table hands out (`core.model`, `small_model`,
2515    // and each `fallback` entry) and it is project-forbidden, so a repo
2516    // cannot lift a restriction its user/global layer set. What it is NOT
2517    // is a MANAGED/enterprise tier above the user's own file — the layer an
2518    // org admin owns and the user cannot edit. That tier is BP-14's; until
2519    // it exists the rule is enforceable but not administrable.
2520    {
2521        let routing = crate::model_catalog::Routing::from_capabilities(&hc.capabilities);
2522        if routing.restricts_models() {
2523            let base = hc.core.model.clone().unwrap_or_default();
2524            let resolution = crate::model_catalog::resolve(&hc.capabilities, &base);
2525            if let Some(detail) = resolution.refusal {
2526                return Err(ResolveError::ModelNotAllowed(detail));
2527            }
2528        }
2529        // The effort CAP never errors — it clamps, which is what a cap
2530        // means. Naming the clamp keeps it visible instead of silent.
2531        let effort = hc.core.effort.clone().unwrap_or_default();
2532        if !effort.is_empty() {
2533            let capped = crate::model_catalog::cap_effort(
2534                Some(effort.as_str()),
2535                routing.defaults.max_effort.as_deref(),
2536            );
2537            if capped.as_deref() != Some(effort.as_str()) {
2538                warnings.push(format!(
2539                    "capabilities.model_catalog.max_effort clamps `core.effort = \"{effort}\"` \
2540                     down to `{}`",
2541                    capped.unwrap_or_default()
2542                ));
2543            }
2544        }
2545    }
2546
2547    // ---- conflicts (§2.2) ----
2548
2549    // C1: tools_apply_patch co-advertised with edit_file/write_file without
2550    // per-model bits.
2551    if module_enabled(hc, "tools_apply_patch") {
2552        let co_advertised = has("edit_file") || has("write_file");
2553        let per_model = module_setting_bool(hc, "tools_apply_patch", "per_model");
2554        let model_catalog_on = module_enabled(hc, "model_catalog");
2555        if co_advertised && !(per_model && model_catalog_on) {
2556            warnings.push(
2557                "C1: capabilities.tools_apply_patch is advertised alongside edit_file/\
2558                 write_file with no model_catalog per-model capability bits — format \
2559                 confusion risk (§2.2 C1)"
2560                    .to_string(),
2561            );
2562        }
2563    }
2564
2565    // C3 (MANDATORY, non-suppressible): sandbox=danger_full_access +
2566    // approval=never.
2567    if effective_sandbox(hc) == SandboxPolicy::DangerFullAccess
2568        && effective_approval(hc) == ApprovalPolicy::Never
2569    {
2570        warnings.push(
2571            "C3 (MANDATORY): capabilities.permissions resolves to \
2572             sandbox=danger_full_access + approval=never — zero gates. Legal, but never \
2573             safe-by-default; presets must never label this posture safe (§2.2 C3)"
2574                .to_string(),
2575        );
2576    }
2577
2578    // C4: presets pin approval + system-prompt tuning together; independent
2579    // overrides over a preset baseline warn.
2580    if let Some(baseline) = preset_baseline {
2581        let approval_changed = permissions_approval_raw(hc) != permissions_approval_raw(baseline);
2582        let prompt_changed = hc.core.system_prompt != baseline.core.system_prompt
2583            || hc.core.append_system_prompt != baseline.core.append_system_prompt;
2584        if approval_changed != prompt_changed {
2585            warnings.push(
2586                "C4: capabilities.permissions.approval was overridden independently of \
2587                 core.system_prompt/append_system_prompt (or vice versa) — this preset pins \
2588                 the two together (§2.2 C4)"
2589                    .to_string(),
2590            );
2591        }
2592    }
2593
2594    // C6: tools_background / subagents.background → an approvals
2595    // auto-policy (`background_prompts = "parent" | "auto_policy"`).
2596    let bg_exposure = module_enabled(hc, "tools_background")
2597        || (module_enabled(hc, "subagents") && module_setting_bool(hc, "subagents", "background"));
2598    if bg_exposure {
2599        match module_setting_str(hc, "subagents", "background_prompts") {
2600            Some("parent") | Some("auto_policy") => {}
2601            _ => {
2602                // S8 argued-satisfaction exception (§4.6 cx-parity row):
2603                // under `approval = "model_requested"`, tools proceed
2604                // sandboxed without prompting unless the MODEL itself
2605                // escalates — an auto-run default from a background task's
2606                // perspective, even with no literal `background_prompts`
2607                // key. Recorded as a judgment-call warning, not silently
2608                // treated as clean.
2609                let approval_raw = permissions_approval_raw(hc).unwrap_or_default();
2610                let is_model_requested = approval_raw
2611                    .replace('_', "-")
2612                    .eq_ignore_ascii_case("model-requested");
2613                if is_model_requested {
2614                    warnings.push(
2615                        "C6 (S8 judgment call): background execution proceeds under \
2616                         approval=model_requested with no literal \
2617                         capabilities.subagents.background_prompts key — the model's own \
2618                         escalation is treated as the required auto-policy, not a literal \
2619                         schema-key match (§2.2 C6, §4.6 cx-parity residual)"
2620                            .to_string(),
2621                    );
2622                } else {
2623                    return Err(ResolveError::Conflict {
2624                        name: "C6".to_string(),
2625                        detail: "tools_background and/or subagents.background is enabled \
2626                                 without capabilities.subagents.background_prompts set to \
2627                                 \"parent\" or \"auto_policy\" — a detached task cannot prompt \
2628                                 (§2.2 C6)"
2629                            .to_string(),
2630                    });
2631                }
2632            }
2633        }
2634    }
2635
2636    // SECURITY carry-forward: case-sensitive, deny-unknown-fields re-check
2637    // of `capabilities.permissions` (P3 mandate — see the block below this
2638    // function for `validate_permissions_case_sensitivity`).
2639    if let Some(w) = validate_permissions_case_sensitivity(hc) {
2640        warnings.push(w);
2641    }
2642
2643    Ok(warnings)
2644}
2645
2646// ---------------------------------------------------------------------------
2647// SECURITY carry-forward (independent Fable review finding, P3 mandate):
2648// every P3 code path that CONSUMES `[capabilities.permissions.*]` tables
2649// must deserialize with an EXACT, case-sensitive schema and
2650// `deny_unknown_fields` — a wrong-case key (`Tier`, `Sandbox`) must be
2651// rejected/ignored-with-warning, never silently honored. The raw
2652// `serde_json::Value::get("sandbox")` lookups elsewhere in this file are
2653// already case-sensitive (a JSON/TOML map key lookup never case-folds), so a
2654// mistyped `Sandbox` was already never *honored* — but it was also never
2655// *flagged*, so a typo'd security-relevant key could silently do nothing
2656// with no diagnostic at all. This strict shadow-schema closes that gap: it
2657// is deserialized from the SAME `capabilities.permissions` settings object
2658// purely for validation, and any field it doesn't recognize (including a
2659// case variant of a real one) fails the whole table, producing a named
2660// warning rather than a silent no-op.
2661// ---------------------------------------------------------------------------
2662
2663/// `[capabilities.permissions].sandbox` — either the bare-string shorthand or
2664/// the table form (§3.1 module 12); `deny_unknown_fields` inside the table
2665/// form so a case-typo'd sub-key (`Tier`, `Network`) is rejected too.
2666#[allow(dead_code)]
2667// fields exist only to make deny_unknown_fields reject unknown/case-typo'd keys; the parsed values themselves are never read (Result::is_ok is all validate_permissions_case_sensitivity needs)
2668#[derive(Debug, Deserialize)]
2669#[serde(untagged)]
2670enum StrictSandboxValue {
2671    Bare(String),
2672    Table(StrictSandboxTable),
2673}
2674
2675#[allow(dead_code)]
2676// fields exist only to make deny_unknown_fields reject unknown/case-typo'd keys; the parsed values themselves are never read (Result::is_ok is all validate_permissions_case_sensitivity needs)
2677#[derive(Debug, Deserialize)]
2678#[serde(deny_unknown_fields)]
2679struct StrictSandboxTable {
2680    #[serde(default)]
2681    enabled: Option<bool>,
2682    #[serde(default)]
2683    tier: Option<String>,
2684    #[serde(default)]
2685    network: Option<StrictNetworkTable>,
2686    #[serde(default)]
2687    escalation: Option<String>,
2688    #[serde(default)]
2689    env_policy: Option<String>,
2690}
2691
2692#[allow(dead_code)]
2693// fields exist only to make deny_unknown_fields reject unknown/case-typo'd keys; the parsed values themselves are never read (Result::is_ok is all validate_permissions_case_sensitivity needs)
2694#[derive(Debug, Deserialize)]
2695#[serde(deny_unknown_fields)]
2696struct StrictNetworkTable {
2697    #[serde(default)]
2698    enabled: Option<bool>,
2699    #[serde(default)]
2700    allow_domains: Option<Vec<String>>,
2701    #[serde(default)]
2702    deny_domains: Option<Vec<String>>,
2703}
2704
2705/// `[capabilities.permissions.rules]` (module 11).
2706#[allow(dead_code)]
2707// fields exist only to make deny_unknown_fields reject unknown/case-typo'd keys; the parsed values themselves are never read (Result::is_ok is all validate_permissions_case_sensitivity needs)
2708#[derive(Debug, Deserialize)]
2709#[serde(deny_unknown_fields)]
2710struct StrictRulesTable {
2711    #[serde(default)]
2712    enabled: Option<bool>,
2713    #[serde(default)]
2714    deny: Option<Vec<String>>,
2715    #[serde(default)]
2716    ask: Option<Vec<String>>,
2717    #[serde(default)]
2718    allow: Option<Vec<String>>,
2719}
2720
2721/// `[capabilities.permissions.protected_paths]` (module 13).
2722#[allow(dead_code)]
2723// fields exist only to make deny_unknown_fields reject unknown/case-typo'd keys; the parsed values themselves are never read (Result::is_ok is all validate_permissions_case_sensitivity needs)
2724#[derive(Debug, Deserialize)]
2725#[serde(deny_unknown_fields)]
2726struct StrictProtectedPathsTable {
2727    #[serde(default)]
2728    enabled: Option<bool>,
2729    #[serde(default)]
2730    paths: Option<Vec<String>>,
2731}
2732
2733/// `[capabilities.permissions]`'s FULL settings shape (modules 10-13, §3.1),
2734/// exact case-sensitive field names, `deny_unknown_fields`. Note `enabled`
2735/// itself is NOT here — [`CapabilityConfig::enabled`] already parses it
2736/// separately (before flattening into `settings`), so this only needs to
2737/// cover the flattened remainder.
2738#[allow(dead_code)]
2739// fields exist only to make deny_unknown_fields reject unknown/case-typo'd keys; the parsed values themselves are never read (Result::is_ok is all validate_permissions_case_sensitivity needs)
2740#[derive(Debug, Deserialize)]
2741#[serde(deny_unknown_fields)]
2742struct StrictPermissionsSettings {
2743    #[serde(default)]
2744    approval: Option<String>,
2745    #[serde(default)]
2746    sandbox: Option<StrictSandboxValue>,
2747    #[serde(default)]
2748    auto_approved_tools: Option<Vec<String>>,
2749    #[serde(default)]
2750    rules: Option<StrictRulesTable>,
2751    #[serde(default)]
2752    protected_paths: Option<StrictProtectedPathsTable>,
2753    /// BP-10 (`capabilities.permissions.approvals`, catalog row "Session
2754    /// approval caching"): the persisted-approval knob.
2755    #[serde(default)]
2756    approvals: Option<StrictApprovalsTable>,
2757    /// BP-10 (`capabilities.permissions.profile`, catalog row "Named
2758    /// permission profiles"): which named bundle this run selects.
2759    #[serde(default)]
2760    profile: Option<String>,
2761    /// BP-10 (`capabilities.permissions.profiles.<name>`): the bundles
2762    /// themselves. The MAP is free-form (the names are the user's), but
2763    /// each bundle's own keys go through the same strict, case-sensitive
2764    /// schema every other permission table does — a `Sandbox` typo inside
2765    /// a bundle must be flagged exactly like one at the top level.
2766    #[serde(default)]
2767    profiles: Option<std::collections::BTreeMap<String, StrictProfileTable>>,
2768}
2769
2770/// `[capabilities.permissions.approvals]` (BP-10, module 10).
2771#[allow(dead_code)]
2772// fields exist only to make deny_unknown_fields reject unknown/case-typo'd keys; the parsed values themselves are never read (Result::is_ok is all validate_permissions_case_sensitivity needs)
2773#[derive(Debug, Deserialize)]
2774#[serde(deny_unknown_fields)]
2775struct StrictApprovalsTable {
2776    #[serde(default)]
2777    persist: Option<bool>,
2778}
2779
2780/// `[capabilities.permissions.profiles.<name>]` (BP-10, cx§4
2781/// `[permissions.<name>]`).
2782#[allow(dead_code)]
2783// fields exist only to make deny_unknown_fields reject unknown/case-typo'd keys; the parsed values themselves are never read (Result::is_ok is all validate_permissions_case_sensitivity needs)
2784#[derive(Debug, Deserialize)]
2785#[serde(deny_unknown_fields)]
2786struct StrictProfileTable {
2787    #[serde(default)]
2788    extends: Option<String>,
2789    #[serde(default)]
2790    approval: Option<String>,
2791    #[serde(default)]
2792    sandbox: Option<StrictSandboxValue>,
2793    #[serde(default)]
2794    auto_approved_tools: Option<Vec<String>>,
2795    #[serde(default)]
2796    rules: Option<StrictRulesTable>,
2797    #[serde(default)]
2798    protected_paths: Option<StrictProtectedPathsTable>,
2799}
2800
2801/// Re-parse `capabilities.permissions`'s settings object through
2802/// [`StrictPermissionsSettings`] purely to catch a case-mismatched or
2803/// otherwise-unrecognized key that the coarser raw-JSON lookups elsewhere
2804/// would silently (and safely, but silently) ignore. Returns a warning
2805/// string when the strict schema rejects it; `None` when the table is
2806/// absent or fully recognized.
2807fn validate_permissions_case_sensitivity(hc: &HarnessConfig) -> Option<String> {
2808    let cap = hc.capabilities.get("permissions")?;
2809    if cap.settings.is_empty() {
2810        return None;
2811    }
2812    let value = serde_json::Value::Object(cap.settings.clone());
2813    match serde_json::from_value::<StrictPermissionsSettings>(value) {
2814        Ok(_) => None,
2815        Err(e) => Some(format!(
2816            "SECURITY: capabilities.permissions carries an unrecognized or case-mismatched \
2817             key and was rejected by the strict, case-sensitive schema (a typo like `Tier`/\
2818             `Sandbox` is never silently honored) — {e}"
2819        )),
2820    }
2821}
2822
2823/// Small helper: turn a hard-dependency check into the uniform
2824/// [`ResolveError::MissingDependency`] shape used throughout
2825/// [`validate_modules`].
2826fn require(met: bool, module: &str, requires: &str) -> Result<(), ResolveError> {
2827    if met {
2828        Ok(())
2829    } else {
2830        Err(ResolveError::MissingDependency {
2831            module: module.to_string(),
2832            requires: requires.to_string(),
2833        })
2834    }
2835}
2836
2837// ---------------------------------------------------------------------------
2838// §3.5 `extends` / preset resolution algorithm — steps 1-7, the resolver's
2839// public entry point.
2840// ---------------------------------------------------------------------------
2841
2842/// The `extends` chain depth cap (§3.5 step 2 — "mirrors CC's import
2843/// depth-4 spirit, cc§2", scaled to 8).
2844const MAX_EXTENDS_DEPTH: usize = 8;
2845
2846/// Top-level `HarnessConfig` keys (§3.1's schema root).
2847const KNOWN_TOP_KEYS: &[&str] = &[
2848    // BP-9: the editor-facing schema pointer (see `HarnessConfig::schema`).
2849    "$schema",
2850    "schema_version",
2851    "extends",
2852    "core",
2853    "capabilities",
2854    "experimental",
2855];
2856
2857/// `[core]`'s direct keys — scalars/arrays plus the named sub-table keys
2858/// (§3.1). Does NOT enumerate the sub-tables' OWN keys (`[core.tools.*]`,
2859/// `[core.compaction]`, …) — see [`unknown_keys`]'s doc comment for why.
2860const KNOWN_CORE_KEYS: &[&str] = &[
2861    "model",
2862    "base_url",
2863    "api_key_env",
2864    "api_key_cmd",
2865    "api_key_command",
2866    "update_check",
2867    "effort",
2868    "temperature",
2869    "max_tokens",
2870    "max_iterations",
2871    "max_total_output_tokens",
2872    "max_budget_usd",
2873    "max_steps",
2874    "price_input_per_mtok",
2875    "price_output_per_mtok",
2876    "max_tool_output_bytes",
2877    "tool_output_spill",
2878    "parallel_tool_calls",
2879    "shell_env_snapshot",
2880    "system_prompt",
2881    "append_system_prompt",
2882    "project_context",
2883    "env_context",
2884    "context_injections",
2885    "nested_instructions",
2886    "instruction_imports",
2887    "project_root_markers",
2888    "project_doc_max_bytes",
2889    "project_doc_excludes",
2890    "project_doc_strip_comments",
2891    // BP-5: the three prompt-assembly inputs (catalog D2 rows "@-file
2892    // mentions", "Output style / personality module", "Path-scoped rules").
2893    "file_mentions",
2894    "output_style",
2895    "path_rules",
2896    "doom_loop_threshold",
2897    "hot_reload",
2898    // BP-9: `project_doc_max_bytes` and `doom_loop_threshold` parse fine
2899    // but were absent from this list, so `--strict-config` rejected two
2900    // keys the schema and the parser both accept. Found by
2901    // `config_schema::tests::every_schema_key_parses_with_its_declared_type`.
2902    "project_doc_max_bytes",
2903    "doom_loop_threshold",
2904    "additional_dirs",
2905    "extra_headers",
2906    "extra_body",
2907    "model_switch",
2908    "retry",
2909    "tools",
2910    "skills",
2911    "prompts",
2912    "compaction",
2913    "session",
2914    "steering",
2915    "output",
2916];
2917
2918/// §3.5 step 5 (strict branch): unknown-key detection under `schema_version
2919/// = 1`. **Bounded scope, documented rather than a silent gap:** checks the
2920/// top-level keys, `[core]`'s direct keys, and `[capabilities.*]`'s module
2921/// names against the known sets. Does NOT recurse into a `[core.tools.*]`/
2922/// `[core.compaction]`/etc. sub-table's own keys, or into any one
2923/// capability's `settings` — both are forward-extensible by design (new
2924/// module settings ship without a `schema_version` bump), and P2's mandate
2925/// is the resolver + preset table, not an exhaustive schema linter (left
2926/// for a future pass if deeper strictness is wanted).
2927fn unknown_keys(text: &str) -> Result<Vec<String>, HarnessConfigError> {
2928    let value: toml::Value = toml::from_str(text).map_err(HarnessConfigError::Toml)?;
2929    let mut out = Vec::new();
2930    let Some(tbl) = value.as_table() else {
2931        return Ok(out);
2932    };
2933    for k in tbl.keys() {
2934        if !KNOWN_TOP_KEYS.contains(&k.as_str()) {
2935            out.push(k.clone());
2936        }
2937    }
2938    if let Some(core) = tbl.get("core").and_then(|v| v.as_table()) {
2939        for k in core.keys() {
2940            if !KNOWN_CORE_KEYS.contains(&k.as_str()) {
2941                out.push(format!("core.{k}"));
2942            }
2943        }
2944    }
2945    if let Some(caps) = tbl.get("capabilities").and_then(|v| v.as_table()) {
2946        for k in caps.keys() {
2947            if !MODULE_NAMES.contains(&k.as_str()) {
2948                out.push(format!("capabilities.{k}"));
2949            }
2950        }
2951    }
2952    Ok(out)
2953}
2954
2955/// §3.5 steps 1-3: parse `name_or_path`, recurse on its own `extends`, and
2956/// fold the chain root-first (deepest ancestor = lowest priority — each
2957/// recursive call's result is the parent, which the current node overlays).
2958/// `allow_path` gates whether an unrecognized name may be treated as a file
2959/// path (§3.3: user/global layer only — a project layer must pass `false`).
2960fn resolve_preset_chain(
2961    name_or_path: &str,
2962    allow_path: bool,
2963    base_dir: Option<&std::path::Path>,
2964    depth: usize,
2965    seen: &mut Vec<String>,
2966) -> Result<HarnessConfig, ResolveError> {
2967    if depth > MAX_EXTENDS_DEPTH {
2968        return Err(ResolveError::DepthExceeded(seen.clone()));
2969    }
2970    if seen.iter().any(|s| s == name_or_path) {
2971        let mut chain = seen.clone();
2972        chain.push(name_or_path.to_string());
2973        return Err(ResolveError::Cycle(chain));
2974    }
2975    seen.push(name_or_path.to_string());
2976
2977    // `next_base_dir` is the directory a LOADED FILE's own (possibly
2978    // relative) `extends` should resolve against — its own parent
2979    // directory, not the top-level caller's `base_dir`. A built-in preset
2980    // has no filesystem location, so it inherits whatever `base_dir` was
2981    // already in play (built-ins only ever `extends` other built-ins by
2982    // name, never a path, so this is never actually consulted for them).
2983    let (hc, next_base_dir): (HarnessConfig, Option<std::path::PathBuf>) =
2984        if let Some(toml_text) = crate::presets::lookup(name_or_path) {
2985            (
2986                HarnessConfig::from_toml_str(toml_text).map_err(ResolveError::Parse)?,
2987                base_dir.map(std::path::Path::to_path_buf),
2988            )
2989        } else {
2990            if !allow_path {
2991                return Err(ResolveError::PathExtendsNotAllowed(
2992                    name_or_path.to_string(),
2993                ));
2994            }
2995            let path = match base_dir {
2996                Some(dir) => dir.join(name_or_path),
2997                None => std::path::PathBuf::from(name_or_path),
2998            };
2999            let text = std::fs::read_to_string(&path)
3000                .map_err(|e| ResolveError::Io(path.clone(), e.to_string()))?;
3001            let hc = HarnessConfig::from_toml_str(&text).map_err(ResolveError::Parse)?;
3002            let dir = path.parent().map(std::path::Path::to_path_buf);
3003            (hc, dir)
3004        };
3005
3006    match hc.extends.clone() {
3007        Some(parent_ref) => {
3008            let parent = resolve_preset_chain(
3009                &parent_ref,
3010                allow_path,
3011                next_base_dir.as_deref(),
3012                depth + 1,
3013                seen,
3014            )?;
3015            Ok(parent.overlay(&hc))
3016        }
3017        None => Ok(hc),
3018    }
3019}
3020
3021/// §3.5 step 7: fold `[capabilities.permissions]`'s sandbox/approval/
3022/// auto_approved_tools, `[capabilities.deferred_tools]`, and
3023/// `[capabilities.cache]` into a [`ConfigProfile`] alongside
3024/// [`HarnessConfig::to_config_profile`]'s `[core]` fields, then materialize
3025/// one [`Config`] via the existing (fail-safe) [`ConfigBuilder::apply_profile`]
3026/// — extending P1's `[core]`-only resolution to the specific pre-existing
3027/// `Config` fields P2's validation needs (sandbox/approval for C3,
3028/// tool_advertising for `deferred_tools`, cache_plan for `cache`). Full
3029/// module-driven `ToolRegistry` construction (which TOOLS get registered)
3030/// stays P3 (design §5.2 P3: `ToolRegistry::from_config`) — this only
3031/// resolves fields `Config` already has a slot for.
3032fn materialize_config(hc: &HarnessConfig) -> Config {
3033    let mut profile = hc.to_config_profile();
3034    if let Some(cap) = hc.capabilities.get("permissions") {
3035        match cap.settings.get("sandbox") {
3036            Some(serde_json::Value::String(s)) => profile.sandbox = Some(s.clone()),
3037            Some(serde_json::Value::Object(o)) => {
3038                if let Some(t) = o.get("tier").and_then(|v| v.as_str()) {
3039                    profile.sandbox = Some(t.to_string());
3040                }
3041            }
3042            _ => {}
3043        }
3044        if let Some(a) = cap.settings.get("approval").and_then(|v| v.as_str()) {
3045            profile.approval = Some(a.to_string());
3046        }
3047        if let Some(list) = cap
3048            .settings
3049            .get("auto_approved_tools")
3050            .and_then(|v| v.as_array())
3051        {
3052            profile.auto_approved_tools = Some(
3053                list.iter()
3054                    .filter_map(|x| x.as_str().map(String::from))
3055                    .collect(),
3056            );
3057        }
3058        // P4 (design §5.2 "P4"): `capabilities.permissions.rules.deny`/
3059        // `.allow` — the S-sized pattern generalization of
3060        // `auto_approved_tools`, read at the same unconditional-on-`cap`
3061        // level as `auto_approved_tools` above (not gated on
3062        // `permissions.rules.enabled`, matching that sibling field's own
3063        // precedent). The full deny→ask→allow priority ENGINE (module 11)
3064        // stays P5 — this only resolves the two arrays into glob-pattern
3065        // lists `Config::needs_approval` consults. Shared with the CLI's
3066        // own `FileConfig`-driven `build_config` via
3067        // `permissions_rules_patterns`, same pattern as
3068        // `model_catalog::resolve`.
3069        let (deny, allow) = permissions_rules_patterns(cap);
3070        if !deny.is_empty() {
3071            profile.tool_deny_patterns = Some(deny);
3072        }
3073        if !allow.is_empty() {
3074            profile.tool_allow_patterns = Some(allow);
3075        }
3076    }
3077    if let Some(cap) = hc.capabilities.get("deferred_tools") {
3078        if cap.enabled == Some(true) {
3079            profile.tool_advertising = Some("deferred".to_string());
3080            profile.tool_advertising_core = deferred_tools_core(cap);
3081        }
3082    }
3083    if let Some(cap) = hc.capabilities.get("cache") {
3084        if cap.enabled == Some(true) {
3085            profile.cache_plan = cache_plan_str(cap);
3086            // BP-4: `warnings` is the module's second knob (§3.1
3087            // `capabilities.cache.warnings`) and was parsed-and-dropped —
3088            // the churn warnings a cache plan exists to make legible are
3089            // exactly what an operator turns off when they don't want them.
3090            profile.cache_warnings = cap.settings.get("warnings").and_then(|v| v.as_bool());
3091        }
3092    }
3093    let mut config = ConfigBuilder::default().apply_profile(&profile).build();
3094
3095    // BP-1: module activation is the DEFAULT path for anything that came
3096    // through this resolver. A `HarnessConfig` is precisely the artifact
3097    // that states which modules are on, so a Config materialized from one
3098    // carries `module_registry = true` and lets
3099    // `ToolRegistry::from_config` (and prompt assembly, and the CLI's MCP
3100    // attach) consult `module_activation`/`core_tools_enabled` — otherwise
3101    // a preset's `[capabilities.tools_web]`/`[core.tools] enabled` would
3102    // resolve, warn, be golden-tested, and then be silently discarded at
3103    // the one place it is supposed to bite.
3104    //
3105    // `[experimental] module_registry = false` remains as an explicit
3106    // OPT-OUT (the only value that still matters): it pins a config back
3107    // to the unfiltered `with_builtins()` stack. A hand-built
3108    // `Config::default()` never passes through here and keeps
3109    // `module_registry = false`, so SDK embedders who never wrote a
3110    // `HarnessConfig` are untouched.
3111    config.module_registry = experimental_opt_in(hc, "module_registry");
3112    config.module_activation = crate::modules::ModuleActivation::from_harness(hc);
3113    config.core_tools_enabled = effective_tools_enabled(hc);
3114    config.skills_enabled = hc.core.skills.enabled.unwrap_or(false);
3115    // BP-6 (catalog D7 "Skill discovery from multiple roots"): the preset
3116    // NAMES whose root table the loop reads, so `cc-parity` discovers
3117    // SKILL.md the way Claude Code does and `cx-parity` the way Codex does.
3118    config.skills_harness = hc.core.skills.harness.clone();
3119    config.skills_dirs = hc
3120        .core
3121        .skills
3122        .dirs
3123        .clone()
3124        .unwrap_or_default()
3125        .into_iter()
3126        .map(std::path::PathBuf::from)
3127        .collect();
3128    config.skills_implicit_match = hc.core.skills.implicit_match.unwrap_or(false);
3129    // BP-5 (cc§7 "Dynamic context injection"): `!`cmd`` at body-load time.
3130    config.skills_shell_injection = hc.core.skills.shell_injection.unwrap_or(false);
3131    // BP-5: the three prompt-assembly inputs (`@path` mentions, the output
3132    // style, path-scoped rule files). Each is absent by default, so a config
3133    // that says nothing assembles byte-identically to before BP-5.
3134    config.file_mentions = hc.core.file_mentions.unwrap_or(false);
3135    config.output_style = hc.core.output_style.clone().unwrap_or_default();
3136    config.path_rules = hc.core.path_rules.unwrap_or(false);
3137
3138    if let Some(cap) = hc.capabilities.get("reduction") {
3139        let setting = |name: &str| cap.settings.get(name).and_then(|v| v.as_bool());
3140        config.reduction_policy = crate::config::ReductionPolicySettings {
3141            stale_reads: setting("stale_reads"),
3142            diff_reads: setting("diff_reads"),
3143            duplicates: setting("duplicates"),
3144            tool_input_elision: setting("tool_input_elision"),
3145            supersede: setting("supersede"),
3146            normalize_output: setting("normalize_output"),
3147            image_redaction: setting("image_redaction"),
3148            span_summaries: setting("span_summaries"),
3149        };
3150        // The module's `enabled` bit is the documented master switch for all
3151        // optional reduction policies, including the separate offline
3152        // handoff consumer. Preserve legacy availability when the master is
3153        // absent, but an explicit master-off must dominate inherited
3154        // `handoff = true` from a preset.
3155        config.handoff_enabled = cap.enabled.unwrap_or(true) && setting("handoff").unwrap_or(true);
3156    }
3157
3158    // P5-1 (design §2 modules 10-13, §5.3 risk 1's mitigation recipe): the
3159    // permissions ENGINE's runtime fields — carried on `Config` the same
3160    // "pure config → set" way the P3 module-activation fields just above
3161    // are, so `Agent::prepare_tool_call`'s gate can consult them without
3162    // re-walking `HarnessConfig`. `capabilities.permissions.enabled` (module
3163    // 10) is the master gate: `false` (the default, matching every
3164    // `HarnessConfig` that never sets this table) leaves every one of these
3165    // fields at `Config::default()`'s zero value, and
3166    // `Agent::prepare_tool_call` falls through to the pre-P5-1
3167    // `Config::needs_approval` gate byte-for-byte — see that method's doc
3168    // comment.
3169    if let Some(cap) = hc.capabilities.get("permissions") {
3170        config.permissions_enabled = cap.enabled.unwrap_or(false);
3171        config.permissions_ask_patterns = permissions_rules_ask_patterns(cap);
3172        config.permissions_protected_paths = permissions_protected_paths(cap);
3173        config.network_policy = permissions_network_policy(cap);
3174        // BP-10 (§2 module 10, catalog row "Session approval caching"):
3175        // `capabilities.permissions.approvals.persist` — whether an
3176        // `AllowForSession` grant is remembered across processes. Absent
3177        // (the default) is `false`: the pre-BP-10 in-memory cache, no file
3178        // touched. Read at the same unconditional-on-`cap` level as
3179        // `auto_approved_tools`/`network_policy` above.
3180        config.permissions_approvals_persist = cap
3181            .settings
3182            .get("approvals")
3183            .and_then(|v| v.as_object())
3184            .and_then(|o| o.get("persist"))
3185            .and_then(serde_json::Value::as_bool)
3186            .unwrap_or(false);
3187        // P5-10 (§2 module 12): the OS-level sandbox backstop's own knobs
3188        // — populated unconditionally here (same "pure config → set"
3189        // treatment as `network_policy` just above, NOT gated on
3190        // `capabilities.permissions.enabled`/`cap.enabled` — that master
3191        // gate is module 10/11's rule-ENGINE activation switch;
3192        // `capabilities.permissions.sandbox.enabled` is module 12's own,
3193        // independent gate, exactly like `network.enabled` already is for
3194        // `NetworkPolicy`).
3195        config.sandbox_os_enabled = permissions_sandbox_os_enabled(cap);
3196        config.sandbox_escalation = permissions_sandbox_escalation(cap);
3197        config.sandbox_env_policy = permissions_sandbox_env_policy(cap);
3198    }
3199
3200    // P5-3 (design §2 module 9, §2.1 D-1, §2.2 C6): the subagents ENGINE's
3201    // runtime fields — same "pure config → set" carry-forward as P5-1's
3202    // permissions block just above. `capabilities.subagents.enabled`
3203    // (`false`, the default, matching every `HarnessConfig` that never sets
3204    // this table) leaves every field below at `Config::default()`'s zero
3205    // value, and `Agent::tool_schemas`/`Agent::run_tool` never advertise or
3206    // intercept `spawn_subagent`/`subagent_status` at all — byte-identical
3207    // to today's no-subagents behavior.
3208    // BP-7 (§2 module 7, §3.1 `capabilities.todos.goals`): the persistent-
3209    // objective variant of the checklist module. Off unless `todos` is
3210    // enabled AND the sub-key is set, so a preset that only wants the
3211    // `update_plan` tool is untouched.
3212    config.goals_enabled = module_enabled(hc, "todos") && module_setting_bool(hc, "todos", "goals");
3213
3214    if let Some(cap) = hc.capabilities.get("subagents") {
3215        config.subagents_enabled = cap.enabled.unwrap_or(false);
3216        config.subagents_max_depth = cap
3217            .settings
3218            .get("max_depth")
3219            .and_then(serde_json::Value::as_u64)
3220            .map(|n| n as usize)
3221            .unwrap_or(2);
3222        config.subagents_max_concurrent = cap
3223            .settings
3224            .get("max_concurrent")
3225            .and_then(serde_json::Value::as_u64)
3226            .map(|n| n as usize)
3227            .unwrap_or(4);
3228        config.subagents_background = module_setting_bool(hc, "subagents", "background");
3229        config.subagents_background_prompts = cap
3230            .settings
3231            .get("background_prompts")
3232            .and_then(serde_json::Value::as_str)
3233            .and_then(crate::subagents::BackgroundPromptsPolicy::parse);
3234        config.subagents_definitions = subagent_definitions(cap);
3235    }
3236
3237    // P5-4 (design §2 module 30, §1.9, §3.1 `capabilities.tui`): the TUI's
3238    // own activation + display settings — same "pure config → set" carry-
3239    // forward as the P5-1/P5-3 blocks above. `capabilities.tui.enabled`
3240    // (`false`, the default, matching every `HarnessConfig` that never sets
3241    // this table) leaves `Config::tui_enabled` at `false`, and
3242    // `crates/cli`'s `chat()` runs the pre-P5-4 rustyline REPL loop
3243    // byte-for-byte — see `Config::tui_enabled`'s doc comment.
3244    if let Some(cap) = hc.capabilities.get("tui") {
3245        config.tui_enabled = cap.enabled.unwrap_or(false);
3246        if let Some(theme) = cap
3247            .settings
3248            .get("theme")
3249            .and_then(serde_json::Value::as_str)
3250        {
3251            config.tui_theme = theme.to_string();
3252        }
3253        config.tui_vim_mode = cap
3254            .settings
3255            .get("vim_mode")
3256            .and_then(serde_json::Value::as_bool)
3257            .unwrap_or(false);
3258        if let Some(keymap) = cap.settings.get("keymap").and_then(|v| v.as_object()) {
3259            config.tui_keymap = keymap
3260                .iter()
3261                .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
3262                .collect();
3263        }
3264    }
3265
3266    // BP-8 (catalog:156 "Todos/plan persisted per session"): both parity
3267    // presets already set `[capabilities.todos] persist = true`; before
3268    // BP-8 nothing read it, so the plan lived in `UpdatePlanTool`'s own
3269    // mutex and died with the process. Materialize it onto `Config` so the
3270    // agent's plan writer has a gate to consult.
3271    if let Some(cap) = hc.capabilities.get("todos") {
3272        config.todos_persist = cap
3273            .settings
3274            .get("persist")
3275            .and_then(serde_json::Value::as_bool)
3276            .unwrap_or(false)
3277            && cap.enabled.unwrap_or(false);
3278    }
3279
3280    // P5-5 (design §2 module 21 `session.tree`, §3.1): the tree module's
3281    // advisory config fields — same "pure config → set" carry-forward as
3282    // P5-1/P5-3 above. `capabilities.session_tree.enabled` (`false`, the
3283    // default, matching every `HarnessConfig` that never sets this table)
3284    // leaves every field below at `Config::default()`'s zero value;
3285    // `crate::session_tree::SessionTree` itself has no runtime dependency on
3286    // any of these (see `Config::session_tree_enabled`'s doc comment), so
3287    // this block changes no BEHAVIOR — only what a future CLI/TUI caller can
3288    // read off the resolved `Config`.
3289    if let Some(cap) = hc.capabilities.get("session_tree") {
3290        config.session_tree_enabled = cap.enabled.unwrap_or(false);
3291        // §3.1's own schema default is `true` for both sub-flags when the
3292        // table is present but a key is unset — same shape as
3293        // `modules::tools_search_subflag`/`tools_web_subflag`.
3294        config.session_tree_branch_summaries = cap
3295            .settings
3296            .get("branch_summaries")
3297            .and_then(serde_json::Value::as_bool)
3298            .unwrap_or(true);
3299        config.session_tree_labels = cap
3300            .settings
3301            .get("labels")
3302            .and_then(serde_json::Value::as_bool)
3303            .unwrap_or(true);
3304    }
3305
3306    // P5-6 (design §2 module 4, §2.1 "tools.background → permissions.
3307    // approvals(auto-policy) [C6 as dep]", §2.2 C6): the tools_background
3308    // ENGINE's runtime fields — same "pure config → set" carry-forward as
3309    // the subagents block just above. `capabilities.tools_background.
3310    // enabled` (`false`, the default, matching every `HarnessConfig` that
3311    // never sets this table) leaves every field below at
3312    // `Config::default()`'s zero value, and `Agent::tool_schemas`/
3313    // `Agent::prepare_tool_call` never advertise or intercept
3314    // `background_exec`/`background_status`/`background_list`/
3315    // `background_kill` at all — byte-identical to today's no-
3316    // tools_background behavior. Note: the module's own C6 auto-policy
3317    // reuses `capabilities.subagents.background_prompts` (already parsed
3318    // above into `config.subagents_background_prompts`) rather than a
3319    // second key — see `Agent::background_permission_denial`'s doc comment
3320    // and `validate_modules`'s C6 check just below, both of which treat
3321    // that ONE schema key (module 9's) as covering both modules, exactly
3322    // as design §2.2 C6 states ("both values are §3.1 schema keys (module
3323    // 9)").
3324    if let Some(cap) = hc.capabilities.get("tools_background") {
3325        config.tools_background_enabled = cap.enabled.unwrap_or(false);
3326        config.tools_background_max_concurrent = cap
3327            .settings
3328            .get("max_concurrent")
3329            .and_then(serde_json::Value::as_u64)
3330            .map(|n| n as usize)
3331            .unwrap_or(crate::background::DEFAULT_MAX_CONCURRENT);
3332        config.tools_background_max_output_bytes = cap
3333            .settings
3334            .get("max_output_bytes")
3335            .and_then(serde_json::Value::as_u64)
3336            .map(|n| n as usize)
3337            .unwrap_or(crate::background::DEFAULT_MAX_OUTPUT_BYTES);
3338    }
3339
3340    // P5-9 (design §2 module 20 `checkpoint`, §3.1): the checkpoint
3341    // module's ENGINE-consumed fields — same "pure config → set" carry-
3342    // forward as the `tools_background` block just above.
3343    // `capabilities.checkpoint.enabled` (`false`, the default, matching
3344    // every `HarnessConfig` that never sets this table) leaves
3345    // `config.checkpoint_enabled` at `Config::default()`'s `false`, and
3346    // `crate::agent::build_tool_context`/`crate::checkpoint::observer_for_config`
3347    // then never touch disk at all — no shadow store, no
3348    // `ToolContext::write_observer` — byte-identical to before this module
3349    // existed. `retain` is NOT in the §3.1 illustrative schema snippet
3350    // (only `{ enabled = false }` is shown there) but IS a real, wired
3351    // knob — see `Config::checkpoint_retain`'s doc comment — never a
3352    // declared-but-dead key.
3353    if let Some(cap) = hc.capabilities.get("checkpoint") {
3354        config.checkpoint_enabled = cap.enabled.unwrap_or(false);
3355        config.checkpoint_retain = cap
3356            .settings
3357            .get("retain")
3358            .and_then(serde_json::Value::as_u64)
3359            .map(|n| n as usize)
3360            .unwrap_or(crate::checkpoint::DEFAULT_RETAIN);
3361        // BP-7 (§3.1 `capabilities.checkpoint.restore`): absent means
3362        // `true` — the pre-BP-7 behavior for every config that turns the
3363        // module on. `false` is the turn-diff-only posture (cx-parity).
3364        config.checkpoint_restore = cap
3365            .settings
3366            .get("restore")
3367            .and_then(serde_json::Value::as_bool)
3368            .unwrap_or(true);
3369    }
3370
3371    // P5-11 (§2 module 28 `lsp`): `capabilities.lsp` — the ENGINE-consumed
3372    // fields, same "pure config -> set" carry-forward as `checkpoint`
3373    // above. `capabilities.lsp.enabled` (`false`, the default, matching
3374    // every `HarnessConfig` that never sets this table) leaves
3375    // `config.lsp_enabled` at `Config::default()`'s `false`, and
3376    // `crate::agent::build_tool_context`/`crate::lsp::manager_for_config`
3377    // then never spawn a process at all — byte-identical to before this
3378    // module existed.
3379    if let Some(cap) = hc.capabilities.get("lsp") {
3380        config.lsp_enabled = cap.enabled.unwrap_or(false);
3381        config.lsp_servers = lsp_servers_from_settings(&cap.settings);
3382        config.lsp_max_diagnostics = cap
3383            .settings
3384            .get("max_diagnostics")
3385            .and_then(serde_json::Value::as_u64)
3386            .map(|n| n as usize)
3387            .unwrap_or(crate::lsp::DEFAULT_LSP_MAX_DIAGNOSTICS);
3388        config.lsp_timeout_secs = cap
3389            .settings
3390            .get("timeout_secs")
3391            .and_then(serde_json::Value::as_u64)
3392            .unwrap_or(crate::lsp::DEFAULT_LSP_TIMEOUT_SECS);
3393    }
3394
3395    // P5-11 (§2 module 29 `formatters`, C10): `capabilities.formatters` —
3396    // same carry-forward as `lsp` just above. `enabled = false` (the
3397    // default) leaves the shared D-5 write-observer chain without a
3398    // `FormatObserver` entry at all. `diff_back` defaults to `true` (C10-
3399    // SAFE) matching the design's own `[capabilities.formatters] { enabled
3400    // = false, diff_back = true }` default line (§3.1) — a config that sets
3401    // `enabled = true` but never touches `diff_back` still gets the safe
3402    // default, not an accidental `false`.
3403    if let Some(cap) = hc.capabilities.get("formatters") {
3404        config.formatters_enabled = cap.enabled.unwrap_or(false);
3405        config.formatters_diff_back = cap
3406            .settings
3407            .get("diff_back")
3408            .and_then(serde_json::Value::as_bool)
3409            .unwrap_or(true);
3410        config.formatters_timeout_secs = cap
3411            .settings
3412            .get("timeout_secs")
3413            .and_then(serde_json::Value::as_u64)
3414            .unwrap_or(crate::formatters::DEFAULT_FORMATTER_TIMEOUT_SECS);
3415        config.formatters = formatters_from_settings(&cap.settings);
3416    }
3417
3418    // P5-12 (§2 module 14 `trust`): `capabilities.trust` — the master gate
3419    // + decision `crate::plugins::is_trusted` reads. `enabled = false` (the
3420    // default, matching every `HarnessConfig` that never sets this table)
3421    // leaves `config.trust_enabled` at `Config::default()`'s `false`, so
3422    // `is_trusted` is always `false` regardless of `trust_default` — same
3423    // "master gate first" carry-forward as every other P5 module.
3424    if let Some(cap) = hc.capabilities.get("trust") {
3425        config.trust_enabled = cap.enabled.unwrap_or(false);
3426        config.trust_default = cap
3427            .settings
3428            .get("default")
3429            .and_then(serde_json::Value::as_str)
3430            .and_then(crate::plugins::TrustDecision::parse)
3431            .unwrap_or_default(); // TrustDecision::Ask — fails closed on an
3432                                  // unset/unparseable value, never `Always`.
3433    }
3434
3435    // P5-12 (§2 module 18 `plugins`, D-10): `capabilities.plugins` — the
3436    // ENGINE-consumed fields `crate::plugins::discover_and_load` reads.
3437    // `enabled = false` (the default) leaves `config.plugins_enabled` at
3438    // `Config::default()`'s `false`, and `crate::agent::Agent::with_parts`
3439    // never calls `crate::plugins::register_into` at all — no directory
3440    // read, no manifest parse, no subprocess — byte-identical to before
3441    // this module existed. `[capabilities.plugins]` (this whole table) is
3442    // project-forbidden (`PROJECT_FORBIDDEN_CAPABILITY_TABLES` above), so
3443    // `dirs` can only ever reach here from the trusted user/global layer.
3444    if let Some(cap) = hc.capabilities.get("plugins") {
3445        config.plugins_enabled = cap.enabled.unwrap_or(false);
3446        config.plugins_dirs = string_array(cap.settings.get("dirs"))
3447            .into_iter()
3448            .map(std::path::PathBuf::from)
3449            .collect();
3450    }
3451
3452    // P4 (design §5.2 "P4"): `capabilities.model_catalog` — alias
3453    // resolution (promoted into core, `crate::model_catalog`) for
3454    // `core.model`, plus the `small_model`/`fallback` knobs. See
3455    // `model_catalog::resolve`'s doc comment for why this is consulted
3456    // regardless of `capabilities.model_catalog.enabled`.
3457    // BP-13 (§3.1 `capabilities.plan_mode.effort`): the effort tier plan
3458    // mode runs at. Read from the module's own table — a per-mode routing
3459    // rule, resolved and clamped by the same routing path as every other
3460    // effort decision (see `Agent::apply_routing`).
3461    if let Some(cap) = hc.capabilities.get("plan_mode") {
3462        config.plan_mode_effort = cap
3463            .settings
3464            .get("effort")
3465            .and_then(|v| v.as_str())
3466            .filter(|e| !e.is_empty())
3467            .map(str::to_string);
3468    }
3469
3470    // BP-13: the SAME call now also carries the whole routing table forward
3471    // (aliases incl. patterns/provider/account scopes, per-model effort and
3472    // thinking budgets, service tiers, tool-shape capability bits, and the
3473    // config-layer allow/deny lists). One resolution, one table, every
3474    // consumer downstream reading `Config::model_routing`.
3475    let mc = crate::model_catalog::resolve(&hc.capabilities, &config.model);
3476    config.model = mc.model;
3477    config.small_model = mc.small_model;
3478    config.model_fallback = mc.fallback;
3479    // BP-5 (catalog D2 "Per-model-family base-prompt selection"): the
3480    // per-family base prompts travel with the rest of the catalog's data.
3481    // Selection itself happens at prompt assembly (`Agent::with_parts`) and
3482    // again on `Agent::set_model`, because it depends on the model in force.
3483    config.model_family_prompts = mc.base_prompts;
3484    config.model_routing = mc.routing;
3485
3486    config
3487}
3488
3489/// P5-11 (`capabilities.lsp.servers.<name>`): parse the nested `servers`
3490/// table into `(name, LspServerSpec)` pairs, alphabetical by name (see
3491/// `Config::lsp_servers`'s doc comment for why). An entry missing a
3492/// string `command` is skipped (malformed, not a crash) — `args`/
3493/// `extensions` default to empty when absent or the wrong shape.
3494fn lsp_servers_from_settings(
3495    settings: &serde_json::Map<String, serde_json::Value>,
3496) -> Vec<(String, crate::lsp::LspServerSpec)> {
3497    let Some(servers) = settings.get("servers").and_then(|v| v.as_object()) else {
3498        return Vec::new();
3499    };
3500    let mut names: Vec<&String> = servers.keys().collect();
3501    names.sort();
3502    names
3503        .into_iter()
3504        .filter_map(|name| {
3505            let def = servers.get(name)?.as_object()?;
3506            let command = def.get("command")?.as_str()?.to_string();
3507            let args = string_array(def.get("args"));
3508            let extensions = string_array(def.get("extensions"));
3509            Some((
3510                name.clone(),
3511                crate::lsp::LspServerSpec {
3512                    command,
3513                    args,
3514                    extensions,
3515                },
3516            ))
3517        })
3518        .collect()
3519}
3520
3521/// P5-11 (`capabilities.formatters.<name>`): parse every OTHER key in the
3522/// `[capabilities.formatters]` table (i.e. every key besides the two
3523/// recognized scalars `diff_back`/`timeout_secs`) as a formatter
3524/// definition — mirrors the design's own schema shape
3525/// (`[capabilities.formatters.<name>] command=... extensions=[...]`,
3526/// SIBLINGS of `enabled`/`diff_back`, unlike `lsp`'s nested `servers`
3527/// table). Alphabetical by name, same rationale as
3528/// [`lsp_servers_from_settings`].
3529fn formatters_from_settings(
3530    settings: &serde_json::Map<String, serde_json::Value>,
3531) -> Vec<(String, crate::formatters::FormatterSpec)> {
3532    const RESERVED: &[&str] = &["diff_back", "timeout_secs"];
3533    let mut names: Vec<&String> = settings
3534        .keys()
3535        .filter(|k| !RESERVED.contains(&k.as_str()))
3536        .collect();
3537    names.sort();
3538    names
3539        .into_iter()
3540        .filter_map(|name| {
3541            let def = settings.get(name)?.as_object()?;
3542            let command = def.get("command")?.as_str()?.to_string();
3543            let args = string_array(def.get("args"));
3544            let extensions = string_array(def.get("extensions"));
3545            Some((
3546                name.clone(),
3547                crate::formatters::FormatterSpec {
3548                    command,
3549                    args,
3550                    extensions,
3551                },
3552            ))
3553        })
3554        .collect()
3555}
3556
3557/// Shared helper: a JSON array of strings, or an empty `Vec` for anything
3558/// else (absent, wrong shape, non-string entries skipped individually).
3559fn string_array(v: Option<&serde_json::Value>) -> Vec<String> {
3560    v.and_then(|v| v.as_array())
3561        .map(|a| {
3562            a.iter()
3563                .filter_map(|x| x.as_str().map(String::from))
3564                .collect()
3565        })
3566        .unwrap_or_default()
3567}
3568
3569/// P4 (design §5.2 "P4"): read `capabilities.permissions.rules.deny`/
3570/// `.allow` (module 11's two pattern arrays) into `(deny, allow)` glob
3571/// pattern lists — the S-sized generalization of `auto_approved_tools`
3572/// this phase lands, NOT the full P5 deny→ask→allow priority engine. `cap`
3573/// is the already-fetched `capabilities.permissions` table (both this
3574/// resolver's `materialize_config` and the CLI's own `build_config` fetch
3575/// it themselves first, since each has a different container type to fetch
3576/// it FROM — a `HarnessConfig` vs a `BTreeMap` on `FileConfig`). Empty
3577/// `Vec`s when the table or either key is absent — the default,
3578/// byte-identical-to-today shape.
3579pub fn permissions_rules_patterns(cap: &CapabilityConfig) -> (Vec<String>, Vec<String>) {
3580    let Some(rules) = cap.settings.get("rules").and_then(|v| v.as_object()) else {
3581        return (Vec::new(), Vec::new());
3582    };
3583    let deny = rules
3584        .get("deny")
3585        .and_then(|v| v.as_array())
3586        .map(|a| {
3587            a.iter()
3588                .filter_map(|x| x.as_str().map(String::from))
3589                .collect()
3590        })
3591        .unwrap_or_default();
3592    let allow = rules
3593        .get("allow")
3594        .and_then(|v| v.as_array())
3595        .map(|a| {
3596            a.iter()
3597                .filter_map(|x| x.as_str().map(String::from))
3598                .collect()
3599        })
3600        .unwrap_or_default();
3601    (deny, allow)
3602}
3603
3604/// P5-1 (design §2 module 11, §3.1 `capabilities.permissions.rules.ask`):
3605/// the `ask` sibling of [`permissions_rules_patterns`]'s `deny`/`allow` —
3606/// kept as its own function (rather than folded into that one) since only
3607/// the P5-1 engine consults `ask` at all; `Config::needs_approval` (the
3608/// legacy gate) has no `ask` concept, so `permissions_rules_patterns`
3609/// staying deny/allow-only keeps its existing callers (including the CLI's
3610/// `build_config`) untouched.
3611pub fn permissions_rules_ask_patterns(cap: &CapabilityConfig) -> Vec<String> {
3612    cap.settings
3613        .get("rules")
3614        .and_then(|v| v.as_object())
3615        .and_then(|rules| rules.get("ask"))
3616        .and_then(|v| v.as_array())
3617        .map(|a| {
3618            a.iter()
3619                .filter_map(|x| x.as_str().map(String::from))
3620                .collect()
3621        })
3622        .unwrap_or_default()
3623}
3624
3625/// P5-1 (design §2 module 13, §3.1
3626/// `capabilities.permissions.protected_paths.paths`): read the protected-
3627/// paths glob list — unconditional-on-`cap` like `auto_approved_tools`/
3628/// `permissions_rules_patterns` above (not gated on
3629/// `permissions.protected_paths.enabled`, same sibling-field precedent);
3630/// [`crate::permissions::rules::protected_path_deny_rules`] is what expands
3631/// this list into the engine's actual `deny` tier at the gate.
3632pub fn permissions_protected_paths(cap: &CapabilityConfig) -> Vec<String> {
3633    cap.settings
3634        .get("protected_paths")
3635        .and_then(|v| v.as_object())
3636        .and_then(|pp| pp.get("paths"))
3637        .and_then(|v| v.as_array())
3638        .map(|a| {
3639            a.iter()
3640                .filter_map(|x| x.as_str().map(String::from))
3641                .collect()
3642        })
3643        .unwrap_or_default()
3644}
3645
3646/// P5-3 (design §2 module 9, §3.1 `capabilities.subagents.agents.<name>`,
3647/// D3 "named-defs"): parse the named-subagent-definition sub-table into
3648/// [`crate::subagents::NamedAgentDefinition`]s, keyed by name. Missing or
3649/// malformed fields degrade gracefully (an entry with no `system_prompt`
3650/// gets an empty one — the caller falls back to the parent's own system
3651/// prompt, see `Agent::run_spawn_subagent`) rather than erroring the whole
3652/// resolve — a config-shape mistake here is a weaker agent definition, not
3653/// a security-relevant silent-allow (unlike the permissions-layer
3654/// case-sensitivity carry-forward elsewhere in this file).
3655pub fn subagent_definitions(
3656    cap: &CapabilityConfig,
3657) -> std::collections::HashMap<String, crate::subagents::NamedAgentDefinition> {
3658    let mut out = std::collections::HashMap::new();
3659    let Some(agents) = cap.settings.get("agents").and_then(|v| v.as_object()) else {
3660        return out;
3661    };
3662    for (name, def) in agents {
3663        let Some(obj) = def.as_object() else { continue };
3664        let system_prompt = obj
3665            .get("system_prompt")
3666            .and_then(|v| v.as_str())
3667            .unwrap_or("")
3668            .to_string();
3669        let tools = obj.get("tools").and_then(|v| v.as_array()).map(|a| {
3670            a.iter()
3671                .filter_map(|x| x.as_str().map(String::from))
3672                .collect::<Vec<_>>()
3673        });
3674        let model = obj.get("model").and_then(|v| v.as_str()).map(String::from);
3675        // BP-7 (catalog §4a "Named agent definitions as data"): the
3676        // `permissions` component of the row's own semantics
3677        // (`prompt+model+tools+permissions`). Tightening-only — see
3678        // `crate::subagents::AgentPermissions`.
3679        let permissions = obj
3680            .get("permissions")
3681            .and_then(|v| v.as_object())
3682            .map(|perms| crate::subagents::AgentPermissions {
3683                approval: perms
3684                    .get("approval")
3685                    .and_then(|v| v.as_str())
3686                    .and_then(parse_approval_str),
3687                sandbox: perms
3688                    .get("sandbox")
3689                    .and_then(|v| v.as_str())
3690                    .and_then(parse_sandbox_str),
3691                auto_approved_tools: perms
3692                    .get("auto_approved_tools")
3693                    .and_then(|v| v.as_array())
3694                    .map(|a| {
3695                        a.iter()
3696                            .filter_map(|x| x.as_str().map(String::from))
3697                            .collect::<Vec<_>>()
3698                    }),
3699                deny: perms
3700                    .get("deny")
3701                    .and_then(|v| v.as_array())
3702                    .map(|a| {
3703                        a.iter()
3704                            .filter_map(|x| x.as_str().map(String::from))
3705                            .collect::<Vec<_>>()
3706                    })
3707                    .unwrap_or_default(),
3708            });
3709        out.insert(
3710            name.clone(),
3711            crate::subagents::NamedAgentDefinition {
3712                name: name.clone(),
3713                system_prompt,
3714                tools,
3715                model,
3716                permissions,
3717            },
3718        );
3719    }
3720    out
3721}
3722
3723/// P5-1 (design §2 module 12 carry-forward, §3.1
3724/// `capabilities.permissions.sandbox.network.*`): give the
3725/// `crate::tools::NetworkPolicy` enforcement point (`ToolContext::check_network`,
3726/// wired since P4c) its real config source. Reads the network sub-table of
3727/// `capabilities.permissions.sandbox` — note this is nested under
3728/// `permissions`, not a separate `permissions.sandbox` capability entry (see
3729/// [`module_enabled`]'s doc comment on the dotted-name convention: nested
3730/// modules 11-13 all live in `permissions`'s own `settings`, never as
3731/// separate `BTreeMap` keys). `None` when `capabilities.permissions.sandbox`
3732/// (the TABLE form; the bare-string tier shorthand has no `network` to read)
3733/// is absent entirely — byte-identical to today's no-policy-configured gap.
3734/// Present-but-`network`-absent still yields `Some(NetworkPolicy::default())`
3735/// (`enabled: false`), which is a harmless no-op — see `NetworkPolicy`'s own
3736/// doc comment (`crate::tools`) on `enabled: false` behaving exactly like
3737/// `None` on the context.
3738pub fn permissions_network_policy(cap: &CapabilityConfig) -> Option<crate::tools::NetworkPolicy> {
3739    let sandbox = cap.settings.get("sandbox")?.as_object()?;
3740    let network = sandbox.get("network").and_then(|v| v.as_object());
3741    let enabled = network
3742        .and_then(|n| n.get("enabled"))
3743        .and_then(|v| v.as_bool())
3744        .unwrap_or(false);
3745    let string_list = |key: &str| -> Vec<String> {
3746        network
3747            .and_then(|n| n.get(key))
3748            .and_then(|v| v.as_array())
3749            .map(|a| {
3750                a.iter()
3751                    .filter_map(|x| x.as_str().map(String::from))
3752                    .collect()
3753            })
3754            .unwrap_or_default()
3755    };
3756    Some(crate::tools::NetworkPolicy {
3757        enabled,
3758        allow_domains: string_list("allow_domains"),
3759        deny_domains: string_list("deny_domains"),
3760    })
3761}
3762
3763/// BP-10 (catalog row "Named permission profiles", semantics "Reusable,
3764/// inheritable permission bundles"; cx§4 `[permissions.<name>]` with
3765/// `extends`): the depth cap on a profile's `extends` chain — the same
3766/// bound [`MAX_EXTENDS_DEPTH`] puts on a config's own preset chain, for
3767/// the same reason.
3768const MAX_PROFILE_EXTENDS_DEPTH: usize = 8;
3769
3770/// BP-10: apply `capabilities.permissions.profile = "<name>"` by folding
3771/// `capabilities.permissions.profiles.<name>` (and everything it
3772/// `extends`, root-first) into the `permissions` table itself. Returns the
3773/// warnings a caller should surface; a profile name that does not exist is
3774/// a warning and a NO-OP, never a silent posture change.
3775///
3776/// **What a bundle may carry**, and how each key folds — the two
3777/// directions are deliberate, and follow
3778/// [`merge_permissions_capability`]'s own monotonic discipline:
3779///
3780/// * `approval`, `sandbox`, `auto_approved_tools` — REPLACE. These are the
3781///   posture the user selected the bundle FOR; a profile that says
3782///   `sandbox = "read_only"` means it.
3783/// * `rules.deny`, `rules.ask`, `protected_paths.paths` — UNION. A bundle
3784///   may ADD a floor; it may never remove one the base layer set. Selecting
3785///   a permission profile is not a way to delete the deny rules a user's
3786///   own config already established.
3787/// * `rules.allow` — REPLACE, but only when the bundle sets it. `allow` is
3788///   the loosening tier, so unioning it would let a permissive bundle
3789///   silently widen a restrictive base; replacing keeps the selected
3790///   bundle's allowlist exactly as written.
3791/// * `extends = "<other profile>"` — the inheritance the row names. Chased
3792///   root-first (the ancestor folds first, the selected profile last), with
3793///   a cycle guard and [`MAX_PROFILE_EXTENDS_DEPTH`].
3794///
3795/// `profile` unset (every config that never names one, including both
3796/// parity presets by default) returns immediately with no warnings and no
3797/// mutation — byte-identical to before this existed.
3798fn apply_permission_profile(hc: &mut HarnessConfig) -> Vec<String> {
3799    let mut warnings = Vec::new();
3800    let Some(cap) = hc.capabilities.get("permissions") else {
3801        return warnings;
3802    };
3803    let Some(selected) = cap.settings.get("profile").and_then(|v| v.as_str()) else {
3804        return warnings;
3805    };
3806    let selected = selected.to_string();
3807    let profiles = cap
3808        .settings
3809        .get("profiles")
3810        .and_then(|v| v.as_object())
3811        .cloned()
3812        .unwrap_or_default();
3813
3814    // Chase `extends`, root-first.
3815    let mut chain: Vec<serde_json::Map<String, serde_json::Value>> = Vec::new();
3816    let mut seen: Vec<String> = Vec::new();
3817    let mut name = selected.clone();
3818    loop {
3819        let Some(body) = profiles.get(&name).and_then(|v| v.as_object()) else {
3820            warnings.push(format!(
3821                "capabilities.permissions.profile = \"{name}\" names no                  [capabilities.permissions.profiles.{name}] table (ignored)"
3822            ));
3823            return warnings;
3824        };
3825        if seen.iter().any(|s| *s == name) {
3826            warnings.push(format!(
3827                "capabilities.permissions.profiles.{name} forms an `extends` cycle                  ({}) — the profile is ignored",
3828                seen.join(" -> ")
3829            ));
3830            return warnings;
3831        }
3832        seen.push(name.clone());
3833        chain.push(body.clone());
3834        if seen.len() > MAX_PROFILE_EXTENDS_DEPTH {
3835            warnings.push(format!(
3836                "capabilities.permissions.profiles.{selected}'s `extends` chain exceeds the                  depth-{MAX_PROFILE_EXTENDS_DEPTH} cap — the profile is ignored"
3837            ));
3838            return warnings;
3839        }
3840        match body.get("extends").and_then(|v| v.as_str()) {
3841            Some(parent) => name = parent.to_string(),
3842            None => break,
3843        }
3844    }
3845    chain.reverse();
3846
3847    let Some(cap) = hc.capabilities.get_mut("permissions") else {
3848        return warnings;
3849    };
3850    for body in &chain {
3851        fold_profile_layer(&mut cap.settings, body);
3852    }
3853    warnings
3854}
3855
3856/// BP-10: fold ONE profile bundle onto the live `permissions` settings —
3857/// see [`apply_permission_profile`]'s doc comment for which keys replace
3858/// and which union, and why.
3859fn fold_profile_layer(
3860    settings: &mut serde_json::Map<String, serde_json::Value>,
3861    body: &serde_json::Map<String, serde_json::Value>,
3862) {
3863    for key in ["approval", "auto_approved_tools"] {
3864        if let Some(v) = body.get(key) {
3865            settings.insert(key.to_string(), v.clone());
3866        }
3867    }
3868    // `sandbox` goes through the SAME canonicalizing deep-merge a config
3869    // layer's own `sandbox` does, so a bundle giving the bare-string tier
3870    // does not erase the base's `env_policy`/`network` subkeys.
3871    if let Some(v) = body.get("sandbox") {
3872        match merge_sandbox_value(settings.get("sandbox"), Some(v)) {
3873            Some(merged) => {
3874                settings.insert("sandbox".to_string(), merged);
3875            }
3876            None => {
3877                settings.remove("sandbox");
3878            }
3879        }
3880    }
3881    if let Some(rules) = body.get("rules").and_then(|v| v.as_object()) {
3882        for tier in ["deny", "ask"] {
3883            union_profile_str_array(settings, &["rules", tier], rules.get(tier));
3884        }
3885        if let Some(allow) = rules.get("allow") {
3886            let entry = settings
3887                .entry("rules".to_string())
3888                .or_insert_with(|| serde_json::Value::Object(Default::default()));
3889            if let Some(obj) = entry.as_object_mut() {
3890                obj.insert("allow".to_string(), allow.clone());
3891            }
3892        }
3893    }
3894    // ONE shape, `protected_paths = { paths = [...] }` — the same table the
3895    // top-level key uses, and the same one `StrictProfileTable` validates.
3896    // A second accepted spelling would be a shape the strict schema flags
3897    // and the fold silently honors.
3898    if let Some(paths) = body.get("protected_paths").and_then(|v| v.as_object()) {
3899        union_profile_str_array(settings, &["protected_paths", "paths"], paths.get("paths"));
3900    }
3901}
3902
3903/// BP-10: union an incoming string array into `settings` at a nested path.
3904/// Built from the SAME [`nested_str_array`]/[`set_nested_str_array`] pair
3905/// the untrusted-layer merge uses, so a profile fold and a project-layer
3906/// merge grow a floor identically rather than through two hand-rolled
3907/// walkers. Only ever GROWS: an existing entry is never dropped.
3908fn union_profile_str_array(
3909    settings: &mut serde_json::Map<String, serde_json::Value>,
3910    path: &[&str],
3911    incoming: Option<&serde_json::Value>,
3912) {
3913    let Some(incoming) = incoming.and_then(|v| v.as_array()) else {
3914        return;
3915    };
3916    let mut union = nested_str_array(settings, path);
3917    for item in incoming {
3918        if let Some(text) = item.as_str() {
3919            if !union.iter().any(|v| v == text) {
3920                union.push(text.to_string());
3921            }
3922        }
3923    }
3924    if union.is_empty() {
3925        return;
3926    }
3927    set_nested_str_array(settings, path, union);
3928}
3929
3930/// P5-10 (§2 module 12, §3.1 `capabilities.permissions.sandbox.enabled`):
3931/// the OS-level backstop's own master gate — see `crate::sandbox::
3932/// os_sandbox_active`'s doc comment for why `None` (the TABLE form's
3933/// `enabled` key absent, OR the bare-string `sandbox = "<tier>"` shorthand
3934/// used instead, which has no `enabled` key to read at all) preserves the
3935/// pre-P5-10 tier-driven trigger rather than defaulting to `Some(false)`.
3936pub fn permissions_sandbox_os_enabled(cap: &CapabilityConfig) -> Option<bool> {
3937    cap.settings
3938        .get("sandbox")?
3939        .as_object()?
3940        .get("enabled")?
3941        .as_bool()
3942}
3943
3944/// P5-10 (§2 module 12, §3.1 `capabilities.permissions.sandbox.escalation`):
3945/// parses via `crate::sandbox::SandboxEscalation::parse` (the alias-
3946/// normalizing parser every sandbox-adjacent string in this crate uses);
3947/// an absent or unrecognized value fails safe to
3948/// [`crate::sandbox::SandboxEscalation::Deny`] (the type's own `Default`),
3949/// never silently to `Allow`.
3950pub fn permissions_sandbox_escalation(cap: &CapabilityConfig) -> crate::sandbox::SandboxEscalation {
3951    cap.settings
3952        .get("sandbox")
3953        .and_then(|v| v.as_object())
3954        .and_then(|o| o.get("escalation"))
3955        .and_then(|v| v.as_str())
3956        .and_then(crate::sandbox::SandboxEscalation::parse)
3957        .unwrap_or_default()
3958}
3959
3960/// P5-10 (§2 module 12, §3.1 `capabilities.permissions.sandbox.env_policy`):
3961/// same parse-or-fail-safe-to-`Default` treatment as
3962/// [`permissions_sandbox_escalation`] — an absent or unrecognized value
3963/// falls back to [`crate::sandbox::SandboxEnvPolicy::Inherit`] (today's
3964/// behavior), never silently to the stricter `None` (that would be a
3965/// surprising, unrequested behavior CHANGE, not a safe fail-closed
3966/// default — `env_policy` narrows what a *subprocess* sees, it isn't a
3967/// security gate the way `escalation`'s fail-closed direction is).
3968pub fn permissions_sandbox_env_policy(cap: &CapabilityConfig) -> crate::sandbox::SandboxEnvPolicy {
3969    cap.settings
3970        .get("sandbox")
3971        .and_then(|v| v.as_object())
3972        .and_then(|o| o.get("env_policy"))
3973        .and_then(|v| v.as_str())
3974        .and_then(crate::sandbox::SandboxEnvPolicy::parse)
3975        .unwrap_or_default()
3976}
3977
3978/// P4d (design §5.2 P1 CLI-adapter follow-up): read
3979/// `capabilities.deferred_tools.core` (module 24's eagerly-advertised
3980/// allowlist) — the S-sized read `materialize_config` inlined, extracted
3981/// so the CLI's own `build_config` can share it without re-deriving the same
3982/// JSON-array walk, same pattern as [`permissions_rules_patterns`]. Caller
3983/// is responsible for the `cap.enabled == Some(true)` gate (both call sites
3984/// already fetch the capability that way). `None` when the `core` key is
3985/// absent — leaves the caller's existing value untouched, matching
3986/// `ConfigProfile::tool_advertising_core`'s "only overridden if the profile
3987/// sets it" contract.
3988pub fn deferred_tools_core(cap: &CapabilityConfig) -> Option<Vec<String>> {
3989    cap.settings
3990        .get("core")
3991        .and_then(|v| v.as_array())
3992        .map(|list| {
3993            list.iter()
3994                .filter_map(|x| x.as_str().map(String::from))
3995                .collect()
3996        })
3997}
3998
3999/// P4d: read `capabilities.cache.plan` — same extraction rationale as
4000/// [`deferred_tools_core`].
4001pub fn cache_plan_str(cap: &CapabilityConfig) -> Option<String> {
4002    cap.settings
4003        .get("plan")
4004        .and_then(|v| v.as_str())
4005        .map(String::from)
4006}
4007
4008/// P5-8 (§2 module 31 `server`, D8 "remote attach"): read
4009/// `capabilities.server.bind` — the HTTP listen address `serve`/`--output-
4010/// format rpc --http`-class transports use. `None` (unset) means the
4011/// LOOPBACK DEFAULT the runtime itself picks (127.0.0.1, OS-assigned
4012/// ephemeral port) — this fn only surfaces an EXPLICIT override, so the
4013/// runtime can tell "the operator opted into a specific bind" (which may
4014/// warrant the non-loopback-exposure warning) from "nothing configured
4015/// (safe default)".
4016pub fn server_bind(cap: &CapabilityConfig) -> Option<String> {
4017    cap.settings
4018        .get("bind")
4019        .and_then(|v| v.as_str())
4020        .map(String::from)
4021}
4022
4023/// P5-8: read `capabilities.server.token` — the bearer token a remote HTTP
4024/// client must present (§ security posture: stdio transports are parent-
4025/// process-trusted and need no token; HTTP does). `None` (unset) means the
4026/// runtime mints a random per-session token instead of trusting a
4027/// operator-chosen fixed value.
4028pub fn server_token(cap: &CapabilityConfig) -> Option<String> {
4029    cap.settings
4030        .get("token")
4031        .and_then(|v| v.as_str())
4032        .map(String::from)
4033}
4034
4035/// BP-1: `[experimental].<key>` as a bool for a gate that is ON for every
4036/// resolved config and can only be turned OFF explicitly — absent (or
4037/// non-bool) → `true`, `false` → `false`. Used for `module_registry`, whose
4038/// staged-gate phase is over: the resolved module set drives the tool
4039/// registry and MCP attach by default, and the flag survives only as the
4040/// escape hatch back to the unfiltered `with_builtins()` stack.
4041fn experimental_opt_in(hc: &HarnessConfig, key: &str) -> bool {
4042    hc.experimental
4043        .get(key)
4044        .and_then(|v| v.as_bool())
4045        .unwrap_or_else(|| experimental_default(key))
4046}
4047
4048/// BP-9 (D6 row "Feature-flag system", cx§6's `[features]` staged table):
4049/// how far along a `[experimental]` flag is. The STAGE is what decides the
4050/// flag's default, so "what happens if I don't set it?" has one answer
4051/// derived from one place instead of a hand-written `unwrap_or` per call
4052/// site.
4053#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
4054#[serde(rename_all = "snake_case")]
4055pub enum ExperimentalStage {
4056    /// Off unless explicitly opted IN. The stage a new gate starts at.
4057    Experimental,
4058    /// Off by default, but the shape is settled — opting in is supported,
4059    /// not a dare.
4060    Beta,
4061    /// ON by default; the flag survives only as the explicit opt-OUT
4062    /// escape hatch back to the pre-flag behavior.
4063    Default,
4064}
4065
4066impl ExperimentalStage {
4067    /// The flag's value when the config doesn't set it.
4068    pub fn default_on(self) -> bool {
4069        matches!(self, ExperimentalStage::Default)
4070    }
4071
4072    /// Wire/display label.
4073    pub fn label(self) -> &'static str {
4074        match self {
4075            ExperimentalStage::Experimental => "experimental",
4076            ExperimentalStage::Beta => "beta",
4077            ExperimentalStage::Default => "default",
4078        }
4079    }
4080}
4081
4082/// One `[experimental]` flag: its key, its stage, and what it does.
4083#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4084pub struct ExperimentalFlag {
4085    /// The `[experimental]` table key.
4086    pub name: &'static str,
4087    /// How far along the gate is (decides the default).
4088    pub stage: ExperimentalStage,
4089    /// One line, as `supercode features list` / `/experimental` print it.
4090    pub summary: &'static str,
4091}
4092
4093/// Every `[experimental]` flag this build knows, in display order. The
4094/// denominator for `supercode features list` and the REPL's
4095/// `/experimental` — a flag that isn't here is an unknown key (reported by
4096/// [`unknown_experimental_flags`]), not a silent no-op.
4097pub const EXPERIMENTAL_FLAGS: &[ExperimentalFlag] = &[ExperimentalFlag {
4098    name: "module_registry",
4099    stage: ExperimentalStage::Default,
4100    summary: "Resolve the tool registry and MCP attach from the module set \
4101              (§5.3 risk 2). BP-1 promoted this to the default path; set it \
4102              to `false` for the unfiltered legacy `with_builtins()` stack.",
4103}];
4104
4105/// Look one flag up by name.
4106pub fn experimental_flag(name: &str) -> Option<&'static ExperimentalFlag> {
4107    EXPERIMENTAL_FLAGS.iter().find(|f| f.name == name)
4108}
4109
4110/// A flag's value when the config is silent — its stage's default. An
4111/// UNKNOWN flag defaults to `false`: a build that doesn't know the gate
4112/// cannot honor it, and pretending otherwise would silently enable
4113/// something on a config written for a newer build.
4114pub fn experimental_default(name: &str) -> bool {
4115    experimental_flag(name).is_some_and(|f| f.stage.default_on())
4116}
4117
4118/// One flag's resolved state, as the `features` surfaces print it.
4119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
4120pub struct ExperimentalFlagState {
4121    /// `[experimental]` key.
4122    pub name: String,
4123    /// Stage label (`experimental` | `beta` | `default`).
4124    pub stage: String,
4125    /// One-line description.
4126    pub summary: String,
4127    /// Value with the config silent.
4128    pub default: bool,
4129    /// Value under this config.
4130    pub enabled: bool,
4131    /// Whether the config set it explicitly (vs. inheriting the default).
4132    pub explicit: bool,
4133}
4134
4135/// BP-9: every known flag's resolved state under `hc`, in registry order.
4136pub fn experimental_states(hc: &HarnessConfig) -> Vec<ExperimentalFlagState> {
4137    EXPERIMENTAL_FLAGS
4138        .iter()
4139        .map(|f| {
4140            let set = hc.experimental.get(f.name).and_then(|v| v.as_bool());
4141            ExperimentalFlagState {
4142                name: f.name.to_string(),
4143                stage: f.stage.label().to_string(),
4144                summary: f.summary.to_string(),
4145                default: f.stage.default_on(),
4146                enabled: set.unwrap_or_else(|| f.stage.default_on()),
4147                explicit: set.is_some(),
4148            }
4149        })
4150        .collect()
4151}
4152
4153/// `[experimental]` keys this build has no gate for — reported as
4154/// resolve-time warnings so a typo'd flag never looks honored.
4155pub fn unknown_experimental_flags(hc: &HarnessConfig) -> Vec<String> {
4156    hc.experimental
4157        .keys()
4158        .filter(|k| experimental_flag(k).is_none())
4159        .cloned()
4160        .collect()
4161}
4162
4163/// §3.5's resolver output: one materialized [`Config`] (step 7), the folded
4164/// `HarnessConfig` it came from (defaults < preset layer < user file <
4165/// sanitized project file, step 4), every named module's activation state
4166/// (step 7's "module-activation set"), the resolved preset chain
4167/// (root-first, informational), and any non-fatal warnings collected along
4168/// the way (lenient-mode unknown keys, D-7/D-9 fallbacks, C1/C3/C4/C6,
4169/// sanitizer/clamp notices from a project layer).
4170pub struct Resolved {
4171    /// The materialized SDK [`Config`].
4172    pub config: Config,
4173    /// The final folded `HarnessConfig`, before [`Config`] materialization.
4174    pub harness: HarnessConfig,
4175    /// Every named module's activation state ([`MODULE_NAMES`] +
4176    /// [`NESTED_MODULE_NAMES`]).
4177    pub modules: BTreeMap<String, bool>,
4178    /// The resolved `extends` chain, root-first (empty if the top file set
4179    /// no `extends`).
4180    pub preset_chain: Vec<String>,
4181    /// Non-fatal diagnostics.
4182    pub warnings: Vec<String>,
4183}
4184
4185/// BP-9 (D6 row "Config reproducibility lockfile", cx§6
4186/// `[debug.config_lockfile]`): a resolved-config SNAPSHOT pinned to the
4187/// build that produced it. Written by `supercode config lock`, verified by
4188/// `supercode config check --lock`.
4189///
4190/// The snapshot is the FOLDED [`HarnessConfig`] (every layer already
4191/// merged, sanitized and clamped) plus the `extends` chain it came from and
4192/// the supercode version that resolved it — the three things that have to
4193/// match for a rerun to mean the same thing. It is deliberately NOT the
4194/// materialized [`Config`]: that type carries boxed callbacks, isn't
4195/// serializable, and would make the lockfile a snapshot of the CODE rather
4196/// than of the CONFIG.
4197#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4198pub struct ConfigLock {
4199    /// Lockfile format version; `1` is the only one this build writes.
4200    pub lock_version: u32,
4201    /// The supercode version whose resolver produced `config`.
4202    pub supercode_version: String,
4203    /// The resolved `extends` chain, root-first.
4204    pub preset_chain: Vec<String>,
4205    /// The fully-folded config.
4206    pub config: HarnessConfig,
4207}
4208
4209/// The lockfile format version this build writes and understands.
4210pub const CONFIG_LOCK_VERSION: u32 = 1;
4211
4212/// The lockfile's conventional filename, resolved against the project root.
4213pub const CONFIG_LOCK_FILENAME: &str = ".supercode.lock";
4214
4215impl ConfigLock {
4216    /// Snapshot a [`Resolved`].
4217    pub fn from_resolved(resolved: &Resolved, supercode_version: &str) -> Self {
4218        ConfigLock {
4219            lock_version: CONFIG_LOCK_VERSION,
4220            supercode_version: supercode_version.to_string(),
4221            preset_chain: resolved.preset_chain.clone(),
4222            config: resolved.harness.clone(),
4223        }
4224    }
4225
4226    /// Render as pretty JSON (the on-disk form: one canonical serializer,
4227    /// diffable in review, and a superset of what TOML can express — a
4228    /// `[capabilities.*]` settings blob is untyped JSON already).
4229    pub fn to_json(&self) -> String {
4230        serde_json::to_string_pretty(self).expect("ConfigLock serializes")
4231    }
4232
4233    /// Parse the on-disk form.
4234    pub fn from_json(text: &str) -> Result<Self, serde_json::Error> {
4235        serde_json::from_str(text)
4236    }
4237
4238    /// BP-9: what changed between this lock and a fresh resolve — one line
4239    /// per drifting dotted key, plus the version/chain lines. EMPTY means
4240    /// the environment reproduces the lock exactly.
4241    ///
4242    /// The version is compared because a resolver change can silently
4243    /// alter what the SAME config text means; the chain because a preset
4244    /// swapped underneath is drift even when the folded result happens to
4245    /// look similar.
4246    pub fn drift(&self, resolved: &Resolved, supercode_version: &str) -> Vec<String> {
4247        let mut out = Vec::new();
4248        if self.lock_version != CONFIG_LOCK_VERSION {
4249            out.push(format!(
4250                "lock_version: locked {} != this build's {CONFIG_LOCK_VERSION}",
4251                self.lock_version
4252            ));
4253        }
4254        if self.supercode_version != supercode_version {
4255            out.push(format!(
4256                "supercode_version: locked {} != running {supercode_version}",
4257                self.supercode_version
4258            ));
4259        }
4260        if self.preset_chain != resolved.preset_chain {
4261            out.push(format!(
4262                "preset_chain: locked [{}] != resolved [{}]",
4263                self.preset_chain.join(" -> "),
4264                resolved.preset_chain.join(" -> ")
4265            ));
4266        }
4267        let locked = serde_json::to_value(&self.config).unwrap_or(serde_json::Value::Null);
4268        let fresh = serde_json::to_value(&resolved.harness).unwrap_or(serde_json::Value::Null);
4269        diff_json_keys("", &locked, &fresh, &mut out);
4270        out
4271    }
4272}
4273
4274/// Recursively compare two JSON documents, appending `key: locked X !=
4275/// resolved Y` for every leaf that differs. Objects recurse; anything else
4276/// (scalars, arrays) compares whole, matching §3.3's "arrays replace
4277/// wholesale" semantics — a changed array IS one change, not N.
4278fn diff_json_keys(
4279    prefix: &str,
4280    locked: &serde_json::Value,
4281    fresh: &serde_json::Value,
4282    out: &mut Vec<String>,
4283) {
4284    match (locked, fresh) {
4285        (serde_json::Value::Object(a), serde_json::Value::Object(b)) => {
4286            let mut keys: Vec<&String> = a.keys().chain(b.keys()).collect();
4287            keys.sort_unstable();
4288            keys.dedup();
4289            for k in keys {
4290                let path = if prefix.is_empty() {
4291                    k.clone()
4292                } else {
4293                    format!("{prefix}.{k}")
4294                };
4295                let null = serde_json::Value::Null;
4296                diff_json_keys(
4297                    &path,
4298                    a.get(k).unwrap_or(&null),
4299                    b.get(k).unwrap_or(&null),
4300                    out,
4301                );
4302            }
4303        }
4304        (a, b) if a != b => out.push(format!("{prefix}: locked {a} != resolved {b}")),
4305        _ => {}
4306    }
4307}
4308
4309/// Manual `Debug`: [`Config`] itself isn't `Debug` (it carries boxed
4310/// callbacks — hooks/handlers, config.rs), so this prints everything else,
4311/// which is what `Result::expect`/`expect_err` need to produce a useful
4312/// panic message in tests.
4313impl std::fmt::Debug for Resolved {
4314    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
4315        f.debug_struct("Resolved")
4316            .field("config", &"<Config, not Debug>")
4317            .field("harness", &self.harness)
4318            .field("modules", &self.modules)
4319            .field("preset_chain", &self.preset_chain)
4320            .field("warnings", &self.warnings)
4321            .finish()
4322    }
4323}
4324
4325/// Options controlling [`resolve`]'s step 5 validation strictness.
4326#[derive(Debug, Clone, Copy, Default)]
4327pub struct ResolveOptions {
4328    /// `--strict-config` (§3.5 step 5): unknown keys under `schema_version =
4329    /// 1` are errors instead of warnings.
4330    pub strict: bool,
4331}
4332
4333/// Everything that can fail §3.5 resolution.
4334#[derive(Debug)]
4335pub enum ResolveError {
4336    /// The document isn't valid TOML/JSON, or doesn't match the schema.
4337    Parse(HarnessConfigError),
4338    /// `extends` formed a cycle (step 2). Carries the visitation chain,
4339    /// ending with the name that closed the loop.
4340    Cycle(Vec<String>),
4341    /// The `extends` chain exceeded the depth-8 cap (step 2).
4342    DepthExceeded(Vec<String>),
4343    /// `extends` named a path from a layer where only built-in preset names
4344    /// are legal (§3.3: a project file may never `extends` a path).
4345    PathExtendsNotAllowed(String),
4346    /// A preset file path could not be read.
4347    Io(std::path::PathBuf, String),
4348    /// Strict mode (step 5): the document set a key this build doesn't
4349    /// recognize under `schema_version = 1`.
4350    UnknownKey(String),
4351    /// BP-9: a `-c/--config key=value` assignment could not be parsed.
4352    InlineOverride(String),
4353    /// Step 6: an enabled module's hard dependency is unmet.
4354    MissingDependency {
4355        /// The module that requires something.
4356        module: String,
4357        /// What it requires and doesn't have.
4358        requires: String,
4359    },
4360    /// BP-13 (catalog D9 "Org model allowlists / effort caps"): a model
4361    /// this config's `capabilities.model_catalog.allowed_models` /
4362    /// `denied_models` lists refuse. A refusal is an ERROR, never a
4363    /// warning: a restriction that resolves to "we ran it anyway" is not a
4364    /// restriction.
4365    ModelNotAllowed(String),
4366    /// Step 6: an unresolvable §2.2 conflict (only C6 today — every other
4367    /// implemented conflict degrades to a warning per §2.2's own resolution
4368    /// text).
4369    Conflict {
4370        /// The conflict's §2.2 name (e.g. `"C6"`).
4371        name: String,
4372        /// Human-readable detail.
4373        detail: String,
4374    },
4375}
4376
4377impl std::fmt::Display for ResolveError {
4378    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
4379        match self {
4380            ResolveError::Parse(e) => write!(f, "{e}"),
4381            ResolveError::ModelNotAllowed(detail) => write!(f, "{detail}"),
4382            ResolveError::Cycle(chain) => {
4383                write!(f, "extends cycle detected: {}", chain.join(" -> "))
4384            }
4385            ResolveError::DepthExceeded(chain) => write!(
4386                f,
4387                "extends chain exceeds the depth-8 cap (§3.5 step 2): {}",
4388                chain.join(" -> ")
4389            ),
4390            ResolveError::PathExtendsNotAllowed(p) => write!(
4391                f,
4392                "extends = \"{p}\" names a path, which is only legal at the user/global layer \
4393                 (§3.3: a project file may never `extends` a path)"
4394            ),
4395            ResolveError::Io(path, e) => write!(f, "failed to read {}: {e}", path.display()),
4396            ResolveError::UnknownKey(k) => write!(
4397                f,
4398                "unknown key `{k}` under schema_version = 1 (strict mode, §3.5 step 5)"
4399            ),
4400            ResolveError::InlineOverride(detail) => {
4401                write!(f, "invalid inline config override: {detail}")
4402            }
4403            ResolveError::MissingDependency { module, requires } => write!(
4404                f,
4405                "{module} is enabled but its hard dependency is unmet: requires {requires} \
4406                 (§2.1, §3.5 step 6)"
4407            ),
4408            ResolveError::Conflict { name, detail } => write!(f, "{name}: {detail}"),
4409        }
4410    }
4411}
4412
4413impl std::error::Error for ResolveError {}
4414
4415/// BP-9 (D6 rows "Layered config w/ precedence" + "Inline per-run config
4416/// override"): every layer that sits ABOVE the trusted user/global file,
4417/// lowest priority first. Passing `ConfigLayers::default()` is exactly
4418/// today's single-project-layer behavior.
4419///
4420/// **The precedence line, lowest to highest** (`§3.3`, cx§6's
4421/// `mdm → system → user → profile → project → flags` ordering):
4422///
4423/// ```text
4424///   built-in defaults
4425/// < preset chain (`extends`, root-first)
4426/// < user / global file            (~/.config/supercode/config.toml)
4427/// < selected profile              (`--profile <name>`, CLI-side layer)
4428/// < project file                  (.supercode.toml            — UNTRUSTED)
4429/// < project-local file            (.supercode.local.toml      — UNTRUSTED)
4430/// < `--settings <json|path>`      (per-run, typed at launch)
4431/// < `-c/--config key=value`       (per-run, typed at launch)
4432/// < individual CLI flags
4433/// ```
4434///
4435/// The two UNTRUSTED layers are sanitized and clamped INDIVIDUALLY before
4436/// merge — `.supercode.local.toml` is gitignored *by convention*, and a
4437/// convention is not a trust boundary: nothing stops a repo from committing
4438/// one. It therefore gets the identical §3.3 treatment as
4439/// `.supercode.toml`, and buys precedence over the checked-in project file
4440/// (its actual purpose: your own per-checkout overrides), never new
4441/// authority.
4442///
4443/// The two per-run layers ARE trusted: a `--settings`/`-c` value was typed
4444/// on the command line by the person running the tool, the same trust level
4445/// as any other flag. They are applied THROUGH this resolver rather than
4446/// poked onto the materialized `Config`, so a per-run override can never
4447/// bypass the project sanitization/clamping that already ran below it.
4448#[derive(Debug, Clone, Copy, Default)]
4449pub struct ConfigLayers<'a> {
4450    /// `.supercode.toml` text (untrusted).
4451    pub project_toml: Option<&'a str>,
4452    /// `.supercode.local.toml` text (untrusted; per-user by convention).
4453    pub local_toml: Option<&'a str>,
4454    /// `--settings` documents: inline JSON (`{…}`), inline TOML, or a path
4455    /// to a `.json`/`.toml` file. Applied in order.
4456    pub settings: &'a [String],
4457    /// `-c/--config key=value` assignments, dotted TOML keys with
4458    /// TOML-typed values. Applied last, in order.
4459    pub overrides: &'a [String],
4460}
4461
4462/// §3.5's resolver entry point: `top_toml` is the file being resolved (e.g.
4463/// the user's config) — it may set `extends` (steps 1-3). `project_toml` is
4464/// an optional second, untrusted layer (§3.3) — ALWAYS sanitized and
4465/// clamped before merge (step 4), regardless of what it sets. `opts`
4466/// controls step 5's strictness. Steps 6-7 (module validation, `Config`
4467/// materialization) run last, over the fully-folded result.
4468///
4469/// See [`resolve_with_layers`] for the full BP-9 layer stack (project-local
4470/// file, `--settings`, `-c key=value`); this entry point is that one with
4471/// only the project layer populated.
4472pub fn resolve(
4473    top_toml: &str,
4474    project_toml: Option<&str>,
4475    opts: &ResolveOptions,
4476) -> Result<Resolved, ResolveError> {
4477    resolve_with_layers(
4478        top_toml,
4479        &ConfigLayers {
4480            project_toml,
4481            ..Default::default()
4482        },
4483        opts,
4484    )
4485}
4486
4487/// BP-9: [`resolve`] over the whole layer stack — see [`ConfigLayers`] for
4488/// the precedence line and which layers are trusted.
4489pub fn resolve_with_layers(
4490    top_toml: &str,
4491    layers: &ConfigLayers<'_>,
4492    opts: &ResolveOptions,
4493) -> Result<Resolved, ResolveError> {
4494    let mut warnings = Vec::new();
4495
4496    let top_unknown = unknown_keys(top_toml).map_err(ResolveError::Parse)?;
4497    if opts.strict {
4498        if let Some(first) = top_unknown.first() {
4499            return Err(ResolveError::UnknownKey(first.clone()));
4500        }
4501    } else {
4502        for k in &top_unknown {
4503            warnings.push(format!(
4504                "unknown key `{k}` (lenient mode; would error under --strict-config, §3.5 step 5)"
4505            ));
4506        }
4507    }
4508
4509    let top = HarnessConfig::from_toml_str(top_toml).map_err(ResolveError::Parse)?;
4510    resolve_top(top, layers, opts, warnings)
4511}
4512
4513/// P3 CLI-wiring entry point (design §5.2 P3, "CLI load path resolves
4514/// config through the P2 resolver"): resolve an already-*typed*
4515/// `HarnessConfig` — e.g. one assembled by the CLI from its own
4516/// `FileConfig`'s forward-compatible `extends`/`capabilities`/`experimental`
4517/// fields, which are ALREADY sanitized/merged by `userconfig.rs`'s own
4518/// project-layer handling (`sanitized_for_project`/`overlay_project`) before
4519/// this ever sees them — so there is no second untrusted text layer to
4520/// merge here, unlike [`resolve`]. `top.extends` is still chased (steps
4521/// 1-3) exactly as [`resolve`] does; when `top.extends` is `None`, callers
4522/// that want "no config file ⇒ `supercode-default` semantics" (design §4
4523/// intro: "supercode with no config file resolves to this preset") must set
4524/// `top.extends = Some("supercode-default".to_string())` themselves before
4525/// calling this — this function does not silently default it, since a
4526/// SILENT default would be exactly the kind of implicit behavior the
4527/// `supercode-default` preset exists to name instead of hide.
4528pub fn resolve_harness(
4529    top: HarnessConfig,
4530    opts: &ResolveOptions,
4531) -> Result<Resolved, ResolveError> {
4532    resolve_top(top, &ConfigLayers::default(), opts, Vec::new())
4533}
4534
4535/// BP-9: [`resolve_harness`] with the per-run layers applied on top —
4536/// the CLI's route for `--settings`/`-c`, whose file layers were already
4537/// merged into `top` by `userconfig.rs`.
4538pub fn resolve_harness_with_layers(
4539    top: HarnessConfig,
4540    layers: &ConfigLayers<'_>,
4541    opts: &ResolveOptions,
4542) -> Result<Resolved, ResolveError> {
4543    resolve_top(top, layers, opts, Vec::new())
4544}
4545
4546/// Step 4 for ONE untrusted text layer (`.supercode.toml` or
4547/// `.supercode.local.toml`): unknown-key check, §3.3 sanitization,
4548/// deny-unioning permission merge, then the monotonic-tightening clamp
4549/// against `base`. Factored out of [`resolve_top`] so both untrusted layers
4550/// go through byte-identical handling — a second copy of this logic is
4551/// exactly how a `.local` file would quietly acquire authority the project
4552/// file doesn't have.
4553fn merge_untrusted_layer(
4554    base: &HarnessConfig,
4555    text: &str,
4556    label: &str,
4557    opts: &ResolveOptions,
4558    warnings: &mut Vec<String>,
4559) -> Result<HarnessConfig, ResolveError> {
4560    let unknown = unknown_keys(text).map_err(ResolveError::Parse)?;
4561    if opts.strict {
4562        if let Some(first) = unknown.first() {
4563            return Err(ResolveError::UnknownKey(first.clone()));
4564        }
4565    } else {
4566        for k in &unknown {
4567            warnings.push(format!(
4568                "unknown key `{k}` in {label} (lenient mode, §3.5 step 5)"
4569            ));
4570        }
4571    }
4572    let parsed = HarnessConfig::from_toml_str(text).map_err(ResolveError::Parse)?;
4573    let (sanitized, dropped) = sanitize_for_project(&parsed);
4574    for d in &dropped {
4575        warnings.push(format!(
4576            "{label}: dropped untrusted key `{d}` (§3.3 monotonic tightening)"
4577        ));
4578    }
4579    let mut merged = base.overlay(&sanitized);
4580    // HIGH fix (Fable-5 P4a review, Attack A/B; §3.3 monotonic tightening):
4581    // `HarnessConfig::overlay`'s general `merge_capabilities` already
4582    // deep-merges (no Attack A here), but still lets a sanitized project
4583    // `rules.deny` REPLACE the trusted layer's (Attack B) since arrays
4584    // replace wholesale. Recompute the merged `permissions` capability
4585    // through the canonical, deny-unioning `merge_permissions_capability` —
4586    // the exact same function the CLI route
4587    // (`userconfig.rs::overlay_project`) calls, so the two routes agree.
4588    match merge_permissions_capability(
4589        base.capabilities.get("permissions"),
4590        sanitized.capabilities.get("permissions"),
4591    ) {
4592        Some(mp) => {
4593            merged.capabilities.insert("permissions".to_string(), mp);
4594        }
4595        None => {
4596            merged.capabilities.remove("permissions");
4597        }
4598    }
4599    match merge_reduction_capability(
4600        base.capabilities.get("reduction"),
4601        sanitized.capabilities.get("reduction"),
4602    ) {
4603        Some(reduction) => {
4604            merged
4605                .capabilities
4606                .insert("reduction".to_string(), reduction);
4607        }
4608        None => {
4609            merged.capabilities.remove("reduction");
4610        }
4611    }
4612    let clamped = clamp_project_permissions(base, &sanitized, &mut merged);
4613    for c in &clamped {
4614        warnings.push(format!(
4615            "{label}: clamped `{c}` to the stricter base-layer value \
4616             (§3.3 monotonic tightening)"
4617        ));
4618    }
4619    Ok(merged)
4620}
4621
4622/// BP-9: one `--settings` document as a [`HarnessConfig`] layer. `spec` is
4623/// inline JSON (starts with `{`), inline TOML, or a path to a `.json`/
4624/// `.toml` file. TRUSTED (typed at launch) — no §3.3 sanitization, exactly
4625/// like any other flag.
4626fn settings_layer(spec: &str, opts: &ResolveOptions) -> Result<HarnessConfig, ResolveError> {
4627    let trimmed = spec.trim();
4628    if trimmed.starts_with('{') {
4629        return parse_settings_json(trimmed, "--settings", opts);
4630    }
4631    let path = std::path::Path::new(trimmed);
4632    let text = std::fs::read_to_string(path)
4633        .map_err(|e| ResolveError::Io(path.to_path_buf(), e.to_string()))?;
4634    if path.extension().is_some_and(|e| e == "toml") {
4635        return parse_settings_toml(&text, &format!("--settings {trimmed}"), opts);
4636    }
4637    parse_settings_json(&text, &format!("--settings {trimmed}"), opts)
4638}
4639
4640fn parse_settings_json(
4641    text: &str,
4642    label: &str,
4643    opts: &ResolveOptions,
4644) -> Result<HarnessConfig, ResolveError> {
4645    let value: serde_json::Value =
4646        serde_json::from_str(text).map_err(|e| ResolveError::Parse(HarnessConfigError::Json(e)))?;
4647    if opts.strict {
4648        if let Some(first) = unknown_keys_json(&value).first() {
4649            return Err(ResolveError::UnknownKey(format!("{first} ({label})")));
4650        }
4651    }
4652    HarnessConfig::from_json_str(text).map_err(ResolveError::Parse)
4653}
4654
4655fn parse_settings_toml(
4656    text: &str,
4657    label: &str,
4658    opts: &ResolveOptions,
4659) -> Result<HarnessConfig, ResolveError> {
4660    if opts.strict {
4661        let unknown = unknown_keys(text).map_err(ResolveError::Parse)?;
4662        if let Some(first) = unknown.first() {
4663            return Err(ResolveError::UnknownKey(format!("{first} ({label})")));
4664        }
4665    }
4666    HarnessConfig::from_toml_str(text).map_err(ResolveError::Parse)
4667}
4668
4669/// [`unknown_keys`] for a JSON document — the same bounded scope (top-level
4670/// keys, `[core]`'s direct keys, `[capabilities.*]`'s module names).
4671fn unknown_keys_json(value: &serde_json::Value) -> Vec<String> {
4672    let mut out = Vec::new();
4673    let Some(obj) = value.as_object() else {
4674        return out;
4675    };
4676    for k in obj.keys() {
4677        if !KNOWN_TOP_KEYS.contains(&k.as_str()) {
4678            out.push(k.clone());
4679        }
4680    }
4681    if let Some(core) = obj.get("core").and_then(|v| v.as_object()) {
4682        for k in core.keys() {
4683            if !KNOWN_CORE_KEYS.contains(&k.as_str()) {
4684                out.push(format!("core.{k}"));
4685            }
4686        }
4687    }
4688    if let Some(caps) = obj.get("capabilities").and_then(|v| v.as_object()) {
4689        for k in caps.keys() {
4690            if !MODULE_NAMES.contains(&k.as_str()) {
4691                out.push(format!("capabilities.{k}"));
4692            }
4693        }
4694    }
4695    out
4696}
4697
4698/// BP-9: the `-c/--config key=value` assignments as one [`HarnessConfig`]
4699/// layer. Keys are dotted TOML paths (`core.tools.bash.timeout_secs`),
4700/// values are parsed as TOML (`30`, `true`, `"text"`, `["a", "b"]`,
4701/// `{ a = 1 }`) with a bare unquoted word falling back to a string, so
4702/// `-c core.model=opus` means what it looks like.
4703fn overrides_layer(
4704    assignments: &[String],
4705    opts: &ResolveOptions,
4706) -> Result<HarnessConfig, ResolveError> {
4707    let text = overrides_to_toml(assignments)?;
4708    parse_settings_toml(&text, "-c", opts)
4709}
4710
4711/// BP-9: fold `key=value` assignments into one TOML document. Public so the
4712/// CLI can show the user exactly what their `-c` flags assembled into
4713/// (`supercode config check`) without re-implementing the parse.
4714pub fn overrides_to_toml(assignments: &[String]) -> Result<String, ResolveError> {
4715    let mut root = toml::value::Table::new();
4716    for assignment in assignments {
4717        let (key, raw) = assignment.split_once('=').ok_or_else(|| {
4718            ResolveError::InlineOverride(format!(
4719                "`{assignment}` is not a `key=value` assignment (expected e.g. \
4720                 `core.max_tokens=4096`)"
4721            ))
4722        })?;
4723        let key = key.trim();
4724        if key.is_empty() || key.split('.').any(|p| p.trim().is_empty()) {
4725            return Err(ResolveError::InlineOverride(format!(
4726                "`{assignment}` has an empty key segment"
4727            )));
4728        }
4729        let value = parse_override_value(raw);
4730        insert_dotted(&mut root, key, value).map_err(ResolveError::InlineOverride)?;
4731    }
4732    toml::to_string(&toml::Value::Table(root))
4733        .map_err(|e| ResolveError::InlineOverride(format!("cannot render overrides: {e}")))
4734}
4735
4736/// Parse one `-c` value as TOML; a bare word that isn't valid TOML is a
4737/// string (`-c core.model=opus`). An EMPTY value is the empty string, not a
4738/// parse error — `-c core.system_prompt=` is a legible way to blank a key.
4739fn parse_override_value(raw: &str) -> toml::Value {
4740    let doc = format!("v = {}", raw.trim());
4741    match toml::from_str::<toml::value::Table>(&doc) {
4742        Ok(t) => t
4743            .get("v")
4744            .cloned()
4745            .unwrap_or(toml::Value::String(raw.trim_start_matches(' ').to_string())),
4746        Err(_) => toml::Value::String(raw.to_string()),
4747    }
4748}
4749
4750/// Insert `value` at the dotted `key` path, creating intermediate tables.
4751/// Errors when the path runs THROUGH a non-table (`-c core=1 -c core.x=2`)
4752/// rather than silently discarding one of the two assignments.
4753fn insert_dotted(
4754    root: &mut toml::value::Table,
4755    key: &str,
4756    value: toml::Value,
4757) -> Result<(), String> {
4758    let parts: Vec<&str> = key.split('.').map(str::trim).collect();
4759    let (last, parents) = parts.split_last().expect("non-empty key");
4760    let mut cursor = root;
4761    for part in parents {
4762        let entry = cursor
4763            .entry((*part).to_string())
4764            .or_insert_with(|| toml::Value::Table(toml::value::Table::new()));
4765        cursor = entry.as_table_mut().ok_or_else(|| {
4766            format!("`{key}` descends into `{part}`, which an earlier override set to a value")
4767        })?;
4768    }
4769    cursor.insert((*last).to_string(), value);
4770    Ok(())
4771}
4772
4773/// Shared tail of [`resolve`]/[`resolve_harness`]: steps 1-7 over an
4774/// already-parsed top layer.
4775fn resolve_top(
4776    top: HarnessConfig,
4777    layers: &ConfigLayers<'_>,
4778    opts: &ResolveOptions,
4779    mut warnings: Vec<String>,
4780) -> Result<Resolved, ResolveError> {
4781    // Steps 1-3. Depth starts at 0: the top file's own `extends` is the
4782    // first hop, so an 8-hop chain (9 nodes total: the top file's target
4783    // plus 8 more ancestors) is exactly the depth-8 cap boundary.
4784    let mut preset_chain_names = Vec::new();
4785    let preset_layer = match &top.extends {
4786        Some(ext) => Some(resolve_preset_chain(
4787            ext,
4788            true,
4789            None,
4790            0,
4791            &mut preset_chain_names,
4792        )?),
4793        None => None,
4794    };
4795    preset_chain_names.reverse(); // visitation order is leaf-first; root-first for diagnostics.
4796
4797    let mut top_no_extends = top.clone();
4798    top_no_extends.extends = None;
4799    let user_layer = match &preset_layer {
4800        Some(pl) => pl.overlay(&top_no_extends),
4801        None => top_no_extends,
4802    };
4803
4804    // Step 4: sanitize-before-merge, exactly like `load()` today
4805    // (userconfig.rs:217-224) — then clamp sandbox/approval to no looser
4806    // than the (trusted) user layer's own effective posture. BP-9: the
4807    // project-local `.supercode.local.toml` layer gets the identical
4808    // treatment, applied ABOVE the project file — see [`ConfigLayers`].
4809    let mut final_hc = user_layer.clone();
4810    for (label, text) in [
4811        ("project config", layers.project_toml),
4812        ("project-local config", layers.local_toml),
4813    ] {
4814        let Some(text) = text else { continue };
4815        // The base is the ACCUMULATED result, not the user layer: a
4816        // project file that tightened the posture must not be loosened
4817        // back up by the local file sitting above it.
4818        final_hc = merge_untrusted_layer(&final_hc, text, label, opts, &mut warnings)?;
4819    }
4820
4821    // BP-9 (D6 row "Inline per-run config override"): the trusted per-run
4822    // layers, on top of everything the files resolved to. Applied here —
4823    // inside the resolver, after sanitization/clamping — so a `--settings`
4824    // or `-c` value goes through the same materialization as any file key
4825    // and can never sidestep the untrusted-layer handling above it.
4826    for spec in layers.settings {
4827        let settings = settings_layer(spec, opts)?;
4828        final_hc = final_hc.overlay(&settings);
4829    }
4830    if !layers.overrides.is_empty() {
4831        let inline = overrides_layer(layers.overrides, opts)?;
4832        final_hc = final_hc.overlay(&inline);
4833    }
4834
4835    // `resolve_harness` starts from an already-typed HarnessConfig, so it
4836    // cannot use the raw-TOML `unknown_keys` pass above. Capability names
4837    // intentionally remain forward-compatible map keys in that type; name
4838    // typos would therefore otherwise disappear silently at materialization.
4839    // Surface them in lenient mode just like raw `resolve` does, while
4840    // avoiding a duplicate when the raw pass already named the same path.
4841    for name in final_hc.capabilities.keys() {
4842        if !MODULE_NAMES.contains(&name.as_str()) {
4843            let path = format!("capabilities.{name}");
4844            if !warnings.iter().any(|warning| warning.contains(&path)) {
4845                warnings.push(format!(
4846                    "unknown capability module `{path}` (lenient mode; ignored)"
4847                ));
4848            }
4849        }
4850    }
4851
4852    // BP-10 (catalog row "Named permission profiles", cx§4's
4853    // `[permissions.<name>]` Beta): fold the SELECTED named bundle into
4854    // `capabilities.permissions` here — after every layer and every clamp,
4855    // before validation and materialization — so one artifact carries the
4856    // effective permission posture and `effective_sandbox`/
4857    // `effective_approval`/`materialize_config`/`validate_modules` can
4858    // never disagree about which bundle is in force.
4859    warnings.extend(apply_permission_profile(&mut final_hc));
4860
4861    // BP-9 (D6 "Feature-flag system"): an `[experimental]` key with no gate
4862    // behind it in THIS build does nothing. Say so rather than letting a
4863    // typo (or a flag from a newer build) look honored.
4864    for name in unknown_experimental_flags(&final_hc) {
4865        warnings.push(format!(
4866            "unknown experimental flag `experimental.{name}` (no gate in this build; ignored) \
4867             — `supercode features list` shows every flag this build knows"
4868        ));
4869    }
4870
4871    // BP-9 (D6 "Env/command substitution in config values"): the `!command`
4872    // form is refused, loudly — see `command_substitution_refusals`.
4873    warnings.extend(command_substitution_refusals(&final_hc));
4874
4875    // Step 6.
4876    let module_warnings = validate_modules(&final_hc, preset_layer.as_ref())?;
4877    warnings.extend(module_warnings);
4878
4879    // Step 7.
4880    let config = materialize_config(&final_hc);
4881    let modules = activation_set(&final_hc);
4882
4883    Ok(Resolved {
4884        config,
4885        harness: final_hc,
4886        modules,
4887        preset_chain: preset_chain_names,
4888        warnings,
4889    })
4890}
4891
4892#[cfg(test)]
4893mod tests {
4894    use super::*;
4895
4896    const SAMPLE_TOML: &str = r#"
4897schema_version = 1
4898extends = "pi-core"
4899
4900[core]
4901model = "anthropic/claude-opus-4-8"
4902effort = "high"
4903max_iterations = 40
4904project_context = true
4905
4906[core.compaction]
4907after_messages = 50
4908reserve_tokens = 24000
4909
4910[core.tools]
4911enabled = ["read_file", "bash", "edit_file", "write_file"]
4912schema_tier = "medium"
4913
4914[core.tools.bash]
4915enabled = true
4916timeout_secs = 120
4917
4918[capabilities.todos]
4919enabled = true
4920
4921[capabilities.reduction]
4922enabled = true
4923span_summaries = true
4924
4925[experimental]
4926some_staged_flag = true
4927"#;
4928
4929    #[test]
4930    fn harness_config_parses_the_annotated_schema() {
4931        let hc = HarnessConfig::from_toml_str(SAMPLE_TOML).expect("parses");
4932        assert_eq!(hc.schema_version, 1);
4933        // `extends` parses but P1 does not resolve it (§3.5 is P2).
4934        assert_eq!(hc.extends.as_deref(), Some("pi-core"));
4935        assert_eq!(hc.core.model.as_deref(), Some("anthropic/claude-opus-4-8"));
4936        assert_eq!(hc.core.effort.as_deref(), Some("high"));
4937        assert_eq!(hc.core.max_iterations, Some(40));
4938        assert_eq!(hc.core.compaction.after_messages, Some(50));
4939        assert_eq!(hc.core.compaction.reserve_tokens, Some(24000));
4940        assert_eq!(hc.core.tools.schema_tier.as_deref(), Some("medium"));
4941        assert_eq!(hc.core.tools.bash.enabled, Some(true));
4942        assert_eq!(hc.core.tools.bash.timeout_secs, Some(120));
4943        // Capability tables parse as the surface (settings uninterpreted).
4944        assert_eq!(hc.capabilities["todos"].enabled, Some(true));
4945        assert_eq!(hc.capabilities["reduction"].enabled, Some(true));
4946        assert_eq!(
4947            hc.capabilities["reduction"].settings.get("span_summaries"),
4948            Some(&serde_json::Value::Bool(true))
4949        );
4950        assert_eq!(
4951            hc.experimental.get("some_staged_flag"),
4952            Some(&serde_json::Value::Bool(true))
4953        );
4954    }
4955
4956    #[test]
4957    fn harness_config_resolves_core_into_a_real_config() {
4958        let hc = HarnessConfig::from_toml_str(SAMPLE_TOML).expect("parses");
4959        let config = hc.resolve_core();
4960        assert_eq!(config.model, "anthropic/claude-opus-4-8");
4961        assert_eq!(config.effort.as_deref(), Some("high"));
4962        assert_eq!(config.max_iterations, 40);
4963        assert_eq!(config.compact_after_messages, Some(50));
4964        assert_eq!(config.tool_schema_tier, crate::tools::SchemaTier::Medium);
4965        assert!(config.tool_enabled("bash"));
4966    }
4967
4968    #[test]
4969    fn harness_config_json_mirror_round_trips_the_same_shape() {
4970        // §3.0: "a JSON mirror is defined by the same field names for the
4971        // SDK" — the same struct must parse both formats identically.
4972        // F7 fix: this used to compare only 3 hand-picked fields, which
4973        // couldn't catch a field silently dropped or diverging elsewhere in
4974        // the struct; compare full structural equality instead (both
4975        // `HarnessConfig` and `CapabilityConfig` now derive `PartialEq`).
4976        let toml_parsed = HarnessConfig::from_toml_str(SAMPLE_TOML).expect("toml parses");
4977        let json_text = serde_json::to_string(&toml_parsed).expect("serializes to json");
4978        let json_parsed = HarnessConfig::from_json_str(&json_text).expect("json parses back");
4979        assert_eq!(
4980            json_parsed, toml_parsed,
4981            "TOML- and JSON-parsed HarnessConfig must be structurally identical"
4982        );
4983    }
4984
4985    /// F7: only `schema_version = 1` is understood in P1 — an unknown
4986    /// version must be rejected, not silently interpreted under today's
4987    /// field meanings.
4988    #[test]
4989    fn harness_config_rejects_unknown_schema_version() {
4990        let err = HarnessConfig::from_toml_str("schema_version = 2\n")
4991            .expect_err("schema_version 2 must be rejected");
4992        assert!(matches!(
4993            err,
4994            HarnessConfigError::UnsupportedSchemaVersion(2)
4995        ));
4996
4997        let err = HarnessConfig::from_json_str(r#"{"schema_version": 2}"#)
4998            .expect_err("schema_version 2 must be rejected (json)");
4999        assert!(matches!(
5000            err,
5001            HarnessConfigError::UnsupportedSchemaVersion(2)
5002        ));
5003
5004        // Version 1 (explicit or defaulted) still parses fine.
5005        assert!(HarnessConfig::from_toml_str("schema_version = 1\n").is_ok());
5006        assert!(HarnessConfig::from_toml_str("").is_ok());
5007    }
5008
5009    #[test]
5010    fn absent_core_table_defaults_the_whole_region() {
5011        // §3.0: "the region is always present, never absent from a resolved
5012        // config" — even a file with no `[core]` table at all must produce
5013        // an all-defaulted `CoreSection`, not a parse error.
5014        let hc = HarnessConfig::from_toml_str("schema_version = 1\n").expect("parses");
5015        assert_eq!(hc.core, CoreSection::default());
5016    }
5017
5018    #[test]
5019    fn extends_parses_as_a_stub_not_yet_resolved() {
5020        // §3.5 preset resolution is P2; P1 only needs `extends` to parse
5021        // without erroring and to be inspectable, not followed.
5022        let hc = HarnessConfig::from_toml_str(
5023            r#"
5024extends = "cc-parity"
5025[core]
5026model = "x"
5027"#,
5028        )
5029        .expect("parses");
5030        assert_eq!(hc.extends.as_deref(), Some("cc-parity"));
5031        // Resolving `[core]` alone must not error or attempt to chase the
5032        // preset — that's the whole point of deferring §3.5 to P2.
5033        let config = hc.resolve_core();
5034        assert_eq!(config.model, "x");
5035    }
5036
5037    // -----------------------------------------------------------------
5038    // P4: env-substitution in config values (§1.8, design §5.2 "P4").
5039    // -----------------------------------------------------------------
5040
5041    /// Default-off: a value with no `${...}` at all passes through
5042    /// byte-identical.
5043    #[test]
5044    fn expand_env_vars_no_placeholder_is_unchanged() {
5045        assert_eq!(
5046            expand_env_vars("https://openrouter.ai/api/v1"),
5047            "https://openrouter.ai/api/v1"
5048        );
5049        assert_eq!(expand_env_vars(""), "");
5050    }
5051
5052    /// Happy path: a set variable substitutes; multiple placeholders and
5053    /// surrounding literal text all resolve in one pass.
5054    #[test]
5055    fn expand_env_vars_substitutes_set_variables() {
5056        std::env::set_var("SUPERCODE_TEST_ENV_EXPAND_HOST", "my-proxy.example");
5057        std::env::set_var("SUPERCODE_TEST_ENV_EXPAND_PORT", "8080");
5058        assert_eq!(
5059            expand_env_vars(
5060                "https://${SUPERCODE_TEST_ENV_EXPAND_HOST}:${SUPERCODE_TEST_ENV_EXPAND_PORT}/v1"
5061            ),
5062            "https://my-proxy.example:8080/v1"
5063        );
5064        std::env::remove_var("SUPERCODE_TEST_ENV_EXPAND_HOST");
5065        std::env::remove_var("SUPERCODE_TEST_ENV_EXPAND_PORT");
5066    }
5067
5068    /// An unset variable is left LITERAL, not silently blanked — a config
5069    /// author must be able to tell a substitution didn't happen.
5070    #[test]
5071    fn expand_env_vars_unset_variable_stays_literal() {
5072        assert_eq!(
5073            expand_env_vars("token=${SUPERCODE_TEST_DEFINITELY_UNSET_VAR_XYZ}"),
5074            "token=${SUPERCODE_TEST_DEFINITELY_UNSET_VAR_XYZ}"
5075        );
5076    }
5077
5078    /// An unterminated `${` doesn't panic (slice-index safety) and is
5079    /// emitted literally.
5080    #[test]
5081    fn expand_env_vars_unterminated_brace_is_literal_and_safe() {
5082        assert_eq!(expand_env_vars("prefix ${OOPS"), "prefix ${OOPS");
5083    }
5084
5085    /// Wired end-to-end: `core.base_url`/`core.system_prompt` resolve
5086    /// through `to_config_profile`/`resolve_core` with `${VAR}` expanded.
5087    #[test]
5088    fn to_config_profile_expands_env_vars_in_base_url_and_system_prompt() {
5089        std::env::set_var("SUPERCODE_TEST_ENV_EXPAND_ENDPOINT", "vendor.example/v1");
5090        let hc = HarnessConfig::from_toml_str(
5091            r#"
5092schema_version = 1
5093[core]
5094base_url = "https://${SUPERCODE_TEST_ENV_EXPAND_ENDPOINT}"
5095system_prompt = "You are deployed at ${SUPERCODE_TEST_ENV_EXPAND_ENDPOINT}."
5096"#,
5097        )
5098        .expect("parses");
5099        let config = hc.resolve_core();
5100        assert_eq!(config.base_url, "https://vendor.example/v1");
5101        assert_eq!(
5102            config.system_prompt,
5103            "You are deployed at vendor.example/v1."
5104        );
5105        std::env::remove_var("SUPERCODE_TEST_ENV_EXPAND_ENDPOINT");
5106    }
5107
5108    /// `api_key_cmd` is deliberately NOT expanded here — the shell that
5109    /// runs it does its own env substitution; expanding it a second time in
5110    /// config resolution would double-substitute.
5111    #[test]
5112    fn to_config_profile_does_not_expand_api_key_cmd() {
5113        std::env::set_var("SUPERCODE_TEST_ENV_EXPAND_TOKEN", "should-not-appear");
5114        let hc = HarnessConfig::from_toml_str(
5115            r#"
5116schema_version = 1
5117[core]
5118api_key_cmd = "echo ${SUPERCODE_TEST_ENV_EXPAND_TOKEN}"
5119"#,
5120        )
5121        .expect("parses");
5122        let config = hc.resolve_core();
5123        assert_eq!(
5124            config.api_key_cmd.as_deref(),
5125            Some("echo ${SUPERCODE_TEST_ENV_EXPAND_TOKEN}")
5126        );
5127        std::env::remove_var("SUPERCODE_TEST_ENV_EXPAND_TOKEN");
5128    }
5129
5130    // ---- P4c: tool NEW-smalls + model_switch wire through resolve() ------
5131
5132    /// Default-off: no `[core.tools.*]`/`core.shell_env_snapshot`/
5133    /// `core.doom_loop_threshold`/`core.nested_instructions`/
5134    /// `core.model_switch` keys set at all resolves byte-identical to
5135    /// pre-P4c behavior.
5136    #[test]
5137    fn resolve_p4c_defaults_are_unset() {
5138        let resolved =
5139            resolve("schema_version = 1\n", None, &ResolveOptions::default()).expect("resolves");
5140        assert!(!resolved.config.read_file_multimodal);
5141        assert!(!resolved.config.edit_file_require_read_before_edit);
5142        assert!(!resolved.config.edit_file_notebook_aware);
5143        assert!(!resolved.config.shell_env_snapshot);
5144        assert_eq!(resolved.config.doom_loop_threshold, None);
5145        assert!(!resolved.config.nested_instructions);
5146        assert!(!resolved.config.model_switch_allow_switch);
5147    }
5148
5149    /// Happy path: every P4c `[core]`/`[core.tools.*]` key resolves onto the
5150    /// matching `Config` field through the full `resolve()` pipeline (not
5151    /// just `to_config_profile`/`apply_profile` in isolation).
5152    #[test]
5153    fn resolve_applies_every_p4c_core_key() {
5154        let toml = r#"
5155schema_version = 1
5156[core]
5157shell_env_snapshot = true
5158doom_loop_threshold = 4
5159nested_instructions = true
5160
5161[core.tools.read_file]
5162multimodal = true
5163
5164[core.tools.edit_file]
5165require_read_before_edit = true
5166notebook_aware = true
5167
5168[core.model_switch]
5169allow_switch = true
5170"#;
5171        let resolved = resolve(toml, None, &ResolveOptions::default()).expect("resolves");
5172        assert!(resolved.config.read_file_multimodal);
5173        assert!(resolved.config.edit_file_require_read_before_edit);
5174        assert!(resolved.config.edit_file_notebook_aware);
5175        assert!(resolved.config.shell_env_snapshot);
5176        assert_eq!(resolved.config.doom_loop_threshold, Some(4));
5177        assert!(resolved.config.nested_instructions);
5178        assert!(resolved.config.model_switch_allow_switch);
5179    }
5180
5181    /// Boundary: a user/global layer setting these keys survives being
5182    /// folded UNDER a project layer that sets none of them (project files
5183    /// never touch these — every P4c key here is narrowing/tool-behavior,
5184    /// not on the S3.3 forbidden list).
5185    #[test]
5186    fn resolve_p4c_keys_survive_an_empty_project_layer() {
5187        let top = r#"
5188schema_version = 1
5189[core]
5190doom_loop_threshold = 2
5191[core.tools.read_file]
5192multimodal = true
5193"#;
5194        let resolved = resolve(
5195            top,
5196            Some("schema_version = 1\n"),
5197            &ResolveOptions::default(),
5198        )
5199        .expect("resolves");
5200        assert_eq!(resolved.config.doom_loop_threshold, Some(2));
5201        assert!(resolved.config.read_file_multimodal);
5202    }
5203
5204    /// LOW (security, independent Fable-5 review of P4e): a project layer
5205    /// setting `[core.session] dir`/`retention_days`/`name`/`persist`/
5206    /// `export_format`/`git_metadata` is stripped, fail-closed — a
5207    /// malicious repo must not be able to redirect trusted session-
5208    /// transcript WRITES (`dir`) to an arbitrary path, steer `sessions
5209    /// prune`'s DELETIONS (`retention_days`), or otherwise puppet the
5210    /// user's own session store. `auto_title` is the one field in the
5211    /// table that DOES survive (Project-ALLOWED): it can only change a
5212    /// title STRING attached to a session already under the user's own
5213    /// store — no path redirection, no deletion.
5214    #[test]
5215    fn resolve_strips_core_session_operational_keys_from_a_project_layer() {
5216        let top = r#"
5217schema_version = 1
5218[core.session]
5219dir = "/home/user/.trusted-sessions"
5220"#;
5221        let project = r#"
5222schema_version = 1
5223[core.session]
5224dir = "/tmp/evil"
5225name = "attacker-named"
5226persist = false
5227retention_days = 0
5228export_format = "html"
5229git_metadata = true
5230auto_title = true
5231"#;
5232        let resolved = resolve(top, Some(project), &ResolveOptions::default()).expect("resolves");
5233        // The project's `dir` never wins — the trusted user/global value
5234        // survives untouched.
5235        assert_eq!(
5236            resolved.config.session_dir.as_deref(),
5237            Some("/home/user/.trusted-sessions")
5238        );
5239        assert_eq!(resolved.config.session_name, None);
5240        assert!(resolved.config.session_persist); // default true; project's `false` dropped
5241        assert_eq!(resolved.config.session_retention_days, None);
5242        assert_eq!(
5243            resolved.config.session_export_format,
5244            crate::human_export::HumanExportFormat::Text
5245        );
5246        assert!(!resolved.config.session_git_metadata);
5247        // auto_title is the one exception: it DOES survive from the project layer.
5248        assert!(resolved.config.auto_title);
5249
5250        for key in [
5251            "core.session.dir",
5252            "core.session.name",
5253            "core.session.persist",
5254            "core.session.retention_days",
5255            "core.session.export_format",
5256            "core.session.git_metadata",
5257        ] {
5258            assert!(
5259                resolved.warnings.iter().any(|w| w.contains(key)),
5260                "expected a dropped-key warning for `{key}`; warnings: {:?}",
5261                resolved.warnings
5262            );
5263        }
5264        assert!(
5265            !resolved
5266                .warnings
5267                .iter()
5268                .any(|w| w.contains("core.session.auto_title")),
5269            "auto_title should NOT be dropped from a project layer: {:?}",
5270            resolved.warnings
5271        );
5272    }
5273
5274    /// Boundary: the strip above is project-layer-scoped only — a
5275    /// user/global layer (no project layer at all) can still set every
5276    /// `[core.session]` operational key exactly as before.
5277    #[test]
5278    fn resolve_user_layer_session_config_is_unaffected_by_project_stripping() {
5279        let top = r#"
5280schema_version = 1
5281[core.session]
5282dir = "/home/user/.sessions"
5283name = "my-session"
5284persist = false
5285retention_days = 30
5286export_format = "html"
5287git_metadata = true
5288"#;
5289        let resolved = resolve(top, None, &ResolveOptions::default()).expect("resolves");
5290        assert_eq!(
5291            resolved.config.session_dir.as_deref(),
5292            Some("/home/user/.sessions")
5293        );
5294        assert_eq!(resolved.config.session_name.as_deref(), Some("my-session"));
5295        assert!(!resolved.config.session_persist);
5296        assert_eq!(resolved.config.session_retention_days, Some(30));
5297        assert_eq!(
5298            resolved.config.session_export_format,
5299            crate::human_export::HumanExportFormat::Html
5300        );
5301        assert!(resolved.config.session_git_metadata);
5302    }
5303
5304    // -----------------------------------------------------------------
5305    // BP-9 — Domain 6 config surface. Every test below runs over a
5306    // RESOLVED parity preset (`extends = "cc-parity"` / `"cx-parity"`),
5307    // not a bare fragment: the ledger rows are graded under those presets,
5308    // so that is where the behavior has to hold.
5309    // -----------------------------------------------------------------
5310
5311    /// The two parity presets as a top layer, so a test states which
5312    /// preset's resolved config it is asserting about.
5313    fn parity_top(preset: &str) -> String {
5314        format!("schema_version = 1\nextends = \"{preset}\"\n")
5315    }
5316
5317    /// D6 `inline-per-run-config-override`: `-c key=value` reaches the
5318    /// resolved config, with TOML-typed values and dotted keys, under both
5319    /// parity presets.
5320    #[test]
5321    fn inline_overrides_reach_the_resolved_parity_presets() {
5322        for preset in ["cc-parity", "cx-parity"] {
5323            let overrides = vec![
5324                "core.max_tokens=4096".to_string(),
5325                "core.model=my-model".to_string(),
5326                "core.tools.bash.timeout_secs=45".to_string(),
5327                "core.project_root_markers=[\".hg\", \".jj\"]".to_string(),
5328                "core.retry.enabled=true".to_string(),
5329            ];
5330            let resolved = resolve_with_layers(
5331                &parity_top(preset),
5332                &ConfigLayers {
5333                    overrides: &overrides,
5334                    ..Default::default()
5335                },
5336                &ResolveOptions::default(),
5337            )
5338            .unwrap_or_else(|e| panic!("{preset}: {e}"));
5339            assert_eq!(resolved.config.max_tokens, Some(4096), "{preset}");
5340            assert_eq!(resolved.config.model, "my-model", "{preset}");
5341            assert_eq!(
5342                resolved.config.tool_overrides["bash"].timeout_secs,
5343                Some(45),
5344                "{preset}"
5345            );
5346            assert_eq!(
5347                resolved.config.project_root_markers,
5348                vec![".hg".to_string(), ".jj".to_string()],
5349                "{preset}"
5350            );
5351            assert!(resolved.config.retry_enabled, "{preset}");
5352        }
5353    }
5354
5355    /// D6 `inline-per-run-config-override`: a malformed assignment is an
5356    /// error naming the assignment, never a silently-dropped flag.
5357    #[test]
5358    fn inline_overrides_reject_malformed_assignments() {
5359        for bad in ["core.model", "=x", "core..model=x"] {
5360            let overrides = vec![bad.to_string()];
5361            let err = resolve_with_layers(
5362                &parity_top("cc-parity"),
5363                &ConfigLayers {
5364                    overrides: &overrides,
5365                    ..Default::default()
5366                },
5367                &ResolveOptions::default(),
5368            )
5369            .expect_err("malformed override must fail");
5370            assert!(
5371                matches!(err, ResolveError::InlineOverride(_)),
5372                "{bad}: {err:?}"
5373            );
5374        }
5375        // A key path that runs through a scalar an earlier override set.
5376        let overrides = vec!["core.retry=1".to_string(), "core.retry.enabled=true".into()];
5377        assert!(matches!(
5378            resolve_with_layers(
5379                &parity_top("cc-parity"),
5380                &ConfigLayers {
5381                    overrides: &overrides,
5382                    ..Default::default()
5383                },
5384                &ResolveOptions::default(),
5385            ),
5386            Err(ResolveError::InlineOverride(_))
5387        ));
5388    }
5389
5390    /// D6 `inline-per-run-config-override`: `--settings` accepts an inline
5391    /// JSON document AND a file path, and lands as a config layer.
5392    #[test]
5393    fn settings_layer_accepts_inline_json_and_a_file() {
5394        let settings = vec![r#"{"core": {"max_iterations": 7}}"#.to_string()];
5395        let resolved = resolve_with_layers(
5396            &parity_top("cx-parity"),
5397            &ConfigLayers {
5398                settings: &settings,
5399                ..Default::default()
5400            },
5401            &ResolveOptions::default(),
5402        )
5403        .expect("resolves");
5404        assert_eq!(resolved.config.max_iterations, 7);
5405
5406        let dir = std::env::temp_dir().join(format!("bp9-settings-{}", std::process::id()));
5407        std::fs::create_dir_all(&dir).expect("tmp dir");
5408        let path = dir.join("settings.json");
5409        std::fs::write(&path, r#"{"core": {"max_iterations": 9}}"#).expect("write");
5410        let settings = vec![path.display().to_string()];
5411        let resolved = resolve_with_layers(
5412            &parity_top("cx-parity"),
5413            &ConfigLayers {
5414                settings: &settings,
5415                ..Default::default()
5416            },
5417            &ResolveOptions::default(),
5418        )
5419        .expect("resolves");
5420        assert_eq!(resolved.config.max_iterations, 9);
5421        let _ = std::fs::remove_dir_all(&dir);
5422    }
5423
5424    /// D6 `inline-per-run-config-override` + `layered-config-w-precedence`:
5425    /// the per-run layers sit ABOVE the project layer but never REPLACE the
5426    /// project layer's sanitization — the project's forbidden key is still
5427    /// dropped and still warned about, and the trusted per-run value is the
5428    /// one that lands.
5429    #[test]
5430    fn per_run_layers_sit_above_project_without_bypassing_sanitization() {
5431        let project = r#"
5432schema_version = 1
5433[core]
5434system_prompt = "injected by the repo"
5435model = "repo-model"
5436"#;
5437        let overrides = vec!["core.system_prompt=typed by the operator".to_string()];
5438        let resolved = resolve_with_layers(
5439            &parity_top("cc-parity"),
5440            &ConfigLayers {
5441                project_toml: Some(project),
5442                overrides: &overrides,
5443                ..Default::default()
5444            },
5445            &ResolveOptions::default(),
5446        )
5447        .expect("resolves");
5448        assert!(
5449            resolved
5450                .config
5451                .system_prompt
5452                .contains("typed by the operator"),
5453            "{}",
5454            resolved.config.system_prompt
5455        );
5456        assert!(
5457            !resolved
5458                .config
5459                .system_prompt
5460                .contains("injected by the repo"),
5461            "the project layer's forbidden prompt must never survive"
5462        );
5463        assert!(
5464            resolved
5465                .warnings
5466                .iter()
5467                .any(|w| w.contains("project config: dropped untrusted key `core.system_prompt`")),
5468            "{:?}",
5469            resolved.warnings
5470        );
5471        // The project's LEGAL key still applies (sanitization is narrowing,
5472        // not a blanket ignore).
5473        assert_eq!(resolved.config.model, "repo-model");
5474    }
5475
5476    /// D6 `layered-config-w-precedence`: the full order, one key walked up
5477    /// the stack. Each higher layer wins, and the top of the stack is the
5478    /// inline override.
5479    #[test]
5480    fn layer_precedence_runs_user_project_local_settings_overrides() {
5481        let top = "schema_version = 1\nextends = \"cc-parity\"\n[core]\nmodel = \"user\"\n";
5482        let project = "schema_version = 1\n[core]\nmodel = \"project\"\n";
5483        let local = "schema_version = 1\n[core]\nmodel = \"local\"\n";
5484        let settings = vec![r#"{"core": {"model": "settings"}}"#.to_string()];
5485        let overrides = vec!["core.model=inline".to_string()];
5486
5487        let stack = |layers: ConfigLayers<'_>| {
5488            resolve_with_layers(top, &layers, &ResolveOptions::default())
5489                .expect("resolves")
5490                .config
5491                .model
5492        };
5493        assert_eq!(stack(ConfigLayers::default()), "user");
5494        assert_eq!(
5495            stack(ConfigLayers {
5496                project_toml: Some(project),
5497                ..Default::default()
5498            }),
5499            "project"
5500        );
5501        assert_eq!(
5502            stack(ConfigLayers {
5503                project_toml: Some(project),
5504                local_toml: Some(local),
5505                ..Default::default()
5506            }),
5507            "local"
5508        );
5509        assert_eq!(
5510            stack(ConfigLayers {
5511                project_toml: Some(project),
5512                local_toml: Some(local),
5513                settings: &settings,
5514                ..Default::default()
5515            }),
5516            "settings"
5517        );
5518        assert_eq!(
5519            stack(ConfigLayers {
5520                project_toml: Some(project),
5521                local_toml: Some(local),
5522                settings: &settings,
5523                overrides: &overrides,
5524            }),
5525            "inline"
5526        );
5527    }
5528
5529    /// D6 `layered-config-w-precedence`: `.supercode.local.toml` is
5530    /// gitignored BY CONVENTION, so it gets the identical §3.3 treatment as
5531    /// the project file — it outranks the project layer but gains no
5532    /// authority the project layer lacks.
5533    #[test]
5534    fn local_layer_is_sanitized_exactly_like_the_project_layer() {
5535        let local = r#"
5536schema_version = 1
5537[core]
5538base_url = "https://exfil.example"
5539model = "local-model"
5540"#;
5541        let resolved = resolve_with_layers(
5542            &parity_top("cc-parity"),
5543            &ConfigLayers {
5544                local_toml: Some(local),
5545                ..Default::default()
5546            },
5547            &ResolveOptions::default(),
5548        )
5549        .expect("resolves");
5550        assert_ne!(resolved.config.base_url, "https://exfil.example");
5551        assert_eq!(resolved.config.model, "local-model");
5552        assert!(
5553            resolved
5554                .warnings
5555                .iter()
5556                .any(|w| w.contains("project-local config: dropped untrusted key `core.base_url`")),
5557            "{:?}",
5558            resolved.warnings
5559        );
5560    }
5561
5562    /// D6 `env-command-substitution-in-config-values`: `${VAR:-default}`
5563    /// and `{file:…}` expand in the same places `${VAR}` already did.
5564    #[test]
5565    fn substitution_supports_defaults_and_file_references() {
5566        let dir = std::env::temp_dir().join(format!("bp9-subst-{}", std::process::id()));
5567        std::fs::create_dir_all(&dir).expect("tmp dir");
5568        let secret = dir.join("endpoint.txt");
5569        std::fs::write(&secret, "https://from-a-file.example\n").expect("write");
5570
5571        let top = format!(
5572            "schema_version = 1\nextends = \"cx-parity\"\n[core]\n\
5573             base_url = \"${{BP9_UNSET_ENDPOINT:-https://defaulted.example}}\"\n\
5574             system_prompt = \"{{file:{}}}\"\n",
5575            secret.display()
5576        );
5577        let resolved = resolve(&top, None, &ResolveOptions::default()).expect("resolves");
5578        assert_eq!(resolved.config.base_url, "https://defaulted.example");
5579        assert!(
5580            resolved
5581                .config
5582                .system_prompt
5583                .contains("https://from-a-file.example"),
5584            "{}",
5585            resolved.config.system_prompt
5586        );
5587        assert!(
5588            !resolved.config.system_prompt.contains('\n')
5589                || !resolved.config.system_prompt.ends_with('\n'),
5590            "a file reference must not drag its trailing newline in"
5591        );
5592
5593        // An unreadable file stays literal, like an unset `${VAR}`.
5594        assert_eq!(
5595            expand_env_vars("{file:/no/such/bp9/path}"),
5596            "{file:/no/such/bp9/path}"
5597        );
5598        // A set variable still wins over the default.
5599        std::env::set_var("BP9_SET_ENDPOINT", "https://from-env.example");
5600        assert_eq!(
5601            expand_env_vars("${BP9_SET_ENDPOINT:-https://defaulted.example}"),
5602            "https://from-env.example"
5603        );
5604        std::env::remove_var("BP9_SET_ENDPOINT");
5605        let _ = std::fs::remove_dir_all(&dir);
5606    }
5607
5608    /// D6 `env-command-substitution-in-config-values`: the `!command` form
5609    /// is REFUSED with a reason — never executed, never silently accepted.
5610    #[test]
5611    fn command_substitution_is_refused_with_a_reason() {
5612        let top = "schema_version = 1\nextends = \"cx-parity\"\n\
5613                   [core]\nbase_url = \"!echo https://pwned.example\"\n";
5614        let resolved = resolve(top, None, &ResolveOptions::default()).expect("resolves");
5615        assert_eq!(resolved.config.base_url, "!echo https://pwned.example");
5616        let refusal = resolved
5617            .warnings
5618            .iter()
5619            .find(|w| w.contains("core.base_url"))
5620            .unwrap_or_else(|| panic!("no refusal warning: {:?}", resolved.warnings));
5621        assert!(refusal.contains("`!command`"), "{refusal}");
5622        assert!(refusal.contains("api_key_cmd"), "{refusal}");
5623    }
5624
5625    /// D6 `feature-flag-system`: every `[experimental]` flag has a stage,
5626    /// the stage decides the default, and an unknown flag is reported
5627    /// rather than silently honored.
5628    #[test]
5629    fn experimental_flags_have_stages_and_report_unknown_keys() {
5630        let states = experimental_states(&HarnessConfig::default());
5631        assert!(!states.is_empty(), "the registry must not be empty");
5632        let module_registry = states
5633            .iter()
5634            .find(|s| s.name == "module_registry")
5635            .expect("module_registry is a known flag");
5636        assert_eq!(module_registry.stage, "default");
5637        assert!(module_registry.default);
5638        assert!(module_registry.enabled);
5639        assert!(!module_registry.explicit);
5640
5641        let top = "schema_version = 1\nextends = \"cc-parity\"\n\
5642                   [experimental]\nmodule_registry = false\nnot_a_real_flag = true\n";
5643        let resolved = resolve(top, None, &ResolveOptions::default()).expect("resolves");
5644        let states = experimental_states(&resolved.harness);
5645        let module_registry = states
5646            .iter()
5647            .find(|s| s.name == "module_registry")
5648            .expect("known");
5649        assert!(!module_registry.enabled);
5650        assert!(module_registry.explicit);
5651        assert!(
5652            resolved
5653                .warnings
5654                .iter()
5655                .any(|w| w.contains("unknown experimental flag `experimental.not_a_real_flag`")),
5656            "{:?}",
5657            resolved.warnings
5658        );
5659    }
5660
5661    /// D6 `config-reproducibility-lockfile`: a lock round-trips, matches a
5662    /// re-resolve of the same inputs, and names every drifting key when the
5663    /// inputs change.
5664    #[test]
5665    fn config_lock_round_trips_and_detects_drift() {
5666        let top = "schema_version = 1\nextends = \"cc-parity\"\n[core]\nmax_tokens = 100\n";
5667        let resolved = resolve(top, None, &ResolveOptions::default()).expect("resolves");
5668        let lock = ConfigLock::from_resolved(&resolved, "0.0.0-test");
5669        assert_eq!(lock.lock_version, CONFIG_LOCK_VERSION);
5670        assert_eq!(lock.preset_chain, resolved.preset_chain);
5671
5672        let parsed = ConfigLock::from_json(&lock.to_json()).expect("lock round-trips");
5673        assert_eq!(parsed, lock);
5674
5675        // Same inputs, same build → no drift.
5676        let again = resolve(top, None, &ResolveOptions::default()).expect("resolves");
5677        assert!(
5678            parsed.drift(&again, "0.0.0-test").is_empty(),
5679            "{:?}",
5680            parsed.drift(&again, "0.0.0-test")
5681        );
5682
5683        // Changed config → the changed key is named.
5684        let changed = "schema_version = 1\nextends = \"cc-parity\"\n[core]\nmax_tokens = 200\n";
5685        let changed = resolve(changed, None, &ResolveOptions::default()).expect("resolves");
5686        let drift = parsed.drift(&changed, "0.0.0-test");
5687        assert!(
5688            drift.iter().any(|d| d.starts_with("core.max_tokens:")),
5689            "{drift:?}"
5690        );
5691
5692        // Changed build → the version line is drift too.
5693        let drift = parsed.drift(&again, "9.9.9-other");
5694        assert!(
5695            drift.iter().any(|d| d.starts_with("supercode_version:")),
5696            "{drift:?}"
5697        );
5698
5699        // Changed preset chain → drift, even with a similar folded result.
5700        let other = resolve(
5701            "schema_version = 1\nextends = \"cx-parity\"\n[core]\nmax_tokens = 100\n",
5702            None,
5703            &ResolveOptions::default(),
5704        )
5705        .expect("resolves");
5706        assert!(
5707            parsed
5708                .drift(&other, "0.0.0-test")
5709                .iter()
5710                .any(|d| d.starts_with("preset_chain:")),
5711            "{:?}",
5712            parsed.drift(&other, "0.0.0-test")
5713        );
5714    }
5715
5716    /// D6 `credential-helpers-keyring` + `auto-update-channels`: the two
5717    /// new `[core]` keys parse, materialize onto the resolved `Config`
5718    /// under both parity presets, and are refused from a project layer.
5719    #[test]
5720    fn credential_helper_and_update_check_resolve_and_stay_project_forbidden() {
5721        for preset in ["cc-parity", "cx-parity"] {
5722            let top = format!(
5723                "schema_version = 1\nextends = \"{preset}\"\n[core]\n\
5724                 api_key_command = [\"op\", \"read\", \"op://vault/key\"]\n\
5725                 update_check = true\n"
5726            );
5727            let resolved = resolve(&top, None, &ResolveOptions::default()).expect("resolves");
5728            assert_eq!(
5729                resolved.config.api_key_command.as_deref(),
5730                Some(
5731                    ["op", "read", "op://vault/key"]
5732                        .map(String::from)
5733                        .as_slice()
5734                ),
5735                "{preset}"
5736            );
5737            assert!(resolved.config.update_check, "{preset}");
5738        }
5739
5740        let project = "schema_version = 1\n[core]\n\
5741                       api_key_command = [\"curl\", \"https://evil.example\"]\n\
5742                       update_check = true\n";
5743        let resolved = resolve(
5744            &parity_top("cc-parity"),
5745            Some(project),
5746            &ResolveOptions::default(),
5747        )
5748        .expect("resolves");
5749        assert!(resolved.config.api_key_command.is_none());
5750        assert!(!resolved.config.update_check);
5751        for key in ["core.api_key_command", "core.update_check"] {
5752            assert!(
5753                resolved
5754                    .warnings
5755                    .iter()
5756                    .any(|w| w.contains(&format!("dropped untrusted key `{key}`"))),
5757                "{key} not dropped: {:?}",
5758                resolved.warnings
5759            );
5760        }
5761    }
5762}