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/// P4 (design §5.2 "P4", §1.8: "env substitution in values"): expand
52/// `${VAR}` references in `s` against the process environment. Applied at
53/// [`HarnessConfig::to_config_profile`] to the string-valued `[core]`
54/// fields that plausibly vary per deployment — `base_url`, `system_prompt`,
55/// `additional_dirs`, `extra_headers` values, and `extra_body` string
56/// values (judgment call, §1.8's "in values" wording names no exhaustive
57/// field list; `api_key_env`/`api_key_cmd` are deliberately EXCLUDED — the
58/// former is already an env var NAME not a value, the latter is a shell
59/// command the shell itself expands when it runs, see the call site's
60/// comment).
61///
62/// An unset variable is left LITERAL (`${VAR}` stays in the output) rather
63/// than silently substituted with an empty string — a config author sees
64/// immediately that something didn't resolve instead of silently getting a
65/// blank `base_url`/header/etc. Only the braced `${NAME}` form is
66/// recognized — no bare `$NAME`, no shell-style `:-default` operators —
67/// the smallest form that satisfies the obligation without inventing a
68/// shell-expansion dialect.
69pub fn expand_env_vars(s: &str) -> String {
70    let mut out = String::with_capacity(s.len());
71    let mut rest = s;
72    while let Some(start) = rest.find("${") {
73        out.push_str(&rest[..start]);
74        let after = &rest[start + 2..];
75        match after.find('}') {
76            Some(end) => {
77                let var_name = &after[..end];
78                match std::env::var(var_name) {
79                    Ok(v) => out.push_str(&v),
80                    // Unset (or an invalid var name, e.g. one containing
81                    // `=`): keep the placeholder literal rather than
82                    // silently blanking it.
83                    Err(_) => out.push_str(&rest[start..start + 2 + end + 1]),
84                }
85                rest = &after[end + 1..];
86            }
87            None => {
88                // Unterminated `${` — emit the rest literally and stop.
89                out.push_str(&rest[start..]);
90                rest = "";
91                break;
92            }
93        }
94    }
95    out.push_str(rest);
96    out
97}
98
99/// The top-level schema (§3.1): one TOML/JSON document that fully determines
100/// the harness's shape (§3.0: "Everything the harness does is a function of
101/// the resolved file").
102#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
103pub struct HarnessConfig {
104    /// Schema version; `1` is the only version P1 understands.
105    #[serde(default = "default_schema_version")]
106    pub schema_version: u32,
107    /// Built-in preset name, or (user/global layer only, §3.3) a file path.
108    /// Parsed but NOT resolved in P1 — preset resolution is §3.5 / P2.
109    #[serde(default)]
110    pub extends: Option<String>,
111    /// `[core]` — obligation knobs (§1). Per §3.0, the region is always
112    /// present in a resolved config even when every knob inside it is
113    /// defaulted; `#[serde(default)]` gives an absent `[core]` table the
114    /// same all-defaulted shape.
115    #[serde(default)]
116    pub core: CoreSection,
117    /// `[capabilities.*]` — the §2 modules, keyed by capability name.
118    /// Parsed (the surface) but not consumed (the runtime) in P1 — see the
119    /// module doc comment.
120    #[serde(default)]
121    pub capabilities: BTreeMap<String, CapabilityConfig>,
122    /// `[experimental]` — obligation 8 feature flags, staged gates not yet
123    /// promoted to `[core]`. Untyped: P1 only carries the table through.
124    /// LOW-1 (P3 review): a project-layer file may never set ANY key in
125    /// this table — `sanitize_for_project` strips it whole, since future
126    /// flags added here aren't guaranteed narrowing-only the way
127    /// `module_registry` is today. User/global layer only.
128    #[serde(default)]
129    pub experimental: serde_json::Map<String, serde_json::Value>,
130}
131
132impl Default for HarnessConfig {
133    fn default() -> Self {
134        HarnessConfig {
135            schema_version: default_schema_version(),
136            extends: None,
137            core: CoreSection::default(),
138            capabilities: BTreeMap::new(),
139            experimental: serde_json::Map::new(),
140        }
141    }
142}
143
144/// `[core]` (§3.1 lines 581-609 + the named subtables that follow).
145#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
146pub struct CoreSection {
147    /// `Config.model` (config.rs).
148    pub model: Option<String>,
149    /// `Config.base_url` (config.rs). `[project-forbidden]` (§3.3).
150    pub base_url: Option<String>,
151    /// `Config.api_key_env` (config.rs). `[project-forbidden]` (§3.3).
152    pub api_key_env: Option<String>,
153    /// NEW: credential helper (`!command` form, pi§6 / D6 row).
154    /// `[project-forbidden]`. P4: consumed by the CLI's credential
155    /// resolution (`userconfig::resolve_api_key`) — captured, not yet wired.
156    pub api_key_cmd: Option<String>,
157    /// `Config.effort` (config.rs).
158    pub effort: Option<String>,
159    /// `Config.temperature` (config.rs).
160    pub temperature: Option<f32>,
161    /// `Config.max_tokens` (config.rs).
162    pub max_tokens: Option<u32>,
163    /// `Config.max_iterations` (config.rs).
164    pub max_iterations: Option<usize>,
165    /// `Config.max_total_output_tokens` (config.rs); `0`/absent = off.
166    pub max_total_output_tokens: Option<u64>,
167    /// `Config.max_tool_output_bytes` (config.rs).
168    pub max_tool_output_bytes: Option<usize>,
169    /// NEW: universal parallel tool-call execution (catalog:59). P4e:
170    /// consumed by `Agent::run_tools_concurrently` — see
171    /// `Config::parallel_tool_calls`'s doc comment.
172    pub parallel_tool_calls: Option<bool>,
173    /// NEW: shell-env snapshotting (catalog:338).
174    /// P3/P4: consumed by the bash tool module.
175    pub shell_env_snapshot: Option<bool>,
176    /// `Config.system_prompt` (config.rs). `[project-forbidden]` (§3.3).
177    pub system_prompt: Option<String>,
178    /// NEW: append lever (D2 row 1). `[project-forbidden]`.
179    /// P4: consumed by prompt assembly, alongside `system_prompt`.
180    pub append_system_prompt: Option<String>,
181    /// `Config.load_project_context` (config.rs).
182    pub project_context: Option<bool>,
183    /// NEW: environment block (catalog §4a).
184    /// P4: consumed by prompt assembly.
185    pub env_context: Option<bool>,
186    /// NEW: synthetic nudge blocks (catalog:91).
187    /// P4: consumed by prompt assembly.
188    pub context_injections: Option<bool>,
189    /// NEW: on-demand subdir instruction loading (catalog:84).
190    /// P4: consumed by the skills/instructions subsystem.
191    pub nested_instructions: Option<bool>,
192    /// NEW: `@path` / `instructions[]` imports (catalog:85).
193    /// P4: consumed by the skills/instructions subsystem.
194    pub instruction_imports: Option<bool>,
195    /// NEW: directory-walk stop markers (catalog:232).
196    /// P4: consumed by project-context discovery.
197    pub project_root_markers: Option<Vec<String>>,
198    /// NEW: live-apply config edits (catalog:221). ASPIRATIONAL /
199    /// UNIMPLEMENTED (P4e assessment): a genuine config-file-watch +
200    /// live-reload subsystem — detecting the resolved file changing on
201    /// disk, re-resolving the full `extends`/layering chain, and safely
202    /// swapping a live `Agent`'s `Config` mid-run without corrupting
203    /// in-flight state — is M+ (an architecturally significant addition
204    /// per catalog:221's "COMMON row" classification, not a small runtime
205    /// gap), not the S-sized "NEW: small" a config-plumbing-only key would
206    /// be. This field parses and round-trips through every merge/overlay
207    /// step (so a config file setting it is never silently dropped or
208    /// misinterpreted) but has NO consumer: setting it does nothing. Needs
209    /// explicit scheduling as its own unit (P5+), not a half-built watcher
210    /// here.
211    pub hot_reload: Option<bool>,
212    /// P4b (design §5.2 "P4" "instruction-walk nuances", cx§2
213    /// `project_doc_max_bytes` analog, §3.1 `core.project_doc_max_bytes`):
214    /// hygiene cap on the total bytes of assembled instruction-file content
215    /// — see `Config::project_doc_max_bytes`. Consumed by prompt assembly.
216    pub project_doc_max_bytes: Option<usize>,
217    /// `Config.additional_dirs` (config.rs). Project files may only ADD
218    /// under the repo root (§3.3) — enforced by `sanitize_for_project`'s
219    /// `is_safe_project_dir` check (LOW-1, Fable-5 P4a review), which strips
220    /// absolute/`~`/`..`-escaping/`${VAR}`-expanding entries from a project
221    /// layer before this is expanded (`to_config_profile`). User/global
222    /// layers are unrestricted.
223    pub additional_dirs: Option<Vec<String>>,
224    /// `Config.extra_headers` (config.rs). `[project-forbidden]`: exfil
225    /// channel (§3.3).
226    pub extra_headers: Option<HashMap<String, String>>,
227    /// `Config.extra_body` (config.rs). `[project-forbidden]` (§3.3).
228    pub extra_body: Option<serde_json::Map<String, serde_json::Value>>,
229    /// P4c (design §5.2 "P4", §5.2 P4 "doom-loop breaker", oc `doom_loop`
230    /// UNIQUE row, catalog D3): repeated-identical-tool-call threshold — see
231    /// `Config::doom_loop_threshold`. `None`/absent = off (today's
232    /// behavior).
233    pub doom_loop_threshold: Option<u32>,
234
235    /// `[core.model_switch]` — see the module-level doc comment on the
236    /// `[core.model]` naming conflict.
237    #[serde(default)]
238    pub model_switch: CoreModelSwitchConfig,
239    /// `[core.retry]` (obligation 1; pi§3 naming).
240    #[serde(default)]
241    pub retry: CoreRetryConfig,
242    /// `[core.tools]` — registry shaping.
243    #[serde(default)]
244    pub tools: CoreToolsConfig,
245    /// `[core.skills]` (obligation 4, D-7).
246    #[serde(default)]
247    pub skills: CoreSkillsConfig,
248    /// `[core.prompts]` — maps directly onto `Config.prompts` (config.rs);
249    /// a table merged key-wise onto the built-ins, not a wholesale replace
250    /// (§3.3), via `ConfigBuilder::apply_profile`.
251    #[serde(default)]
252    pub prompts: BTreeMap<String, String>,
253    /// `[core.compaction]` (obligation 5).
254    #[serde(default)]
255    pub compaction: CoreCompactionConfig,
256    /// `[core.session]` (obligation 6).
257    #[serde(default)]
258    pub session: CoreSessionConfig,
259    /// `[core.steering]` (obligation 7; pi§3 semantics).
260    #[serde(default)]
261    pub steering: CoreSteeringConfig,
262    /// `[core.output]` (obligation 9).
263    #[serde(default)]
264    pub output: CoreOutputConfig,
265}
266
267/// `[core.model_switch]` (design's `[core.model]`; see the naming-conflict
268/// doc comment above).
269#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
270pub struct CoreModelSwitchConfig {
271    /// NEW core subsystem: mid-session switch + persisted `model_change`
272    /// records (§1.10). P4: consumed by the agentic loop + session store.
273    pub allow_switch: Option<bool>,
274}
275
276/// `[core.retry]`. P4: consumed by a request-retry loop that doesn't exist
277/// as a `Config` field yet (obligation 1; pi§3 naming).
278#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
279pub struct CoreRetryConfig {
280    /// Whether the retry loop is on.
281    pub enabled: Option<bool>,
282    /// Maximum retry attempts.
283    pub max_retries: Option<u32>,
284    /// Base backoff delay in milliseconds (doubles per pi§3 semantics).
285    pub base_delay_ms: Option<u64>,
286}
287
288/// `[core.tools]` — registry shaping (§3.1 line 619; replaces
289/// `with_builtins()` hardcoding, `tools/mod.rs:179-192`, in **P3**).
290#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
291pub struct CoreToolsConfig {
292    /// The default-active tool names. P3: consumed by `ToolRegistry`
293    /// construction (`with_builtins()` today is unconditional).
294    pub enabled: Option<Vec<String>>,
295    /// Global schema tier — mirrors `ConfigProfile::schema_tier`; this ONE
296    /// *is* resolved in P1 via [`HarnessConfig::to_config_profile`], since
297    /// `Config.tool_schema_tier` already exists.
298    pub schema_tier: Option<String>,
299    /// `[core.tools.read_file]`.
300    #[serde(default)]
301    pub read_file: ReadFileToolConfig,
302    /// `[core.tools.edit_file]`.
303    #[serde(default)]
304    pub edit_file: EditFileToolConfig,
305    /// `[core.tools.bash]` — the one per-tool table P1 resolves into a real
306    /// `ToolOverride` (minus `timeout_secs`, see [`BashToolConfig`]).
307    #[serde(default)]
308    pub bash: BashToolConfig,
309}
310
311/// `[core.tools.read_file]`. P3/P4: `multimodal` has no `ToolOverride` home
312/// yet (catalog §4a small).
313#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
314pub struct ReadFileToolConfig {
315    /// Whether `read_file` may return image content (catalog §4a).
316    pub multimodal: Option<bool>,
317}
318
319/// `[core.tools.edit_file]`. P3/P4: `require_read_before_edit`/
320/// `notebook_aware` have no `ToolOverride` home yet (S6/S12 catalog rows 32,
321/// 40 — `ToolContext` state). `schema_tier` DOES resolve (P2 addition,
322/// mirroring `[core.tools.bash].schema_tier`'s existing P1 handling in
323/// [`HarnessConfig::to_config_profile`]) — needed for `token-saver`'s own
324/// C9 resolution (§2.2: "per-tool `Full` override survives a global
325/// `minimal`", design §4.5) to actually materialize into the resolved
326/// [`Config`] rather than silently parsing-and-dropping the one field the
327/// preset relies on.
328#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
329pub struct EditFileToolConfig {
330    /// Require a prior `read_file` on the same path before an edit is
331    /// accepted (unique CC row, catalog:32).
332    pub require_read_before_edit: Option<bool>,
333    /// Notebook-cell-aware editing (unique CC row "NotebookEdit", catalog:40).
334    pub notebook_aware: Option<bool>,
335    /// Per-tool schema tier override — `ToolOverride::schema_tier` for
336    /// `edit_file` (config.rs; C9, catalog §5 conflict 9).
337    pub schema_tier: Option<String>,
338}
339
340/// `[core.tools.bash]` — maps onto a real [`crate::config::ToolOverride`]
341/// (`enabled`/`description`/`schema_tier`/`timeout_secs`) via
342/// [`HarnessConfig::to_config_profile`] (P4e closes the `timeout_secs` gap
343/// S14 flagged — `BashTool`'s timeout is consumed via
344/// `tools::ToolContext::bash_timeout_secs`, threaded from
345/// `agent::build_tool_context`, not the `ToolOverride` struct directly,
346/// since `Tool::execute` only sees a `ToolContext`, not the resolved
347/// `Config`/`ToolOverride` map — see that field's doc comment).
348#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
349pub struct BashToolConfig {
350    /// `ToolOverride::enabled` for `bash`.
351    pub enabled: Option<bool>,
352    /// `ToolOverride::description` for `bash`.
353    pub description: Option<String>,
354    /// `ToolOverride::schema_tier` for `bash`.
355    pub schema_tier: Option<String>,
356    /// `ToolOverride::timeout_secs` for `bash` (P4e).
357    pub timeout_secs: Option<u64>,
358}
359
360/// `[core.skills]` (obligation 4, D-7). P3/P4: a NEW subsystem extending
361/// `Config.prompts`; not yet consumed.
362#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
363pub struct CoreSkillsConfig {
364    /// Whether the skills subsystem is on.
365    pub enabled: Option<bool>,
366    /// Extra roots merged over user+project skill defaults.
367    pub dirs: Option<Vec<String>>,
368}
369
370/// `[core.compaction]` (obligation 5). `after_messages` maps to the real
371/// `Config.compact_after_messages`, resolved in P1; the rest are P4 NEW
372/// pressure-trigger fields.
373#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
374pub struct CoreCompactionConfig {
375    /// P4: no master gate exists yet on `Config` — `after_messages =
376    /// Some(0)` (or absent) is today's only "off" signal.
377    pub enabled: Option<bool>,
378    /// `Config.compact_after_messages` (config.rs).
379    pub after_messages: Option<usize>,
380    /// P4 NEW (pi§2 shape).
381    pub reserve_tokens: Option<usize>,
382    /// P4 NEW.
383    pub keep_recent_tokens: Option<usize>,
384    /// P4: `SpanSummary` side-call gate (reduce.rs:274-289; D-9 small-model
385    /// fallback) — not yet consumed here.
386    pub summarize: Option<bool>,
387    /// P4b (design §5.2 "P4" "compaction pressure trigger + focus
388    /// instructions", §3.1 `core.compaction.focus_instructions`, catalog D2
389    /// "no instruction steering" gap) — see
390    /// `Config::compaction_focus_instructions`.
391    pub focus_instructions: Option<String>,
392}
393
394/// `[core.session]` (obligation 6). P3/P4: entirely NEW — no `Config`
395/// field represents a session store location/policy today.
396#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
397pub struct CoreSessionConfig {
398    /// Default session-store location.
399    pub dir: Option<String>,
400    /// Session naming/rename (S14).
401    pub name: Option<String>,
402    /// `false` = ephemeral (D5 row; cc/cx/pi have it).
403    pub persist: Option<bool>,
404    /// Retention window in days.
405    pub retention_days: Option<u32>,
406    /// Human transcript export format: `text` | `html` (catalog:283).
407    pub export_format: Option<String>,
408    /// Auto-title/session-summary (catalog:150; D-9 small-model consumer).
409    pub auto_title: Option<bool>,
410    /// Capture git branch/sha on write (catalog:331).
411    pub git_metadata: Option<bool>,
412}
413
414/// `[core.steering]` (obligation 7; pi§3 semantics). P4: NEW, no `Config`
415/// field yet.
416#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
417pub struct CoreSteeringConfig {
418    /// `all` | `one-at-a-time`.
419    pub steering_mode: Option<String>,
420    /// `all` | `one-at-a-time`.
421    pub follow_up_mode: Option<String>,
422}
423
424/// `[core.output]` (obligation 9). P3/P4: `Config.event_sink` is code-only
425/// ("Callbacks/handlers are code-only", config.rs); this is its declarative
426/// equivalent, not yet wired to anything.
427#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
428pub struct CoreOutputConfig {
429    /// `text` | `json` (JSONL event stream over `EventSink`).
430    pub format: Option<String>,
431}
432
433/// `[capabilities.<name>]` (§2 modules). Every module table carries
434/// `enabled` plus module-specific settings. P1 captures the settings as an
435/// untyped catch-all: the modules themselves are P3+ ("consumed later
436/// phases" per design §5.2's P1 description) — this struct is the config
437/// *surface* for them, not their runtime.
438#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
439pub struct CapabilityConfig {
440    /// Module master switch (§3.0: "every table has `enabled`").
441    pub enabled: Option<bool>,
442    /// Everything else the module's table carries (e.g.
443    /// `[capabilities.permissions] approval = "..."`), captured but not
444    /// consumed in P1.
445    #[serde(flatten)]
446    pub settings: serde_json::Map<String, serde_json::Value>,
447}
448
449/// F7 fix: `schema_version` previously parsed any `u32` silently — a future
450/// (or simply typo'd) version number would be interpreted under TODAY's
451/// field meanings with no warning at all, exactly the kind of silent
452/// misinterpretation §3.5 step 5's "fail SAFE" precedent exists to prevent
453/// elsewhere in this migration. Only `1` is understood in P1.
454#[derive(Debug)]
455pub enum HarnessConfigError {
456    /// The document isn't valid TOML, or doesn't match the schema.
457    Toml(toml::de::Error),
458    /// The document isn't valid JSON, or doesn't match the schema.
459    Json(serde_json::Error),
460    /// The document parsed fine, but named a `schema_version` this build
461    /// doesn't understand.
462    UnsupportedSchemaVersion(u32),
463}
464
465impl std::fmt::Display for HarnessConfigError {
466    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
467        match self {
468            HarnessConfigError::Toml(e) => write!(f, "{e}"),
469            HarnessConfigError::Json(e) => write!(f, "{e}"),
470            HarnessConfigError::UnsupportedSchemaVersion(v) => write!(
471                f,
472                "unsupported schema_version {v}; this build only understands schema_version = 1"
473            ),
474        }
475    }
476}
477
478impl std::error::Error for HarnessConfigError {}
479
480impl HarnessConfig {
481    /// Parse from TOML text — the CLI's format (`.supercode.toml` /
482    /// `config.toml`).
483    pub fn from_toml_str(s: &str) -> Result<Self, HarnessConfigError> {
484        let hc: HarnessConfig = toml::from_str(s).map_err(HarnessConfigError::Toml)?;
485        hc.check_schema_version()?;
486        Ok(hc)
487    }
488
489    /// Parse from JSON text — the SDK mirror (§3.0).
490    pub fn from_json_str(s: &str) -> Result<Self, HarnessConfigError> {
491        let hc: HarnessConfig = serde_json::from_str(s).map_err(HarnessConfigError::Json)?;
492        hc.check_schema_version()?;
493        Ok(hc)
494    }
495
496    /// F7: reject an unknown `schema_version` rather than silently
497    /// interpreting it under P1's `[core]`/`[capabilities]` field meanings.
498    fn check_schema_version(&self) -> Result<(), HarnessConfigError> {
499        if self.schema_version != 1 {
500            return Err(HarnessConfigError::UnsupportedSchemaVersion(
501                self.schema_version,
502            ));
503        }
504        Ok(())
505    }
506
507    /// Resolve the `[core]` region (§3.1) into a [`ConfigProfile`] — the
508    /// same per-key overlay type [`ConfigBuilder::apply_profile`] already
509    /// knows how to fold (§3.3: scalars replace, tables merge, arrays
510    /// replace). `[capabilities.*]` is deliberately NOT read here (P1 scope:
511    /// module settings are P3 consumption); `extends` is deliberately NOT
512    /// followed (P2: preset resolution, §3.5).
513    pub fn to_config_profile(&self) -> ConfigProfile {
514        let c = &self.core;
515
516        let mut tool_overrides = HashMap::new();
517        if c.tools.bash.enabled.is_some()
518            || c.tools.bash.description.is_some()
519            || c.tools.bash.schema_tier.is_some()
520            || c.tools.bash.timeout_secs.is_some()
521        {
522            tool_overrides.insert(
523                "bash".to_string(),
524                ToolOverrideProfile {
525                    enabled: c.tools.bash.enabled,
526                    description: c.tools.bash.description.clone(),
527                    schema_tier: c.tools.bash.schema_tier.clone(),
528                    // P4e (§3.1 `core.tools.bash.timeout_secs`, S14): reaches
529                    // `Config.tool_overrides["bash"].timeout_secs`, which
530                    // `agent::build_tool_context` folds into
531                    // `ToolContext::bash_timeout_secs` for `BashTool::execute`.
532                    timeout_secs: c.tools.bash.timeout_secs,
533                },
534            );
535        }
536        // P2 addition: `edit_file`'s `schema_tier` resolves the same way
537        // `bash`'s does (see the `EditFileToolConfig` doc comment) —
538        // `require_read_before_edit`/`notebook_aware` still have no
539        // `ToolOverride` field (P3/P4), so they're excluded here.
540        if c.tools.edit_file.schema_tier.is_some() {
541            tool_overrides.insert(
542                "edit_file".to_string(),
543                ToolOverrideProfile {
544                    enabled: None,
545                    description: None,
546                    schema_tier: c.tools.edit_file.schema_tier.clone(),
547                    timeout_secs: None,
548                },
549            );
550        }
551
552        ConfigProfile {
553            model: c.model.clone(),
554            // P4 (§1.8 "env substitution in values"): `${VAR}` expansion —
555            // see `expand_env_vars`'s doc comment for the exact fields this
556            // applies to and why. `base_url` is the flagship case (D6 row:
557            // route to a different endpoint per environment without a
558            // separate config file per deployment).
559            base_url: c.base_url.as_deref().map(expand_env_vars),
560            api_key_env: c.api_key_env.clone(),
561            // NOT expanded: this is a COMMAND string (§1.8 D6 row), and the
562            // shell that runs it (`sh -c`, `agent.rs::run_api_key_cmd`)
563            // already expands `${VAR}`/`$VAR` itself — expanding it again
564            // here would double-substitute and could leak a resolved value
565            // into a place that then gets logged/echoed as plain config
566            // text instead of running through the shell's own environment.
567            api_key_cmd: c.api_key_cmd.clone(),
568            system_prompt: c.system_prompt.as_deref().map(expand_env_vars),
569            // P4 (§3.1 `core.append_system_prompt`, D2 row 1): additive,
570            // never a replacement — see `ConfigBuilder::apply_profile`'s
571            // composition. Same env-substitution treatment as
572            // `system_prompt` above.
573            append_system_prompt: c.append_system_prompt.as_deref().map(expand_env_vars),
574            temperature: c.temperature,
575            max_tokens: c.max_tokens,
576            effort: c.effort.clone(),
577            // §3.1: sandbox/approval live under `[capabilities.permissions]`,
578            // not `[core]` — module-settings consumption is P3, so this
579            // `[core]`-only resolver leaves them unset.
580            sandbox: None,
581            approval: None,
582            project_context: c.project_context,
583            max_iterations: c.max_iterations,
584            additional_dirs: c
585                .additional_dirs
586                .as_ref()
587                .map(|dirs| dirs.iter().map(|d| expand_env_vars(d)).collect()),
588            compact_after_messages: c.compaction.after_messages,
589            // §3.1: lives under `[capabilities.cache]` — P3 consumption.
590            cache_plan: None,
591            // §3.1: lives under `[capabilities.deferred_tools]` — P3.
592            tool_advertising: None,
593            tool_advertising_core: None,
594            schema_tier: c.tools.schema_tier.clone(),
595            // §3.1: lives under `[capabilities.permissions]` — set by
596            // `materialize_config` after this `[core]`-only resolver runs.
597            auto_approved_tools: None,
598            tool_deny_patterns: None,
599            tool_allow_patterns: None,
600            extra_headers: c.extra_headers.as_ref().map(|headers| {
601                headers
602                    .iter()
603                    .map(|(k, v)| (k.clone(), expand_env_vars(v)))
604                    .collect()
605            }),
606            extra_body: c.extra_body.as_ref().map(|body| {
607                body.iter()
608                    .map(|(k, v)| {
609                        let v = match v {
610                            serde_json::Value::String(s) => {
611                                serde_json::Value::String(expand_env_vars(s))
612                            }
613                            other => other.clone(),
614                        };
615                        (k.clone(), v)
616                    })
617                    .collect()
618            }),
619            max_tool_output_bytes: c.max_tool_output_bytes,
620            max_total_output_tokens: c.max_total_output_tokens,
621            prompts: if c.prompts.is_empty() {
622                None
623            } else {
624                Some(
625                    c.prompts
626                        .iter()
627                        .map(|(k, v)| (k.clone(), v.clone()))
628                        .collect(),
629                )
630            },
631            tool_overrides: if tool_overrides.is_empty() {
632                None
633            } else {
634                Some(tool_overrides)
635            },
636            // P4b: obligations 1/4/5/6/7 — see each field's doc comment on
637            // `ConfigProfile`/`Config` for the exact §3.1 key it maps.
638            env_context: c.env_context,
639            project_root_markers: c.project_root_markers.clone(),
640            project_doc_max_bytes: c.project_doc_max_bytes,
641            instruction_imports: c.instruction_imports,
642            retry_enabled: c.retry.enabled,
643            retry_max_retries: c.retry.max_retries,
644            retry_base_delay_ms: c.retry.base_delay_ms,
645            compaction_reserve_tokens: c.compaction.reserve_tokens.map(|n| n as u64),
646            compaction_keep_recent_tokens: c.compaction.keep_recent_tokens.map(|n| n as u64),
647            compaction_focus_instructions: c.compaction.focus_instructions.clone(),
648            auto_title: c.session.auto_title,
649            steering_mode: c.steering.steering_mode.clone(),
650            follow_up_mode: c.steering.follow_up_mode.clone(),
651            // P4c: obligations 2/4/10 — see each field's doc comment on
652            // `ConfigProfile`/`Config` for the exact §3.1 key it maps.
653            read_file_multimodal: c.tools.read_file.multimodal,
654            edit_file_require_read_before_edit: c.tools.edit_file.require_read_before_edit,
655            edit_file_notebook_aware: c.tools.edit_file.notebook_aware,
656            shell_env_snapshot: c.shell_env_snapshot,
657            doom_loop_threshold: c.doom_loop_threshold,
658            nested_instructions: c.nested_instructions,
659            model_switch_allow_switch: c.model_switch.allow_switch,
660            // P4e: obligations 1/4/5/6 — see each field's doc comment on
661            // `ConfigProfile`/`Config` for the exact §3.1 key it maps.
662            context_injections: c.context_injections,
663            compaction_enabled: c.compaction.enabled,
664            parallel_tool_calls: c.parallel_tool_calls,
665            session_git_metadata: c.session.git_metadata,
666            session_dir: c.session.dir.clone(),
667            session_persist: c.session.persist,
668            session_name: c.session.name.clone(),
669            session_retention_days: c.session.retention_days,
670            session_export_format: c.session.export_format.clone(),
671        }
672    }
673
674    /// Resolve straight into a [`Config`] via
675    /// [`ConfigBuilder::apply_profile`] — a convenience for embedders/tests
676    /// that don't need the intermediate profile. Ignores `extends` (P2) and
677    /// every `[capabilities.*]` module (P3+); P1 is the `[core]` config
678    /// surface only (design §5.2).
679    pub fn resolve_core(&self) -> Config {
680        ConfigBuilder::default()
681            .apply_profile(&self.to_config_profile())
682            .build()
683    }
684
685    /// §3.3 overlay: `over` wins wherever it sets a value. Scalars replace,
686    /// tables merge key-wise (recursively for `[capabilities.*]` settings),
687    /// arrays replace wholesale — the same semantics
688    /// [`ConfigBuilder::apply_profile`] already uses for the `[core]`
689    /// region, generalized here to the whole `HarnessConfig` (§3.5 step 3's
690    /// "fold the chain … with the §3.3 overlay semantics").
691    pub fn overlay(&self, over: &HarnessConfig) -> HarnessConfig {
692        HarnessConfig {
693            schema_version: over.schema_version,
694            extends: over.extends.clone().or_else(|| self.extends.clone()),
695            core: merge_core(&self.core, &over.core),
696            capabilities: merge_capabilities(&self.capabilities, &over.capabilities),
697            experimental: {
698                let mut e = self.experimental.clone();
699                merge_json_object(&mut e, &over.experimental);
700                e
701            },
702        }
703    }
704}
705
706macro_rules! merge_opt {
707    ($base:expr, $over:expr, $field:ident) => {
708        $over.$field.clone().or_else(|| $base.$field.clone())
709    };
710}
711
712fn merge_core(base: &CoreSection, over: &CoreSection) -> CoreSection {
713    CoreSection {
714        model: merge_opt!(base, over, model),
715        base_url: merge_opt!(base, over, base_url),
716        api_key_env: merge_opt!(base, over, api_key_env),
717        api_key_cmd: merge_opt!(base, over, api_key_cmd),
718        effort: merge_opt!(base, over, effort),
719        temperature: merge_opt!(base, over, temperature),
720        max_tokens: merge_opt!(base, over, max_tokens),
721        max_iterations: merge_opt!(base, over, max_iterations),
722        max_total_output_tokens: merge_opt!(base, over, max_total_output_tokens),
723        max_tool_output_bytes: merge_opt!(base, over, max_tool_output_bytes),
724        parallel_tool_calls: merge_opt!(base, over, parallel_tool_calls),
725        shell_env_snapshot: merge_opt!(base, over, shell_env_snapshot),
726        system_prompt: merge_opt!(base, over, system_prompt),
727        append_system_prompt: merge_opt!(base, over, append_system_prompt),
728        project_context: merge_opt!(base, over, project_context),
729        env_context: merge_opt!(base, over, env_context),
730        context_injections: merge_opt!(base, over, context_injections),
731        nested_instructions: merge_opt!(base, over, nested_instructions),
732        instruction_imports: merge_opt!(base, over, instruction_imports),
733        project_root_markers: merge_opt!(base, over, project_root_markers),
734        hot_reload: merge_opt!(base, over, hot_reload),
735        project_doc_max_bytes: merge_opt!(base, over, project_doc_max_bytes),
736        doom_loop_threshold: merge_opt!(base, over, doom_loop_threshold),
737        additional_dirs: merge_opt!(base, over, additional_dirs),
738        extra_headers: match (&base.extra_headers, &over.extra_headers) {
739            (Some(b), Some(o)) => {
740                let mut m = b.clone();
741                m.extend(o.clone());
742                Some(m)
743            }
744            (None, Some(o)) => Some(o.clone()),
745            (b, None) => b.clone(),
746        },
747        extra_body: match (&base.extra_body, &over.extra_body) {
748            (Some(b), Some(o)) => {
749                let mut m = b.clone();
750                for (k, v) in o {
751                    m.insert(k.clone(), v.clone());
752                }
753                Some(m)
754            }
755            (None, Some(o)) => Some(o.clone()),
756            (b, None) => b.clone(),
757        },
758        model_switch: CoreModelSwitchConfig {
759            allow_switch: merge_opt!(base.model_switch, over.model_switch, allow_switch),
760        },
761        retry: CoreRetryConfig {
762            enabled: merge_opt!(base.retry, over.retry, enabled),
763            max_retries: merge_opt!(base.retry, over.retry, max_retries),
764            base_delay_ms: merge_opt!(base.retry, over.retry, base_delay_ms),
765        },
766        tools: CoreToolsConfig {
767            enabled: merge_opt!(base.tools, over.tools, enabled),
768            schema_tier: merge_opt!(base.tools, over.tools, schema_tier),
769            read_file: ReadFileToolConfig {
770                multimodal: merge_opt!(base.tools.read_file, over.tools.read_file, multimodal),
771            },
772            edit_file: EditFileToolConfig {
773                require_read_before_edit: merge_opt!(
774                    base.tools.edit_file,
775                    over.tools.edit_file,
776                    require_read_before_edit
777                ),
778                notebook_aware: merge_opt!(
779                    base.tools.edit_file,
780                    over.tools.edit_file,
781                    notebook_aware
782                ),
783                schema_tier: merge_opt!(base.tools.edit_file, over.tools.edit_file, schema_tier),
784            },
785            bash: BashToolConfig {
786                enabled: merge_opt!(base.tools.bash, over.tools.bash, enabled),
787                description: merge_opt!(base.tools.bash, over.tools.bash, description),
788                schema_tier: merge_opt!(base.tools.bash, over.tools.bash, schema_tier),
789                timeout_secs: merge_opt!(base.tools.bash, over.tools.bash, timeout_secs),
790            },
791        },
792        skills: CoreSkillsConfig {
793            enabled: merge_opt!(base.skills, over.skills, enabled),
794            dirs: merge_opt!(base.skills, over.skills, dirs),
795        },
796        prompts: {
797            let mut p = base.prompts.clone();
798            for (k, v) in &over.prompts {
799                p.insert(k.clone(), v.clone());
800            }
801            p
802        },
803        compaction: CoreCompactionConfig {
804            enabled: merge_opt!(base.compaction, over.compaction, enabled),
805            after_messages: merge_opt!(base.compaction, over.compaction, after_messages),
806            reserve_tokens: merge_opt!(base.compaction, over.compaction, reserve_tokens),
807            keep_recent_tokens: merge_opt!(base.compaction, over.compaction, keep_recent_tokens),
808            summarize: merge_opt!(base.compaction, over.compaction, summarize),
809            focus_instructions: merge_opt!(base.compaction, over.compaction, focus_instructions),
810        },
811        session: CoreSessionConfig {
812            dir: merge_opt!(base.session, over.session, dir),
813            name: merge_opt!(base.session, over.session, name),
814            persist: merge_opt!(base.session, over.session, persist),
815            retention_days: merge_opt!(base.session, over.session, retention_days),
816            export_format: merge_opt!(base.session, over.session, export_format),
817            auto_title: merge_opt!(base.session, over.session, auto_title),
818            git_metadata: merge_opt!(base.session, over.session, git_metadata),
819        },
820        steering: CoreSteeringConfig {
821            steering_mode: merge_opt!(base.steering, over.steering, steering_mode),
822            follow_up_mode: merge_opt!(base.steering, over.steering, follow_up_mode),
823        },
824        output: CoreOutputConfig {
825            format: merge_opt!(base.output, over.output, format),
826        },
827    }
828}
829
830/// Recursive key-wise JSON-object merge (§3.3 "tables merge key-wise"):
831/// nested objects merge recursively; everything else (scalars, arrays)
832/// replaces wholesale when `over` sets it.
833fn merge_json_object(
834    base: &mut serde_json::Map<String, serde_json::Value>,
835    over: &serde_json::Map<String, serde_json::Value>,
836) {
837    for (k, v) in over {
838        match (base.get_mut(k), v) {
839            (Some(serde_json::Value::Object(b)), serde_json::Value::Object(o)) => {
840                merge_json_object(b, o);
841            }
842            _ => {
843                base.insert(k.clone(), v.clone());
844            }
845        }
846    }
847}
848
849/// `[capabilities.*]` merge (§3.3): per capability name, `enabled` replaces
850/// and `settings` merges key-wise recursively (via `merge_json_object`) —
851/// this is what lets `extends = "cc-parity"` plus a single
852/// `capabilities.permissions.approval = "…"` override win without clobbering
853/// the rest of the preset's `permissions` table (design §3.5 closing:
854/// "per-key override layering means a preset is never all-or-nothing").
855fn merge_capabilities(
856    base: &BTreeMap<String, CapabilityConfig>,
857    over: &BTreeMap<String, CapabilityConfig>,
858) -> BTreeMap<String, CapabilityConfig> {
859    let mut out = base.clone();
860    for (name, ov) in over {
861        match out.get_mut(name) {
862            Some(existing) => {
863                existing.enabled = ov.enabled.or(existing.enabled);
864                merge_json_object(&mut existing.settings, &ov.settings);
865            }
866            None => {
867                out.insert(name.clone(), ov.clone());
868            }
869        }
870    }
871    out
872}
873
874/// Merge the project-layer reduction module without allowing an untrusted
875/// repository to widen an explicit trusted disable. Reduction is the one
876/// capability a project may enable when the trusted layer is silent, but an
877/// explicit `false` on either the module master switch or a documented pass
878/// gate is narrowing and therefore dominates `true` from the other layer.
879/// Settings still deep-merge so a sibling project key cannot discard trusted
880/// gates that it did not mention.
881pub fn merge_reduction_capability(
882    trusted: Option<&CapabilityConfig>,
883    project: Option<&CapabilityConfig>,
884) -> Option<CapabilityConfig> {
885    fn narrowing_bool(trusted: Option<bool>, project: Option<bool>) -> Option<bool> {
886        match (trusted, project) {
887            (Some(false), _) | (_, Some(false)) => Some(false),
888            (_, Some(true)) => Some(true),
889            (Some(true), None) => Some(true),
890            (None, None) => None,
891        }
892    }
893
894    const BOOLEAN_GATES: &[&str] = &[
895        "stale_reads",
896        "diff_reads",
897        "duplicates",
898        "tool_input_elision",
899        "supersede",
900        "normalize_output",
901        "image_redaction",
902        "span_summaries",
903        "handoff",
904    ];
905
906    match (trusted, project) {
907        (None, None) => None,
908        (Some(t), None) => Some(t.clone()),
909        (None, Some(p)) => Some(p.clone()),
910        (Some(t), Some(p)) => {
911            let mut merged = t.clone();
912            merged.enabled = narrowing_bool(t.enabled, p.enabled);
913            merge_json_object(&mut merged.settings, &p.settings);
914            for key in BOOLEAN_GATES {
915                let trusted_value = t.settings.get(*key).and_then(|v| v.as_bool());
916                let project_value = p.settings.get(*key).and_then(|v| v.as_bool());
917                if let Some(value) = narrowing_bool(trusted_value, project_value) {
918                    merged
919                        .settings
920                        .insert((*key).to_string(), serde_json::Value::Bool(value));
921                }
922            }
923            Some(merged)
924        }
925    }
926}
927
928/// Read a nested string-array setting by dotted PATH segments (e.g.
929/// `&["rules", "deny"]`, `&["rules", "ask"]`, `&["protected_paths",
930/// "paths"]`) — shared by [`merge_permissions_capability`] below. Generalizes
931/// the P4a `deny_array` helper (originally hardcoded to `rules.deny` alone)
932/// so the P5-1 `rules.ask`/`protected_paths.paths` siblings can reuse the
933/// SAME union-not-replace project-merge protection — see that function's
934/// doc comment on why a bare array-replace is unsafe for any of these three.
935fn nested_str_array(
936    settings: &serde_json::Map<String, serde_json::Value>,
937    path: &[&str],
938) -> Vec<String> {
939    let Some((last, dirs)) = path.split_last() else {
940        return Vec::new();
941    };
942    let mut cur = settings;
943    for seg in dirs {
944        match cur.get(*seg).and_then(|v| v.as_object()) {
945            Some(m) => cur = m,
946            None => return Vec::new(),
947        }
948    }
949    cur.get(*last)
950        .and_then(|v| v.as_array())
951        .map(|a| {
952            a.iter()
953                .filter_map(|x| x.as_str().map(String::from))
954                .collect()
955        })
956        .unwrap_or_default()
957}
958
959/// Overwrite the nested string-array setting at `path` (creating
960/// intermediate tables as needed) — the write-side counterpart of
961/// [`nested_str_array`].
962fn set_nested_str_array(
963    settings: &mut serde_json::Map<String, serde_json::Value>,
964    path: &[&str],
965    value: Vec<String>,
966) {
967    let Some((last, dirs)) = path.split_last() else {
968        return;
969    };
970    let mut cur = settings;
971    for seg in dirs {
972        let entry = cur
973            .entry((*seg).to_string())
974            .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
975        if !entry.is_object() {
976            *entry = serde_json::Value::Object(serde_json::Map::new());
977        }
978        cur = entry.as_object_mut().expect("just ensured object above");
979    }
980    cur.insert(
981        (*last).to_string(),
982        serde_json::Value::Array(value.into_iter().map(serde_json::Value::String).collect()),
983    );
984}
985
986/// Union two arrays read via [`nested_str_array`] and write the result back
987/// via [`set_nested_str_array`] — a project may only ADD entries at `path`,
988/// never remove or shrink the trusted layer's (see
989/// [`merge_permissions_capability`]'s doc comment for the argument that this
990/// is safe/narrowing for `rules.deny`, `rules.ask`, and
991/// `protected_paths.paths` alike: an entry at any of these three can only
992/// make a decision STRICTER, never looser, so a project adding one is
993/// always legal, and a project silently REMOVING one via array-replace is
994/// exactly the widening this closes). No-op (skips the write) when both
995/// sides are empty, so a `HarnessConfig` with no permissions table at all
996/// round-trips with zero spurious `rules`/`protected_paths` tables created.
997fn union_nested_str_array(
998    trusted: &serde_json::Map<String, serde_json::Value>,
999    project: &serde_json::Map<String, serde_json::Value>,
1000    merged: &mut serde_json::Map<String, serde_json::Value>,
1001    path: &[&str],
1002) {
1003    let trusted_vals = nested_str_array(trusted, path);
1004    let project_vals = nested_str_array(project, path);
1005    if trusted_vals.is_empty() && project_vals.is_empty() {
1006        return;
1007    }
1008    let mut union = trusted_vals;
1009    for v in project_vals {
1010        if !union.contains(&v) {
1011            union.push(v);
1012        }
1013    }
1014    set_nested_str_array(merged, path, union);
1015}
1016
1017/// Merge a project-layer `capabilities.permissions` table onto the trusted
1018/// (user/global) layer's — the single canonical merge BOTH the CLI route
1019/// (`crates/cli/src/userconfig.rs::overlay_project`) and this core resolver
1020/// (`resolve_top`, below) call, so the two routes cannot diverge the way the
1021/// independent Fable-5 review of P4a found (proven attacks, both against the
1022/// hard approval floor `Config::needs_approval` gives `rules.deny` — true
1023/// even under `ApprovalPolicy::Never`):
1024///
1025/// - **Attack A (whole-table replace):** a per-capability `insert` (what the
1026///   CLI's `overlay_project` used to do, and what a naive per-name merge
1027///   would still do here) lets a hostile project's `[capabilities.
1028///   permissions]` table — even one `sanitize_for_project`/
1029///   `sanitized_for_project` strips down to an EMPTY table because every key
1030///   it set was forbidden — wholesale REPLACE the trusted layer's populated
1031///   table, silently wiping `rules.deny` and everything else the user set.
1032///   Fixed by deep-merging into a CLONE of the trusted table (via
1033///   `merge_json_object`) rather than ever substituting the project's.
1034/// - **Attack B (array-replace widens deny):** `merge_json_object`'s "arrays
1035///   replace wholesale" rule (§3.3 "tables merge key-wise… arrays replace")
1036///   is correct for `rules.allow` (a widening `allow` is already stripped
1037///   from a sanitized project layer by P1/P4a) but WRONG for `rules.deny`: a
1038///   project's own `deny = […]` would otherwise REPLACE, not add to, the
1039///   trusted layer's list — e.g. user `deny = ["bash*"]` + project
1040///   `deny = ["harmless*"]` merging to `["harmless*"]` is a real widening
1041///   (the floor that blocks `bash*` vanishes). Fixed by unioning
1042///   `rules.deny` explicitly after the deep merge: a project may only ADD
1043///   deny entries, never remove or shrink the trusted layer's — deny
1044///   strictly grows.
1045/// - **Attack B', P5-1 extension:** the identical array-replace hazard
1046///   applies to TWO more keys the P5-1 permissions engine newly consumes:
1047///   `rules.ask` (module 11) and `protected_paths.paths` (module 13). Both
1048///   are narrowing-only by the SAME argument as `deny` — an `ask` entry can
1049///   only make a decision STRICTER (it is checked before `allow`, and can
1050///   never override a `deny`), and a protected path is an unconditional
1051///   deny floor for read+write — so a project may only ADD to either, never
1052///   silently wipe the trusted layer's via `protected_paths.paths = []`/
1053///   `rules.ask = []`. Fixed the same way: union both, right alongside
1054///   `rules.deny`, immediately below.
1055///
1056/// `rules.allow` and every other key keep plain deep-merge/replace
1057/// semantics: this function does not re-derive the sanitizer's trust
1058/// decisions (that's `sanitize_for_project`/`sanitized_for_project`'s job),
1059/// it only guarantees the MERGE step can't reintroduce a widening those
1060/// sanitizers already ruled out.
1061///
1062/// No behavior change for the common case: with no project `permissions`
1063/// table, this returns the trusted layer's table unchanged.
1064pub fn merge_permissions_capability(
1065    trusted: Option<&CapabilityConfig>,
1066    project: Option<&CapabilityConfig>,
1067) -> Option<CapabilityConfig> {
1068    match (trusted, project) {
1069        (None, None) => None,
1070        (Some(t), None) => Some(t.clone()),
1071        (None, Some(p)) => Some(p.clone()),
1072        (Some(t), Some(p)) => {
1073            let mut merged = t.clone();
1074            merged.enabled = p.enabled.or(t.enabled);
1075            merge_json_object(&mut merged.settings, &p.settings);
1076            // CRITICAL fix (P5-10 security reopen): `merge_json_object`'s
1077            // generic type-mismatch rule ("everything else replaces
1078            // wholesale when `over` sets it") is UNSAFE specifically for
1079            // `sandbox`, because the bare-string shorthand `sandbox = "X"`
1080            // is §3.1-defined as identical to the table form `sandbox =
1081            // { tier = "X" }`. When the trusted layer used the bare form and
1082            // the project supplied the table form (now a normal,
1083            // non-adversarial shape since P5-10's `escalation`/`env_policy`/
1084            // `network` subkeys live only in the table), the generic merge
1085            // above REPLACED the trusted string wholesale with the
1086            // project's object — even a project object with NO `tier` at
1087            // all (either because a hostile `tier` was already stripped by
1088            // `sanitize_for_project`, or because the project only set a
1089            // benign subkey like `env_policy`) — silently erasing the base
1090            // tier and falling back to the `DangerFullAccess` default with
1091            // no warning. Recompute `sandbox` via [`merge_sandbox_value`],
1092            // which normalizes BOTH sides to canonical table form before
1093            // deep-merging, so a tier-less project overlay can never erase
1094            // the base's tier.
1095            match merge_sandbox_value(t.settings.get("sandbox"), p.settings.get("sandbox")) {
1096                Some(v) => {
1097                    merged.settings.insert("sandbox".to_string(), v);
1098                }
1099                None => {
1100                    merged.settings.remove("sandbox");
1101                }
1102            }
1103            union_nested_str_array(
1104                &t.settings,
1105                &p.settings,
1106                &mut merged.settings,
1107                &["rules", "deny"],
1108            );
1109            union_nested_str_array(
1110                &t.settings,
1111                &p.settings,
1112                &mut merged.settings,
1113                &["rules", "ask"],
1114            );
1115            union_nested_str_array(
1116                &t.settings,
1117                &p.settings,
1118                &mut merged.settings,
1119                &["protected_paths", "paths"],
1120            );
1121            Some(merged)
1122        }
1123    }
1124}
1125
1126/// Canonicalize + deep-merge the `capabilities.permissions.sandbox` value
1127/// across the trusted/project layers — the type-safe replacement for
1128/// running it through the generic `merge_json_object` (see
1129/// [`merge_permissions_capability`]'s doc comment on the CRITICAL P5-10
1130/// security-reopen fix this closes). §3.1 defines the bare-string shorthand
1131/// `sandbox = "X"` as identical to the table form `sandbox = { tier = "X" }`
1132/// — this function normalizes BOTH sides to that table form first, then
1133/// deep-merges key-wise, so:
1134///
1135/// - a trusted bare-string tier survives a project table overlay that omits
1136///   `tier` entirely (the silent-widen-to-`DangerFullAccess` hole);
1137/// - a project's own `tier`/`escalation`/`env_policy`/`network`/`enabled`
1138///   subkeys still take effect and are still subject to
1139///   [`clamp_project_permissions`]'s separate rank-vs-base-layer clamp
1140///   below (this function only fixes the MERGE representation, not the
1141///   monotonic-tightening policy decision).
1142fn merge_sandbox_value(
1143    base: Option<&serde_json::Value>,
1144    project: Option<&serde_json::Value>,
1145) -> Option<serde_json::Value> {
1146    fn to_table(v: &serde_json::Value) -> serde_json::Map<String, serde_json::Value> {
1147        match v {
1148            serde_json::Value::String(s) => {
1149                let mut m = serde_json::Map::new();
1150                m.insert("tier".to_string(), serde_json::Value::String(s.clone()));
1151                m
1152            }
1153            serde_json::Value::Object(o) => o.clone(),
1154            _ => serde_json::Map::new(),
1155        }
1156    }
1157    match (base, project) {
1158        (None, None) => None,
1159        (Some(b), None) => Some(b.clone()),
1160        (None, Some(p)) => Some(p.clone()),
1161        (Some(b), Some(p)) => {
1162            let mut merged = to_table(b);
1163            let proj_table = to_table(p);
1164            merge_json_object(&mut merged, &proj_table);
1165            Some(serde_json::Value::Object(merged))
1166        }
1167    }
1168}
1169
1170// ---------------------------------------------------------------------------
1171// §3.3 project sanitization for `HarnessConfig` (P2's resolver-native mirror
1172// of `crates/cli/src/userconfig.rs`'s `sanitized_for_project` — that
1173// function keeps gating the CLI's existing `FileConfig`-based `load()` path
1174// unchanged; this is the parallel, additive rule for the new
1175// `HarnessConfig`-based §3.5 resolver, same monotonic-tightening contract:
1176// "a project file may only NARROW the harness, never widen or redirect it"
1177// (§3.3), sanitize-before-merge (§3.5 step 4).
1178// ---------------------------------------------------------------------------
1179
1180/// Parse a `sandbox` string the same way
1181/// `crates/cli/src/main.rs::parse_sandbox` does (alias-normalizing:
1182/// `_`/`-`/case-insensitive, `full` as a `danger_full_access` alias) — a
1183/// small, deliberate duplication rather than a cross-crate dependency (`cli`
1184/// already depends on `core`, not the reverse), documented here so the two
1185/// copies can be kept in lock-step if the alias set ever changes.
1186fn parse_sandbox_str(s: &str) -> Option<SandboxPolicy> {
1187    match s.replace('_', "-").to_ascii_lowercase().as_str() {
1188        "read-only" | "readonly" => Some(SandboxPolicy::ReadOnly),
1189        "workspace-write" | "workspace" => Some(SandboxPolicy::WorkspaceWrite),
1190        "danger-full-access" | "full" => Some(SandboxPolicy::DangerFullAccess),
1191        _ => None,
1192    }
1193}
1194
1195/// Parse an `approval` string, same alias treatment as [`parse_sandbox_str`].
1196/// P5-1: `"model_requested"` is now a REAL, recognized fourth
1197/// [`ApprovalPolicy`] variant (design §3.2 S8, built this unit) — cx-parity
1198/// resolves to its intended posture instead of the pre-P5-1 fail-safe to
1199/// [`ApprovalPolicy::Untrusted`]. Any OTHER unrecognized string still fails
1200/// safe to `Untrusted`, never silently to `Never` (the existing
1201/// `apply_profile` precedent, config.rs). The §2.2 C6 check ALSO reads the
1202/// RAW string directly (not through this parser) for its own
1203/// `"model_requested"` judgment-call diagnostic — see `validate_modules`;
1204/// that check is unaffected by this change (it never depended on this
1205/// parser returning `None`).
1206fn parse_approval_str(s: &str) -> Option<ApprovalPolicy> {
1207    match s.replace('_', "-").to_ascii_lowercase().as_str() {
1208        "never" => Some(ApprovalPolicy::Never),
1209        "on-request" | "onrequest" => Some(ApprovalPolicy::OnRequest),
1210        "untrusted" => Some(ApprovalPolicy::Untrusted),
1211        "model-requested" | "modelrequested" => Some(ApprovalPolicy::ModelRequested),
1212        _ => None,
1213    }
1214}
1215
1216/// §3.3: the loosest possible sandbox value — the one a project file may
1217/// never set (tightening to anything else is legal).
1218fn is_loosening_sandbox_str(s: &str) -> bool {
1219    parse_sandbox_str(s) == Some(SandboxPolicy::DangerFullAccess)
1220}
1221
1222/// §3.3: the loosest possible approval value.
1223fn is_loosening_approval_str(s: &str) -> bool {
1224    parse_approval_str(s) == Some(ApprovalPolicy::Never)
1225}
1226
1227/// Strictness rank — LOWER is stricter (§3.3's explicit order, same as
1228/// `userconfig.rs::sandbox_rank`).
1229fn sandbox_rank(p: SandboxPolicy) -> u8 {
1230    match p {
1231        SandboxPolicy::ReadOnly => 0,
1232        SandboxPolicy::WorkspaceWrite => 1,
1233        SandboxPolicy::DangerFullAccess => 2,
1234    }
1235}
1236
1237/// Strictness rank — LOWER is stricter (§3.3's explicit order, same as
1238/// `userconfig.rs::approval_rank`, which is intentionally NOT updated for
1239/// `ModelRequested` — see `parse_approval_str`'s doc comment on the CLI
1240/// crate being out of this unit's scope; the CLI's own copy simply never
1241/// parses the string, so it never reaches this rank at all).
1242///
1243/// P5-1: `ModelRequested` sits BETWEEN `OnRequest` and `Never` — it is not
1244/// the absolute floor `Never` is (under `Never` literally nothing is ever
1245/// asked; under `ModelRequested` an escalation attempt still can be, per
1246/// `ApprovalPolicy::ModelRequested`'s doc comment on Codex's real posture),
1247/// but it prompts less often in practice than `OnRequest`'s client-side
1248/// allowlist check. This keeps `Never` the one value §3.3's monotonic clamp
1249/// (`is_loosening_approval_str`) singles out as the absolute forbidden
1250/// floor.
1251fn approval_rank(p: ApprovalPolicy) -> u8 {
1252    match p {
1253        ApprovalPolicy::Untrusted => 0,
1254        ApprovalPolicy::OnRequest => 1,
1255        ApprovalPolicy::ModelRequested => 2,
1256        ApprovalPolicy::Never => 3,
1257    }
1258}
1259
1260/// Read `capabilities.permissions`'s effective sandbox setting — either the
1261/// bare-string shorthand (`capabilities.permissions.sandbox = "…"`) or the
1262/// table form's `tier` (`capabilities.permissions.sandbox.tier = "…"`, §3.1
1263/// module 12). Returns the RAW string (not yet parsed), for sanitization and
1264/// C6 diagnostics.
1265fn permissions_sandbox_raw(hc: &HarnessConfig) -> Option<String> {
1266    let cap = hc.capabilities.get("permissions")?;
1267    match cap.settings.get("sandbox")? {
1268        serde_json::Value::String(s) => Some(s.clone()),
1269        serde_json::Value::Object(o) => o.get("tier").and_then(|v| v.as_str()).map(String::from),
1270        _ => None,
1271    }
1272}
1273
1274/// Read `capabilities.permissions.approval`'s raw string value.
1275fn permissions_approval_raw(hc: &HarnessConfig) -> Option<String> {
1276    hc.capabilities
1277        .get("permissions")
1278        .and_then(|cap| cap.settings.get("approval"))
1279        .and_then(|v| v.as_str())
1280        .map(String::from)
1281}
1282
1283/// The effective [`SandboxPolicy`] `capabilities.permissions` resolves to —
1284/// [`SandboxPolicy::DangerFullAccess`] (the [`Config::default`] floor,
1285/// config.rs) when unset or unparseable, matching `apply_profile`'s
1286/// fail-safe-to-`ReadOnly` precedent is intentionally NOT reused here: C3
1287/// (§2.2) needs the ACTUAL default posture (today's `danger_full_access`,
1288/// tools/mod.rs:40-42), not a hypothetical safe fallback, to detect the real
1289/// exposure a bare `supercode` invocation has.
1290fn effective_sandbox(hc: &HarnessConfig) -> SandboxPolicy {
1291    permissions_sandbox_raw(hc)
1292        .as_deref()
1293        .and_then(parse_sandbox_str)
1294        .unwrap_or(SandboxPolicy::DangerFullAccess)
1295}
1296
1297/// The effective [`ApprovalPolicy`] `capabilities.permissions` resolves to —
1298/// [`ApprovalPolicy::Never`] (the [`Config::default`] floor) when unset,
1299/// same rationale as [`effective_sandbox`]. P5-1: cx-parity's
1300/// `"model_requested"` is now a recognized value (resolves to
1301/// [`ApprovalPolicy::ModelRequested`]); any OTHER unparseable-but-present
1302/// value still fails safe to [`ApprovalPolicy::Untrusted`], matching
1303/// `apply_profile`'s precedent.
1304fn effective_approval(hc: &HarnessConfig) -> ApprovalPolicy {
1305    match permissions_approval_raw(hc) {
1306        None => ApprovalPolicy::Never,
1307        Some(raw) => parse_approval_str(&raw).unwrap_or(ApprovalPolicy::Untrusted),
1308    }
1309}
1310
1311/// Capability tables a project file may never set AT ALL (§3.3): arbitrary
1312/// command execution, config-borne code execution, or a listener.
1313///
1314/// P5-12 (§2 module 14 `trust`, D-10): `trust` joined this list alongside
1315/// its own dependents (`hooks`/`plugins`) — a project asserting its OWN
1316/// trust level (e.g. `[capabilities.trust] default = "always"`) would
1317/// self-declare the exact gate D-10 exists to keep out of an untrusted
1318/// repo's hands, defeating the entire point. Only the user/global layer (or
1319/// a preset extended from it) may ever decide this.
1320const PROJECT_FORBIDDEN_CAPABILITY_TABLES: &[&str] =
1321    &["hooks", "plugins", "server", "integrations", "trust"];
1322
1323/// Capability names a project file may flip `enabled = true` on by default
1324/// (§3.3 S9 "Default disposition"): narrows-only, never spends or widens.
1325const PROJECT_ALLOWED_CAPABILITY_ENABLE: &[&str] = &["reduction"];
1326
1327/// LOW-1 (Fable-5 P4a review): is `d` a `core.additional_dirs` entry a
1328/// PROJECT layer is allowed to add? Rejects anything that could resolve
1329/// outside the repo root: absolute paths, `~`-relative paths, any path with
1330/// a `..` component, and any `${VAR}` env-expansion (unbounded — the
1331/// variable could hold anything, including an absolute path elsewhere on
1332/// disk). A relative path with no `..` segments always stays under the
1333/// directory it's resolved against, so it's safe to add.
1334fn is_safe_project_dir(d: &str) -> bool {
1335    if d.contains("${") {
1336        return false;
1337    }
1338    if d.starts_with('~') {
1339        return false;
1340    }
1341    let path = std::path::Path::new(d);
1342    if path.is_absolute() {
1343        return false;
1344    }
1345    !path
1346        .components()
1347        .any(|c| matches!(c, std::path::Component::ParentDir))
1348}
1349
1350/// §3.3's monotonic-tightening rule for a project-layer `HarnessConfig`:
1351/// strip/narrow everything an untrusted repo must not control, recording
1352/// what it touched. Mirrors `userconfig.rs::sanitized_for_project`'s
1353/// contract on the new unified schema (see the module note above).
1354pub fn sanitize_for_project(hc: &HarnessConfig) -> (HarnessConfig, Vec<String>) {
1355    let mut dropped = Vec::new();
1356    let mut out = hc.clone();
1357
1358    if out.core.base_url.take().is_some() {
1359        dropped.push("core.base_url".to_string());
1360    }
1361    if out.core.api_key_env.take().is_some() {
1362        dropped.push("core.api_key_env".to_string());
1363    }
1364    if out.core.api_key_cmd.take().is_some() {
1365        dropped.push("core.api_key_cmd".to_string());
1366    }
1367    if out.core.extra_headers.take().is_some() {
1368        dropped.push("core.extra_headers".to_string());
1369    }
1370    if out.core.extra_body.take().is_some() {
1371        dropped.push("core.extra_body".to_string());
1372    }
1373    if out.core.system_prompt.take().is_some() {
1374        dropped.push("core.system_prompt".to_string());
1375    }
1376    if out.core.append_system_prompt.take().is_some() {
1377        dropped.push("core.append_system_prompt".to_string());
1378    }
1379    // P4b: `focus_instructions` is free text injected into conversation
1380    // history as a system-authored marker every time compaction fires,
1381    // visible to and steering the model — the exact same prompt-injection
1382    // risk class as `system_prompt`/`append_system_prompt` above (§3.3), so
1383    // it gets the same treatment even though the REST of `[core.compaction]`
1384    // (enabled/after_messages/reserve_tokens/keep_recent_tokens/summarize)
1385    // is narrowing-only and stays project-legal.
1386    if out.core.compaction.focus_instructions.take().is_some() {
1387        dropped.push("core.compaction.focus_instructions".to_string());
1388    }
1389    // MEDIUM (independent Fable-5 review of P4d): `core.prompts` is merged
1390    // onto the built-in/user prompt table KEY-WISE by
1391    // `ConfigBuilder::apply_profile` (see `CoreConfig::prompts`'s doc
1392    // comment above), not appended — so unlike `additional_dirs` below,
1393    // there is no "safe, narrowing" entry to keep. A project layer setting
1394    // `[core.prompts]\ncode-review = "malicious {args}"` doesn't just ADD a
1395    // new `/name` prompt, it OVERWRITES a trusted built-in (or user-set)
1396    // prompt template outright, silently substituting attacker text into
1397    // the user's own `/code-review` invocation. Same prompt-injection trust
1398    // boundary as `system_prompt`/`append_system_prompt`/
1399    // `compaction.focus_instructions` above (§3.3) — strip the WHOLE table,
1400    // project-forbidden, fail-closed. Only the user/global layer may set
1401    // prompt templates.
1402    if !out.core.prompts.is_empty() {
1403        out.core.prompts.clear();
1404        dropped.push("core.prompts".to_string());
1405    }
1406
1407    // LOW (security, independent Fable-5 review of P4e): `[core.session]`'s
1408    // OPERATIONAL fields steer WHERE/WHAT/HOW the trusted session store
1409    // behaves, not just this conversation's content — a different trust
1410    // class than a narrowing-only knob. `dir` redirects every session-
1411    // transcript WRITE `run`/`chat` performs to an arbitrary path (repo sets
1412    // `dir = "/tmp/evil"` or anywhere the process can write — exfil, or an
1413    // overwrite of another session's files); `retention_days` steers what
1414    // `sessions prune` PERMANENTLY DELETES (a repo could set it to `1` to
1415    // quietly shred the user's session history, or the reviewer's own
1416    // "retention_days=0 project-forbidden" scenario to try to disable
1417    // pruning entirely — either way, deletion policy is not a repo's call).
1418    // `name`/`persist`/`export_format`/`git_metadata` ride along in the same
1419    // strip: none of them narrow anything either (a repo picking the
1420    // session's name, whether it's written to disk at all, its export
1421    // shape, or whether git provenance is captured is all still "the repo
1422    // steering the trusted store", not "the repo asking for less"). Only
1423    // `auto_title` is left alone: it can only change a title STRING
1424    // attached to a session that already lives under the user's own store
1425    // at a path/name the user (or the user/global layer) controls — no
1426    // path redirection, no deletion, no capability widening — so it stays
1427    // on the Project-ALLOWED side of the monotonic-tightening line. Same
1428    // one-shot-warning pattern (`dropped`) as every other stripped key
1429    // above; user/global layers keep full control of all of `core.session`.
1430    if out.core.session.dir.take().is_some() {
1431        dropped.push("core.session.dir".to_string());
1432    }
1433    if out.core.session.name.take().is_some() {
1434        dropped.push("core.session.name".to_string());
1435    }
1436    if out.core.session.persist.take().is_some() {
1437        dropped.push("core.session.persist".to_string());
1438    }
1439    if out.core.session.retention_days.take().is_some() {
1440        dropped.push("core.session.retention_days".to_string());
1441    }
1442    if out.core.session.export_format.take().is_some() {
1443        dropped.push("core.session.export_format".to_string());
1444    }
1445    if out.core.session.git_metadata.take().is_some() {
1446        dropped.push("core.session.git_metadata".to_string());
1447    }
1448
1449    // LOW-1 (Fable-5 P4a review): `core.additional_dirs` is `${VAR}`-expanded
1450    // unconditionally at `to_config_profile` time with no upper bound on
1451    // where the expansion can point — the doc comment on the field itself
1452    // (`additional_dirs: Option<Vec<String>>` above) says "not enforced
1453    // here… enforced [downstream]", but nothing downstream actually enforced
1454    // it either, so a project layer could set `additional_dirs =
1455    // ["${HOME}/.ssh"]` (or a bare `/etc`, or `../../etc`) and escape the
1456    // repo root entirely. §3.3: a project file "may only ADD under the repo
1457    // root" — since this resolver works over raw TOML text with no
1458    // filesystem root of its own to check against, that's enforced
1459    // structurally: reject any entry that's absolute, starts with `~`,
1460    // contains a `..` component, or contains `${` (any env-expansion is
1461    // unbounded, so it's treated the same as "escaping outside root"). The
1462    // user/global layer is unrestricted (same trust boundary as `sandbox`/
1463    // `approval`: only the untrusted project layer is clamped).
1464    if let Some(dirs) = &out.core.additional_dirs {
1465        let (kept, rejected): (Vec<String>, Vec<String>) =
1466            dirs.iter().cloned().partition(|d| is_safe_project_dir(d));
1467        if !rejected.is_empty() {
1468            dropped.push(format!("core.additional_dirs ({})", rejected.join(", ")));
1469            out.core.additional_dirs = if kept.is_empty() { None } else { Some(kept) };
1470        }
1471    }
1472
1473    // `extends`: a built-in NAME stays legal; anything else is treated as a
1474    // path — "a repo-supplied preset file is config injection through the
1475    // back door" (§3.3). A whitelist membership check against the six
1476    // reserved names (rather than the CLI's path-shaped-string heuristic)
1477    // means nothing can slip through as "not a path" that isn't actually a
1478    // known preset.
1479    if let Some(e) = &out.extends {
1480        if crate::presets::lookup(e).is_none() {
1481            dropped.push("extends (path)".to_string());
1482            out.extends = None;
1483        }
1484    }
1485
1486    // LOW-1 (independent Fable-5 review of P3): `[experimental]` is a
1487    // mode-switching table (§5.3 risk 2's `module_registry` gate, and any
1488    // future flag added under it), not a plain settings table — today it
1489    // happens to be narrowing-only (`module_registry` off is always safe),
1490    // but §3.3's monotonic-tightening principle wants project configs
1491    // categorically unable to toggle experimental/mode-switching behavior,
1492    // since a LATER flag added under this table might not be
1493    // narrowing-only. Strip the WHOLE table (not a per-key allow/deny like
1494    // `capabilities.permissions` above) — same fail-closed posture as
1495    // `hooks`/`plugins`/`server`/`integrations`: experimental gates are
1496    // user/global-layer only.
1497    if !out.experimental.is_empty() {
1498        out.experimental.clear();
1499        dropped.push("experimental".to_string());
1500    }
1501
1502    for name in PROJECT_FORBIDDEN_CAPABILITY_TABLES {
1503        if out.capabilities.remove(*name).is_some() {
1504            dropped.push(format!("capabilities.{name}"));
1505        }
1506    }
1507
1508    if let Some(cap) = out.capabilities.get_mut("mcp") {
1509        if cap.settings.remove("servers").is_some() {
1510            dropped.push("capabilities.mcp.servers".to_string());
1511        }
1512        if matches!(
1513            cap.settings.get("serve"),
1514            Some(serde_json::Value::Bool(true))
1515        ) {
1516            cap.settings.remove("serve");
1517            dropped.push("capabilities.mcp.serve".to_string());
1518        }
1519    }
1520
1521    if let Some(cap) = out.capabilities.get_mut("notify") {
1522        if cap.settings.remove("email").is_some() {
1523            dropped.push("capabilities.notify.email".to_string());
1524        }
1525    }
1526
1527    // P5-11 (§2 module 28 `lsp`, D-10): `capabilities.lsp.servers.*` is
1528    // config-borne code execution (a `command`/`args` pair a project file
1529    // could point at anything on `PATH`) — the exact same injection class
1530    // as `capabilities.mcp.servers` just above, so it gets the identical
1531    // strip-the-whole-table treatment regardless of `enabled` (the generic
1532    // default-disposition loop below already blocks a project file from
1533    // flipping `enabled = true` at all, since `lsp` isn't on the
1534    // Project-ALLOWED list — this additionally blocks server DEFINITIONS
1535    // from ever reaching a base layer that already has `enabled = true`,
1536    // e.g. from `oc-parity`).
1537    if let Some(cap) = out.capabilities.get_mut("lsp") {
1538        if cap.settings.remove("servers").is_some() {
1539            dropped.push("capabilities.lsp.servers".to_string());
1540        }
1541    }
1542
1543    // P5-11 (§2 module 29 `formatters`, D-10, C10 sibling): every key
1544    // under `capabilities.formatters` OTHER than the two recognized
1545    // scalars (`diff_back`/`timeout_secs`) is a formatter DEFINITION —
1546    // `command`/`args`, the same D-10 injection class as `lsp.servers`
1547    // above. Unlike `lsp`, formatter definitions are SIBLINGS of `enabled`
1548    // (design's own schema shape), not nested under one sub-key, so each
1549    // one is checked and stripped individually. `timeout_secs` is
1550    // narrowing-safe either direction is left alone. `diff_back = false`
1551    // is the C10-UNSAFE direction (silences the annotation that lets the
1552    // model notice a formatter rewrote its file) — same "never let a
1553    // project assert the unsafe value" posture as
1554    // `capabilities.permissions.sandbox.enabled = false` above; `true` (or
1555    // simply omitted) passes through untouched.
1556    if let Some(cap) = out.capabilities.get_mut("formatters") {
1557        let formatter_keys: Vec<String> = cap
1558            .settings
1559            .keys()
1560            .filter(|k| !matches!(k.as_str(), "diff_back" | "timeout_secs"))
1561            .cloned()
1562            .collect();
1563        for key in formatter_keys {
1564            cap.settings.remove(&key);
1565            dropped.push(format!("capabilities.formatters.{key}"));
1566        }
1567        if matches!(
1568            cap.settings.get("diff_back"),
1569            Some(serde_json::Value::Bool(false))
1570        ) {
1571            cap.settings.remove("diff_back");
1572            dropped.push("capabilities.formatters.diff_back".to_string());
1573        }
1574    }
1575
1576    if let Some(cap) = out.capabilities.get_mut("permissions") {
1577        match cap.settings.get("sandbox").cloned() {
1578            Some(serde_json::Value::String(sb)) if is_loosening_sandbox_str(&sb) => {
1579                cap.settings.remove("sandbox");
1580                dropped.push("capabilities.permissions.sandbox".to_string());
1581            }
1582            Some(serde_json::Value::Object(_)) => {
1583                if let Some(tbl) = cap
1584                    .settings
1585                    .get_mut("sandbox")
1586                    .and_then(|v| v.as_object_mut())
1587                {
1588                    if let Some(tier) = tbl.get("tier").and_then(|v| v.as_str()).map(String::from) {
1589                        if is_loosening_sandbox_str(&tier) {
1590                            tbl.remove("tier");
1591                            dropped.push("capabilities.permissions.sandbox.tier".to_string());
1592                        }
1593                    }
1594                    // P5-10: `enabled` (OS-level enforcement engaged) only
1595                    // ever TIGHTENS by turning enforcement ON — an explicit
1596                    // project `enabled = false` is the one loosening
1597                    // direction (it can defeat a base layer's `enabled =
1598                    // true`) and is unconditionally dropped, REGARDLESS of
1599                    // the base layer's own value (no base comparison
1600                    // needed: "never let a project assert false" is
1601                    // correct whether the base is `true`, `false`, or
1602                    // unset). `enabled = true` passes through untouched.
1603                    if matches!(tbl.get("enabled"), Some(serde_json::Value::Bool(false))) {
1604                        tbl.remove("enabled");
1605                        dropped.push("capabilities.permissions.sandbox.enabled".to_string());
1606                    }
1607                    // P5-10: `escalation`/`env_policy` graduate from an
1608                    // unconditional strip to the SAME two-stage treatment
1609                    // `tier`/`approval` already get — catch the single
1610                    // absolute-loosest value here (fails safe even if the
1611                    // downstream relative clamp were ever skipped), leave
1612                    // anything else for `clamp_project_permissions`'
1613                    // proper rank-vs-base-layer comparison (a project CAN
1614                    // legitimately tighten these now that they carry real
1615                    // behavior — P5-1's own `sandbox`/`approval` precedent
1616                    // for "let a narrowing project value through").
1617                    if let Some(esc) = tbl.get("escalation").and_then(|v| v.as_str()) {
1618                        if crate::sandbox::SandboxEscalation::parse(esc)
1619                            == Some(crate::sandbox::SandboxEscalation::Allow)
1620                        {
1621                            tbl.remove("escalation");
1622                            dropped.push("capabilities.permissions.sandbox.escalation".to_string());
1623                        }
1624                    }
1625                    if let Some(ep) = tbl.get("env_policy").and_then(|v| v.as_str()) {
1626                        if crate::sandbox::SandboxEnvPolicy::parse(ep)
1627                            == Some(crate::sandbox::SandboxEnvPolicy::Inherit)
1628                        {
1629                            tbl.remove("env_policy");
1630                            dropped.push("capabilities.permissions.sandbox.env_policy".to_string());
1631                        }
1632                    }
1633                    // P5-10: `network.allow_domains`/`.deny_domains` have
1634                    // no established strictness ORDER this resolver can
1635                    // safely clamp against yet: `allow_domains` growing can
1636                    // WIDEN reachability (from a base's empty/unrestricted
1637                    // list), and a project-supplied `deny_domains` REPLACING
1638                    // (not unioning with) the trusted layer's own list risks
1639                    // silently dropping an entry the trusted layer relied
1640                    // on if a future merge step ever folds it in naively —
1641                    // same "no safe-to-trust ordering yet" rationale as
1642                    // `auto_approved_tools`/`rules.allow` below. Both are
1643                    // stripped from a project layer outright (fail-closed);
1644                    // only the coarse `network.enabled` boolean gets the
1645                    // never-assert-false treatment (same as the table's own
1646                    // `enabled` above), since ANY narrower per-domain intent
1647                    // needs the platform primitive this build brief already
1648                    // names as out of reach on this kernel class anyway.
1649                    if let Some(net) = tbl.get_mut("network").and_then(|v| v.as_object_mut()) {
1650                        for k in ["allow_domains", "deny_domains"] {
1651                            if net.remove(k).is_some() {
1652                                dropped
1653                                    .push(format!("capabilities.permissions.sandbox.network.{k}"));
1654                            }
1655                        }
1656                        if matches!(net.get("enabled"), Some(serde_json::Value::Bool(false))) {
1657                            net.remove("enabled");
1658                            dropped.push(
1659                                "capabilities.permissions.sandbox.network.enabled".to_string(),
1660                            );
1661                        }
1662                    }
1663                }
1664            }
1665            _ => {}
1666        }
1667        if let Some(ap) = cap.settings.get("approval").and_then(|v| v.as_str()) {
1668            if is_loosening_approval_str(ap) {
1669                cap.settings.remove("approval");
1670                dropped.push("capabilities.permissions.approval".to_string());
1671            }
1672        }
1673        if cap.settings.remove("auto_approved_tools").is_some() {
1674            dropped.push("capabilities.permissions.auto_approved_tools".to_string());
1675        }
1676        if let Some(rules) = cap
1677            .settings
1678            .get_mut("rules")
1679            .and_then(|v| v.as_object_mut())
1680        {
1681            if rules.remove("allow").is_some() {
1682                dropped.push("capabilities.permissions.rules.allow".to_string());
1683            }
1684        }
1685    }
1686
1687    // Default disposition (S9): opting IN to any module not on the
1688    // allowlist is forbidden by default; disabling (narrowing) is always
1689    // left alone. This also covers `tools_web`/`tools_background`/
1690    // `telemetry`/`session_share`'s `enabled = true` (§3.3's named exfil/
1691    // detached-execution rows) without a redundant per-name list.
1692    for (name, cap) in out.capabilities.iter_mut() {
1693        if cap.enabled == Some(true) && !PROJECT_ALLOWED_CAPABILITY_ENABLE.contains(&name.as_str())
1694        {
1695            cap.enabled = None;
1696            dropped.push(format!("capabilities.{name}.enabled"));
1697        }
1698    }
1699
1700    (out, dropped)
1701}
1702
1703/// §3.3's monotonic clamp, applied specifically at the project-layer merge
1704/// (not the general [`HarnessConfig::overlay`], which is also used for the
1705/// preset chain and the user layer — a user's OWN config extending a preset
1706/// and then setting a looser value is fine; only the UNTRUSTED project layer
1707/// is clamped). Mirrors `userconfig.rs`'s `clamp_sandbox`/`clamp_approval`
1708/// (F2/F3 fix precedent): even a project value that survived
1709/// [`sanitize_for_project`] (because it isn't the single GLOBAL loosest
1710/// value) must still be no looser than the base layer's OWN effective
1711/// posture — e.g. a project setting `workspace_write` when the base layer
1712/// has `read_only` is a real widening and must be clamped back.
1713///
1714/// `pub` (P5-10): also called directly by `crates/cli/src/userconfig.rs`'s
1715/// `overlay_project` (via a throwaway `HarnessConfig` wrapping just the
1716/// `capabilities` map, the same `core_probe` trick
1717/// `sanitized_for_project`'s own doc comment already uses for `[core]`) so
1718/// the CLI's plain `.supercode.toml` route gets the SAME `escalation`/
1719/// `env_policy` relative-rank clamp as the SDK's `HarnessConfig` resolver,
1720/// rather than a second, potentially-drifting reimplementation.
1721pub fn clamp_project_permissions(
1722    base: &HarnessConfig,
1723    sanitized_project: &HarnessConfig,
1724    merged: &mut HarnessConfig,
1725) -> Vec<String> {
1726    let mut clamped = Vec::new();
1727    let base_sandbox = effective_sandbox(base);
1728    let base_approval = effective_approval(base);
1729
1730    if let Some(raw) = permissions_sandbox_raw(sanitized_project) {
1731        if let Some(parsed) = parse_sandbox_str(&raw) {
1732            if sandbox_rank(parsed) > sandbox_rank(base_sandbox) {
1733                clamped.push("capabilities.permissions.sandbox".to_string());
1734                set_sandbox_tier(merged, permissions_sandbox_raw(base));
1735            }
1736        }
1737    }
1738    if let Some(raw) = permissions_approval_raw(sanitized_project) {
1739        if let Some(parsed) = parse_approval_str(&raw) {
1740            if approval_rank(parsed) > approval_rank(base_approval) {
1741                clamped.push("capabilities.permissions.approval".to_string());
1742                set_permissions_approval_raw(merged, permissions_approval_raw(base));
1743            }
1744        }
1745    }
1746    // P5-10 (§2 module 12): `escalation`/`env_policy` get the exact same
1747    // rank-vs-base-layer clamp as `sandbox`/`approval` above — the TABLE
1748    // form only (the bare tier shorthand can't express either key at all,
1749    // so `sanitized_project`/`base` both read `None` for a bare-form
1750    // config and this is a no-op, same as `permissions_sandbox_raw`'s own
1751    // bare-vs-table handling elsewhere in this file). The "unset" floor for
1752    // each mirrors [`crate::sandbox::SandboxEscalation`]/[`crate::sandbox::
1753    // SandboxEnvPolicy`]'s own `Default` (`Deny`/`Inherit` respectively) —
1754    // the SAME values `permissions_sandbox_escalation`/
1755    // `permissions_sandbox_env_policy` already fall back to, so this clamp
1756    // agrees with what `materialize_config` will actually resolve.
1757    let base_escalation = permissions_sandbox_escalation_raw(base)
1758        .as_deref()
1759        .and_then(crate::sandbox::SandboxEscalation::parse)
1760        .unwrap_or_default();
1761    if let Some(raw) = permissions_sandbox_escalation_raw(sanitized_project) {
1762        if let Some(parsed) = crate::sandbox::SandboxEscalation::parse(&raw) {
1763            if parsed.rank() > base_escalation.rank() {
1764                clamped.push("capabilities.permissions.sandbox.escalation".to_string());
1765                set_permissions_sandbox_escalation_raw(
1766                    merged,
1767                    permissions_sandbox_escalation_raw(base),
1768                );
1769            }
1770        }
1771    }
1772    let base_env_policy = permissions_sandbox_env_policy_raw(base)
1773        .as_deref()
1774        .and_then(crate::sandbox::SandboxEnvPolicy::parse)
1775        .unwrap_or_default();
1776    if let Some(raw) = permissions_sandbox_env_policy_raw(sanitized_project) {
1777        if let Some(parsed) = crate::sandbox::SandboxEnvPolicy::parse(&raw) {
1778            if parsed.rank() > base_env_policy.rank() {
1779                clamped.push("capabilities.permissions.sandbox.env_policy".to_string());
1780                set_permissions_sandbox_env_policy_raw(
1781                    merged,
1782                    permissions_sandbox_env_policy_raw(base),
1783                );
1784            }
1785        }
1786    }
1787    // CRITICAL backstop (P5-10 security reopen, belt-and-suspenders on top
1788    // of `merge_sandbox_value`'s merge-representation fix): the invariant
1789    // that actually matters is the RESOLVED/EFFECTIVE sandbox tier, not
1790    // whether `sanitized_project` happened to carry a raw `tier` string —
1791    // the presence-based check above is a no-op whenever the project's
1792    // table omitted `tier` entirely (a hostile tier already stripped by
1793    // `sanitize_for_project`, or a benign tier-less overlay), which is
1794    // exactly the shape that let a widening slip through before. Check the
1795    // MERGED config's actual effective tier directly and clamp it back
1796    // whenever it's looser than the base's, regardless of which code path
1797    // produced it — this makes the monotonic-tightening invariant
1798    // form-agnostic and independent of any single merge/sanitize call site.
1799    let merged_sandbox = effective_sandbox(merged);
1800    if sandbox_rank(merged_sandbox) > sandbox_rank(base_sandbox)
1801        && !clamped
1802            .iter()
1803            .any(|c| c == "capabilities.permissions.sandbox")
1804    {
1805        clamped.push("capabilities.permissions.sandbox".to_string());
1806        set_sandbox_tier(merged, permissions_sandbox_raw(base));
1807    }
1808    clamped
1809}
1810
1811/// Read `capabilities.permissions.sandbox.escalation`'s raw string — TABLE
1812/// form only (§3.1: the bare `sandbox = "<tier>"` shorthand can't express
1813/// this key). See [`clamp_project_permissions`]'s doc comment.
1814fn permissions_sandbox_escalation_raw(hc: &HarnessConfig) -> Option<String> {
1815    hc.capabilities
1816        .get("permissions")?
1817        .settings
1818        .get("sandbox")?
1819        .as_object()?
1820        .get("escalation")?
1821        .as_str()
1822        .map(String::from)
1823}
1824
1825/// Read `capabilities.permissions.sandbox.env_policy`'s raw string — same
1826/// TABLE-form-only treatment as [`permissions_sandbox_escalation_raw`].
1827fn permissions_sandbox_env_policy_raw(hc: &HarnessConfig) -> Option<String> {
1828    hc.capabilities
1829        .get("permissions")?
1830        .settings
1831        .get("sandbox")?
1832        .as_object()?
1833        .get("env_policy")?
1834        .as_str()
1835        .map(String::from)
1836}
1837
1838/// Overwrite (or clear) `capabilities.permissions.sandbox.escalation` —
1839/// used to revert a clamped project override back to the base layer's own
1840/// setting, same rationale as [`set_sandbox_tier`]. Only
1841/// touches the TABLE form (creating one if the entry didn't already exist
1842/// as an object — a clamp only ever fires when the PROJECT supplied the
1843/// table form in the first place, since the bare shorthand has no
1844/// `escalation` key to clamp).
1845fn set_permissions_sandbox_escalation_raw(hc: &mut HarnessConfig, value: Option<String>) {
1846    set_permissions_sandbox_subkey_raw(hc, "escalation", value);
1847}
1848
1849/// Same as [`set_permissions_sandbox_escalation_raw`] for `env_policy`.
1850fn set_permissions_sandbox_env_policy_raw(hc: &mut HarnessConfig, value: Option<String>) {
1851    set_permissions_sandbox_subkey_raw(hc, "env_policy", value);
1852}
1853
1854/// Shared body for [`set_permissions_sandbox_escalation_raw`]/
1855/// [`set_permissions_sandbox_env_policy_raw`].
1856fn set_permissions_sandbox_subkey_raw(hc: &mut HarnessConfig, key: &str, value: Option<String>) {
1857    let cap = hc
1858        .capabilities
1859        .entry("permissions".to_string())
1860        .or_default();
1861    let entry = cap
1862        .settings
1863        .entry("sandbox".to_string())
1864        .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
1865    if !entry.is_object() {
1866        // The project supplied the bare-string shorthand (no object to set
1867        // a sub-key on) — nothing to clamp back onto since it couldn't
1868        // have carried this key in the first place; leave it untouched.
1869        return;
1870    }
1871    let obj = entry.as_object_mut().expect("just checked is_object");
1872    match value {
1873        Some(v) => {
1874            obj.insert(key.to_string(), serde_json::Value::String(v));
1875        }
1876        None => {
1877            obj.remove(key);
1878        }
1879    }
1880}
1881
1882/// Overwrite (or clear) `capabilities.permissions.sandbox`'s TIER — used to
1883/// revert a clamped project override back to the base layer's own setting.
1884///
1885/// P5-10 fix: unlike the old bare-string-only clamp this replaces, `sandbox`
1886/// can now carry legitimate sibling subkeys (`enabled`/`escalation`/
1887/// `env_policy`/`network`) alongside `tier` that the project may have
1888/// validly tightened in the SAME merge — wholesale-replacing the whole
1889/// value with a bare string would silently discard those. When the merged
1890/// value is already table form, only the `tier` subkey is overwritten,
1891/// preserving every other subkey; only when it's the bare-string shorthand
1892/// (or absent) does this fall back to setting/clearing the bare string, same
1893/// as before (there's no table to preserve subkeys on).
1894fn set_sandbox_tier(hc: &mut HarnessConfig, value: Option<String>) {
1895    let cap = hc
1896        .capabilities
1897        .entry("permissions".to_string())
1898        .or_default();
1899    if let Some(serde_json::Value::Object(obj)) = cap.settings.get_mut("sandbox") {
1900        match value {
1901            Some(v) => {
1902                obj.insert("tier".to_string(), serde_json::Value::String(v));
1903            }
1904            None => {
1905                obj.remove("tier");
1906            }
1907        }
1908        return;
1909    }
1910    match value {
1911        Some(v) => {
1912            cap.settings
1913                .insert("sandbox".to_string(), serde_json::Value::String(v));
1914        }
1915        None => {
1916            cap.settings.remove("sandbox");
1917        }
1918    }
1919}
1920
1921/// Same as [`set_sandbox_tier`] for `approval` (bare-string-only field, no
1922/// table form exists to preserve subkeys on).
1923fn set_permissions_approval_raw(hc: &mut HarnessConfig, value: Option<String>) {
1924    let cap = hc
1925        .capabilities
1926        .entry("permissions".to_string())
1927        .or_default();
1928    match value {
1929        Some(v) => {
1930            cap.settings
1931                .insert("approval".to_string(), serde_json::Value::String(v));
1932        }
1933        None => {
1934            cap.settings.remove("approval");
1935        }
1936    }
1937}
1938
1939// ---------------------------------------------------------------------------
1940// §3.5 step 6: module resolution — §2.1's dependency graph and §2.2's
1941// conflict matrix encoded AS DATA the resolver consumes, per the design's
1942// explicit instruction ("Deps `→`... Conflicts `⚡`..."), rather than
1943// hardcoded if-chains scattered through the crate.
1944// ---------------------------------------------------------------------------
1945
1946/// The 31 top-level `[capabilities.<name>]` table names (§2's 35 modules,
1947/// minus the 4 that nest as sub-tables of a family: `permissions.rules`,
1948/// `permissions.sandbox`, `permissions.protected_paths` nest under
1949/// `permissions`; `mcp.server` is the `capabilities.mcp.serve` bool, not a
1950/// separate top-level table).
1951pub const MODULE_NAMES: &[&str] = &[
1952    "tools_search",
1953    "tools_apply_patch",
1954    "tools_persistent_shell",
1955    "tools_background",
1956    "tools_web",
1957    "tools_question",
1958    "todos",
1959    "plan_mode",
1960    "subagents",
1961    "permissions",
1962    "trust",
1963    "mcp",
1964    "hooks",
1965    "plugins",
1966    "memory",
1967    "checkpoint",
1968    "session_tree",
1969    "session_share",
1970    "reduction",
1971    "deferred_tools",
1972    "cache",
1973    "model_catalog",
1974    "model_oauth",
1975    "lsp",
1976    "formatters",
1977    "tui",
1978    "server",
1979    "notify",
1980    "structured_output",
1981    "telemetry",
1982    "integrations",
1983];
1984
1985/// The 3 nested sub-modules under `capabilities.permissions` (module family
1986/// 10-13, §2 table) — dotted paths [`module_enabled`] understands.
1987pub const NESTED_MODULE_NAMES: &[&str] = &[
1988    "permissions.rules",
1989    "permissions.sandbox",
1990    "permissions.protected_paths",
1991];
1992
1993/// Whether a module (a top-level name, or a dotted `top.sub` path for the
1994/// [`NESTED_MODULE_NAMES`]) is enabled in a resolved `HarnessConfig`.
1995pub fn module_enabled(hc: &HarnessConfig, module: &str) -> bool {
1996    let mut parts = module.splitn(2, '.');
1997    let top = parts.next().unwrap_or("");
1998    let Some(cap) = hc.capabilities.get(top) else {
1999        return false;
2000    };
2001    match parts.next() {
2002        None => cap.enabled.unwrap_or(false),
2003        Some(sub) => cap
2004            .settings
2005            .get(sub)
2006            .and_then(|v| v.as_object())
2007            .and_then(|o| o.get("enabled"))
2008            .and_then(|v| v.as_bool())
2009            .unwrap_or(false),
2010    }
2011}
2012
2013/// Read a boolean setting nested under a capability's table (e.g.
2014/// `subagents.background`, `reduction.span_summaries`) — `false` if the
2015/// module or the key is absent. `pub(crate)`: also used by
2016/// [`crate::modules::ModuleId::is_active`] for the module-16
2017/// (`mcp.server` → `capabilities.mcp.serve`) schema-collapse case.
2018pub(crate) fn module_setting_bool(hc: &HarnessConfig, top: &str, key: &str) -> bool {
2019    hc.capabilities
2020        .get(top)
2021        .and_then(|c| c.settings.get(key))
2022        .and_then(|v| v.as_bool())
2023        .unwrap_or(false)
2024}
2025
2026/// Read a string setting nested under a capability's table.
2027fn module_setting_str<'a>(hc: &'a HarnessConfig, top: &str, key: &str) -> Option<&'a str> {
2028    hc.capabilities
2029        .get(top)
2030        .and_then(|c| c.settings.get(key))
2031        .and_then(|v| v.as_str())
2032}
2033
2034/// The effective `[core.tools] enabled` list — the §3.1 default four when
2035/// unset (`core.tools.enabled` has no built-in default of its own; the
2036/// schema's stated default is the §1.2 "default-active four").
2037fn effective_tools_enabled(hc: &HarnessConfig) -> Vec<String> {
2038    hc.core.tools.enabled.clone().unwrap_or_else(|| {
2039        ["read_file", "bash", "edit_file", "write_file"]
2040            .iter()
2041            .map(|s| s.to_string())
2042            .collect()
2043    })
2044}
2045
2046/// Every named module's activation state (§3.5 step 7's "module-activation
2047/// set") — [`MODULE_NAMES`] plus [`NESTED_MODULE_NAMES`], each mapped to
2048/// [`module_enabled`]'s verdict.
2049fn activation_set(hc: &HarnessConfig) -> BTreeMap<String, bool> {
2050    let mut set = BTreeMap::new();
2051    for name in MODULE_NAMES {
2052        set.insert((*name).to_string(), module_enabled(hc, name));
2053    }
2054    for name in NESTED_MODULE_NAMES {
2055        set.insert((*name).to_string(), module_enabled(hc, name));
2056    }
2057    set
2058}
2059
2060/// A resolver diagnostic (§3.5 step 6): a warning is advisory (attached to
2061/// [`Resolved::warnings`]); a hard-dependency or conflict failure is a
2062/// [`ResolveError`].
2063///
2064/// **Scope note (documented, not a gap the golden tests miss):** this
2065/// implements every dependency/conflict edge §2.1/§2.2 name that is
2066/// mechanically checkable from config data alone AND that the design's own
2067/// §4.6 mechanical re-validation table shows firing (or cleanly passing)
2068/// for at least one of the six reserved presets: D-1 (subagents
2069/// background→approvals), D-3 (permissions.rules→approvals), D-4
2070/// (lsp→edit/write), D-7 (skills→read_file|bash, warn-degrade), D-9
2071/// (span_summaries/memory→small_model, fallback-warn), D-10
2072/// (hooks/plugins→trust), plan_mode→rules|sandbox, mcp.server→mcp.client,
2073/// tools_question→tui|server, C1, C3, C4, C6. D-8 is never checked
2074/// (rehydrate is always-on core, §1.13/S1). Two §2.1 edges are deliberately
2075/// NOT enforced as resolver warnings even though prose names them
2076/// (`checkpoint`'s "full-coverage" sandbox qualifier; `mcp.client.elicitation
2077/// →tools.question`, satisfied-by-`tui` in every preset that needs it):
2078/// §4.6's own verdict table treats both as narrative residuals in the
2079/// design DOCUMENT, not as warnings the mechanical resolver itself must
2080/// emit — implementing them as active checks would fire un-named warnings
2081/// on cc-parity/oc-parity that contradict §4.6's stated clean verdicts for
2082/// those two presets. Left for a future pass if the design promotes them to
2083/// resolver-checked rows.
2084///
2085/// D-9's trigger set is deliberately narrowed to `reduction.span_summaries`
2086/// and `memory.enabled` — NOT `core.compaction.summarize`, even though
2087/// §2.1's literal text lists all three. `compaction.summarize = true` is
2088/// the near-universal default across every preset (all six set it, or
2089/// inherit it from `pi-core`), and falling back to the main model for
2090/// compaction summaries is unremarkable — §4.6 never names a D-9 warning
2091/// for ANY of the six presets, including `pi-core`/`cx-parity`/`oc-parity`,
2092/// which all set `compaction.summarize = true` with no `small_model`
2093/// configured. Including `compaction.summarize` in the trigger set would
2094/// therefore produce three un-named warnings contradicting §4.6's clean
2095/// verdicts for those presets; narrowing to the two dependents whose
2096/// fallback the design's own validation table treats as meaningful resolves
2097/// the contradiction.
2098fn validate_modules(
2099    hc: &HarnessConfig,
2100    preset_baseline: Option<&HarnessConfig>,
2101) -> Result<Vec<String>, ResolveError> {
2102    let mut warnings = Vec::new();
2103    let tools_enabled = effective_tools_enabled(hc);
2104    let has = |name: &str| tools_enabled.iter().any(|t| t == name);
2105
2106    // ---- hard dependencies (§2.1) ----
2107
2108    // D-1: subagents background-mode → permissions.approvals.
2109    if module_enabled(hc, "subagents") && module_setting_bool(hc, "subagents", "background") {
2110        require(
2111            module_enabled(hc, "permissions"),
2112            "subagents (background)",
2113            "permissions",
2114        )?;
2115    }
2116    // D-3: permissions.rules → permissions.approvals.
2117    if module_enabled(hc, "permissions.rules") {
2118        require(
2119            module_enabled(hc, "permissions"),
2120            "permissions.rules",
2121            "permissions",
2122        )?;
2123    }
2124    // D-4: lsp → core.tools(edit/write).
2125    if module_enabled(hc, "lsp") {
2126        require(
2127            has("edit_file") && has("write_file"),
2128            "lsp",
2129            "core.tools.enabled (edit_file, write_file)",
2130        )?;
2131    }
2132    // D-10: hooks(project-scope), plugins → trust.
2133    if module_enabled(hc, "hooks") {
2134        require(module_enabled(hc, "trust"), "hooks", "trust")?;
2135    }
2136    if module_enabled(hc, "plugins") {
2137        require(module_enabled(hc, "trust"), "plugins", "trust")?;
2138    }
2139    // plan_mode → permissions.rules | permissions.sandbox.
2140    if module_enabled(hc, "plan_mode") {
2141        require(
2142            module_enabled(hc, "permissions.rules") || module_enabled(hc, "permissions.sandbox"),
2143            "plan_mode",
2144            "permissions.rules or permissions.sandbox",
2145        )?;
2146    }
2147    // mcp.server → mcp.client.
2148    if module_setting_bool(hc, "mcp", "serve") {
2149        require(module_enabled(hc, "mcp"), "mcp (serve)", "mcp")?;
2150    }
2151    // tools.question, permissions.approvals(ask-UI) → tui | server.
2152    if module_enabled(hc, "tools_question") {
2153        require(
2154            module_enabled(hc, "tui") || module_enabled(hc, "server"),
2155            "tools_question",
2156            "tui or server",
2157        )?;
2158    }
2159
2160    // D-7 (S3-amended): core.skills → core.tools.read_file | core.tools.bash.
2161    // No viable read pathway at all is a hard-dep failure; bash-only
2162    // degrades to a warning, not an error.
2163    if hc.core.skills.enabled == Some(true) {
2164        let has_read = has("read_file");
2165        let has_bash = has("bash");
2166        if !has_read && !has_bash {
2167            return Err(ResolveError::MissingDependency {
2168                module: "core.skills".to_string(),
2169                requires: "core.tools.enabled (read_file or bash)".to_string(),
2170            });
2171        }
2172        if !has_read && has_bash {
2173            warnings.push(
2174                "D-7: core.skills is active with only `bash` as the read pathway (no \
2175                 dedicated read_file); progressive disclosure degrades to bash-only reads \
2176                 (§2.1 D-7, resolver warns rather than errors)"
2177                    .to_string(),
2178            );
2179        }
2180    }
2181
2182    // D-9 (fallback → warning): reduction.span_summaries / memory →
2183    // model_catalog.small_model. See the narrowing rationale on this
2184    // function's doc comment.
2185    let span_summaries_on =
2186        module_enabled(hc, "reduction") && module_setting_bool(hc, "reduction", "span_summaries");
2187    let memory_on = module_enabled(hc, "memory");
2188    if span_summaries_on || memory_on {
2189        let small_model = module_setting_str(hc, "model_catalog", "small_model").unwrap_or("");
2190        if small_model.is_empty() {
2191            warnings.push(
2192                "D-9: a small-model-consuming feature (reduction.span_summaries and/or \
2193                 memory) is enabled with no capabilities.model_catalog.small_model set — \
2194                 falls back to the main model (§2.1 D-9)"
2195                    .to_string(),
2196            );
2197        }
2198    }
2199
2200    // ---- conflicts (§2.2) ----
2201
2202    // C1: tools_apply_patch co-advertised with edit_file/write_file without
2203    // per-model bits.
2204    if module_enabled(hc, "tools_apply_patch") {
2205        let co_advertised = has("edit_file") || has("write_file");
2206        let per_model = module_setting_bool(hc, "tools_apply_patch", "per_model");
2207        let model_catalog_on = module_enabled(hc, "model_catalog");
2208        if co_advertised && !(per_model && model_catalog_on) {
2209            warnings.push(
2210                "C1: capabilities.tools_apply_patch is advertised alongside edit_file/\
2211                 write_file with no model_catalog per-model capability bits — format \
2212                 confusion risk (§2.2 C1)"
2213                    .to_string(),
2214            );
2215        }
2216    }
2217
2218    // C3 (MANDATORY, non-suppressible): sandbox=danger_full_access +
2219    // approval=never.
2220    if effective_sandbox(hc) == SandboxPolicy::DangerFullAccess
2221        && effective_approval(hc) == ApprovalPolicy::Never
2222    {
2223        warnings.push(
2224            "C3 (MANDATORY): capabilities.permissions resolves to \
2225             sandbox=danger_full_access + approval=never — zero gates. Legal, but never \
2226             safe-by-default; presets must never label this posture safe (§2.2 C3)"
2227                .to_string(),
2228        );
2229    }
2230
2231    // C4: presets pin approval + system-prompt tuning together; independent
2232    // overrides over a preset baseline warn.
2233    if let Some(baseline) = preset_baseline {
2234        let approval_changed = permissions_approval_raw(hc) != permissions_approval_raw(baseline);
2235        let prompt_changed = hc.core.system_prompt != baseline.core.system_prompt
2236            || hc.core.append_system_prompt != baseline.core.append_system_prompt;
2237        if approval_changed != prompt_changed {
2238            warnings.push(
2239                "C4: capabilities.permissions.approval was overridden independently of \
2240                 core.system_prompt/append_system_prompt (or vice versa) — this preset pins \
2241                 the two together (§2.2 C4)"
2242                    .to_string(),
2243            );
2244        }
2245    }
2246
2247    // C6: tools_background / subagents.background → an approvals
2248    // auto-policy (`background_prompts = "parent" | "auto_policy"`).
2249    let bg_exposure = module_enabled(hc, "tools_background")
2250        || (module_enabled(hc, "subagents") && module_setting_bool(hc, "subagents", "background"));
2251    if bg_exposure {
2252        match module_setting_str(hc, "subagents", "background_prompts") {
2253            Some("parent") | Some("auto_policy") => {}
2254            _ => {
2255                // S8 argued-satisfaction exception (§4.6 cx-parity row):
2256                // under `approval = "model_requested"`, tools proceed
2257                // sandboxed without prompting unless the MODEL itself
2258                // escalates — an auto-run default from a background task's
2259                // perspective, even with no literal `background_prompts`
2260                // key. Recorded as a judgment-call warning, not silently
2261                // treated as clean.
2262                let approval_raw = permissions_approval_raw(hc).unwrap_or_default();
2263                let is_model_requested = approval_raw
2264                    .replace('_', "-")
2265                    .eq_ignore_ascii_case("model-requested");
2266                if is_model_requested {
2267                    warnings.push(
2268                        "C6 (S8 judgment call): background execution proceeds under \
2269                         approval=model_requested with no literal \
2270                         capabilities.subagents.background_prompts key — the model's own \
2271                         escalation is treated as the required auto-policy, not a literal \
2272                         schema-key match (§2.2 C6, §4.6 cx-parity residual)"
2273                            .to_string(),
2274                    );
2275                } else {
2276                    return Err(ResolveError::Conflict {
2277                        name: "C6".to_string(),
2278                        detail: "tools_background and/or subagents.background is enabled \
2279                                 without capabilities.subagents.background_prompts set to \
2280                                 \"parent\" or \"auto_policy\" — a detached task cannot prompt \
2281                                 (§2.2 C6)"
2282                            .to_string(),
2283                    });
2284                }
2285            }
2286        }
2287    }
2288
2289    // SECURITY carry-forward: case-sensitive, deny-unknown-fields re-check
2290    // of `capabilities.permissions` (P3 mandate — see the block below this
2291    // function for `validate_permissions_case_sensitivity`).
2292    if let Some(w) = validate_permissions_case_sensitivity(hc) {
2293        warnings.push(w);
2294    }
2295
2296    Ok(warnings)
2297}
2298
2299// ---------------------------------------------------------------------------
2300// SECURITY carry-forward (independent Fable review finding, P3 mandate):
2301// every P3 code path that CONSUMES `[capabilities.permissions.*]` tables
2302// must deserialize with an EXACT, case-sensitive schema and
2303// `deny_unknown_fields` — a wrong-case key (`Tier`, `Sandbox`) must be
2304// rejected/ignored-with-warning, never silently honored. The raw
2305// `serde_json::Value::get("sandbox")` lookups elsewhere in this file are
2306// already case-sensitive (a JSON/TOML map key lookup never case-folds), so a
2307// mistyped `Sandbox` was already never *honored* — but it was also never
2308// *flagged*, so a typo'd security-relevant key could silently do nothing
2309// with no diagnostic at all. This strict shadow-schema closes that gap: it
2310// is deserialized from the SAME `capabilities.permissions` settings object
2311// purely for validation, and any field it doesn't recognize (including a
2312// case variant of a real one) fails the whole table, producing a named
2313// warning rather than a silent no-op.
2314// ---------------------------------------------------------------------------
2315
2316/// `[capabilities.permissions].sandbox` — either the bare-string shorthand or
2317/// the table form (§3.1 module 12); `deny_unknown_fields` inside the table
2318/// form so a case-typo'd sub-key (`Tier`, `Network`) is rejected too.
2319#[allow(dead_code)]
2320// 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)
2321#[derive(Debug, Deserialize)]
2322#[serde(untagged)]
2323enum StrictSandboxValue {
2324    Bare(String),
2325    Table(StrictSandboxTable),
2326}
2327
2328#[allow(dead_code)]
2329// 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)
2330#[derive(Debug, Deserialize)]
2331#[serde(deny_unknown_fields)]
2332struct StrictSandboxTable {
2333    #[serde(default)]
2334    enabled: Option<bool>,
2335    #[serde(default)]
2336    tier: Option<String>,
2337    #[serde(default)]
2338    network: Option<StrictNetworkTable>,
2339    #[serde(default)]
2340    escalation: Option<String>,
2341    #[serde(default)]
2342    env_policy: Option<String>,
2343}
2344
2345#[allow(dead_code)]
2346// 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)
2347#[derive(Debug, Deserialize)]
2348#[serde(deny_unknown_fields)]
2349struct StrictNetworkTable {
2350    #[serde(default)]
2351    enabled: Option<bool>,
2352    #[serde(default)]
2353    allow_domains: Option<Vec<String>>,
2354    #[serde(default)]
2355    deny_domains: Option<Vec<String>>,
2356}
2357
2358/// `[capabilities.permissions.rules]` (module 11).
2359#[allow(dead_code)]
2360// 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)
2361#[derive(Debug, Deserialize)]
2362#[serde(deny_unknown_fields)]
2363struct StrictRulesTable {
2364    #[serde(default)]
2365    enabled: Option<bool>,
2366    #[serde(default)]
2367    deny: Option<Vec<String>>,
2368    #[serde(default)]
2369    ask: Option<Vec<String>>,
2370    #[serde(default)]
2371    allow: Option<Vec<String>>,
2372}
2373
2374/// `[capabilities.permissions.protected_paths]` (module 13).
2375#[allow(dead_code)]
2376// 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)
2377#[derive(Debug, Deserialize)]
2378#[serde(deny_unknown_fields)]
2379struct StrictProtectedPathsTable {
2380    #[serde(default)]
2381    enabled: Option<bool>,
2382    #[serde(default)]
2383    paths: Option<Vec<String>>,
2384}
2385
2386/// `[capabilities.permissions]`'s FULL settings shape (modules 10-13, §3.1),
2387/// exact case-sensitive field names, `deny_unknown_fields`. Note `enabled`
2388/// itself is NOT here — [`CapabilityConfig::enabled`] already parses it
2389/// separately (before flattening into `settings`), so this only needs to
2390/// cover the flattened remainder.
2391#[allow(dead_code)]
2392// 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)
2393#[derive(Debug, Deserialize)]
2394#[serde(deny_unknown_fields)]
2395struct StrictPermissionsSettings {
2396    #[serde(default)]
2397    approval: Option<String>,
2398    #[serde(default)]
2399    sandbox: Option<StrictSandboxValue>,
2400    #[serde(default)]
2401    auto_approved_tools: Option<Vec<String>>,
2402    #[serde(default)]
2403    rules: Option<StrictRulesTable>,
2404    #[serde(default)]
2405    protected_paths: Option<StrictProtectedPathsTable>,
2406}
2407
2408/// Re-parse `capabilities.permissions`'s settings object through
2409/// [`StrictPermissionsSettings`] purely to catch a case-mismatched or
2410/// otherwise-unrecognized key that the coarser raw-JSON lookups elsewhere
2411/// would silently (and safely, but silently) ignore. Returns a warning
2412/// string when the strict schema rejects it; `None` when the table is
2413/// absent or fully recognized.
2414fn validate_permissions_case_sensitivity(hc: &HarnessConfig) -> Option<String> {
2415    let cap = hc.capabilities.get("permissions")?;
2416    if cap.settings.is_empty() {
2417        return None;
2418    }
2419    let value = serde_json::Value::Object(cap.settings.clone());
2420    match serde_json::from_value::<StrictPermissionsSettings>(value) {
2421        Ok(_) => None,
2422        Err(e) => Some(format!(
2423            "SECURITY: capabilities.permissions carries an unrecognized or case-mismatched \
2424             key and was rejected by the strict, case-sensitive schema (a typo like `Tier`/\
2425             `Sandbox` is never silently honored) — {e}"
2426        )),
2427    }
2428}
2429
2430/// Small helper: turn a hard-dependency check into the uniform
2431/// [`ResolveError::MissingDependency`] shape used throughout
2432/// [`validate_modules`].
2433fn require(met: bool, module: &str, requires: &str) -> Result<(), ResolveError> {
2434    if met {
2435        Ok(())
2436    } else {
2437        Err(ResolveError::MissingDependency {
2438            module: module.to_string(),
2439            requires: requires.to_string(),
2440        })
2441    }
2442}
2443
2444// ---------------------------------------------------------------------------
2445// §3.5 `extends` / preset resolution algorithm — steps 1-7, the resolver's
2446// public entry point.
2447// ---------------------------------------------------------------------------
2448
2449/// The `extends` chain depth cap (§3.5 step 2 — "mirrors CC's import
2450/// depth-4 spirit, cc§2", scaled to 8).
2451const MAX_EXTENDS_DEPTH: usize = 8;
2452
2453/// Top-level `HarnessConfig` keys (§3.1's schema root).
2454const KNOWN_TOP_KEYS: &[&str] = &[
2455    "schema_version",
2456    "extends",
2457    "core",
2458    "capabilities",
2459    "experimental",
2460];
2461
2462/// `[core]`'s direct keys — scalars/arrays plus the named sub-table keys
2463/// (§3.1). Does NOT enumerate the sub-tables' OWN keys (`[core.tools.*]`,
2464/// `[core.compaction]`, …) — see [`unknown_keys`]'s doc comment for why.
2465const KNOWN_CORE_KEYS: &[&str] = &[
2466    "model",
2467    "base_url",
2468    "api_key_env",
2469    "api_key_cmd",
2470    "effort",
2471    "temperature",
2472    "max_tokens",
2473    "max_iterations",
2474    "max_total_output_tokens",
2475    "max_tool_output_bytes",
2476    "parallel_tool_calls",
2477    "shell_env_snapshot",
2478    "system_prompt",
2479    "append_system_prompt",
2480    "project_context",
2481    "env_context",
2482    "context_injections",
2483    "nested_instructions",
2484    "instruction_imports",
2485    "project_root_markers",
2486    "hot_reload",
2487    "additional_dirs",
2488    "extra_headers",
2489    "extra_body",
2490    "model_switch",
2491    "retry",
2492    "tools",
2493    "skills",
2494    "prompts",
2495    "compaction",
2496    "session",
2497    "steering",
2498    "output",
2499];
2500
2501/// §3.5 step 5 (strict branch): unknown-key detection under `schema_version
2502/// = 1`. **Bounded scope, documented rather than a silent gap:** checks the
2503/// top-level keys, `[core]`'s direct keys, and `[capabilities.*]`'s module
2504/// names against the known sets. Does NOT recurse into a `[core.tools.*]`/
2505/// `[core.compaction]`/etc. sub-table's own keys, or into any one
2506/// capability's `settings` — both are forward-extensible by design (new
2507/// module settings ship without a `schema_version` bump), and P2's mandate
2508/// is the resolver + preset table, not an exhaustive schema linter (left
2509/// for a future pass if deeper strictness is wanted).
2510fn unknown_keys(text: &str) -> Result<Vec<String>, HarnessConfigError> {
2511    let value: toml::Value = toml::from_str(text).map_err(HarnessConfigError::Toml)?;
2512    let mut out = Vec::new();
2513    let Some(tbl) = value.as_table() else {
2514        return Ok(out);
2515    };
2516    for k in tbl.keys() {
2517        if !KNOWN_TOP_KEYS.contains(&k.as_str()) {
2518            out.push(k.clone());
2519        }
2520    }
2521    if let Some(core) = tbl.get("core").and_then(|v| v.as_table()) {
2522        for k in core.keys() {
2523            if !KNOWN_CORE_KEYS.contains(&k.as_str()) {
2524                out.push(format!("core.{k}"));
2525            }
2526        }
2527    }
2528    if let Some(caps) = tbl.get("capabilities").and_then(|v| v.as_table()) {
2529        for k in caps.keys() {
2530            if !MODULE_NAMES.contains(&k.as_str()) {
2531                out.push(format!("capabilities.{k}"));
2532            }
2533        }
2534    }
2535    Ok(out)
2536}
2537
2538/// §3.5 steps 1-3: parse `name_or_path`, recurse on its own `extends`, and
2539/// fold the chain root-first (deepest ancestor = lowest priority — each
2540/// recursive call's result is the parent, which the current node overlays).
2541/// `allow_path` gates whether an unrecognized name may be treated as a file
2542/// path (§3.3: user/global layer only — a project layer must pass `false`).
2543fn resolve_preset_chain(
2544    name_or_path: &str,
2545    allow_path: bool,
2546    base_dir: Option<&std::path::Path>,
2547    depth: usize,
2548    seen: &mut Vec<String>,
2549) -> Result<HarnessConfig, ResolveError> {
2550    if depth > MAX_EXTENDS_DEPTH {
2551        return Err(ResolveError::DepthExceeded(seen.clone()));
2552    }
2553    if seen.iter().any(|s| s == name_or_path) {
2554        let mut chain = seen.clone();
2555        chain.push(name_or_path.to_string());
2556        return Err(ResolveError::Cycle(chain));
2557    }
2558    seen.push(name_or_path.to_string());
2559
2560    // `next_base_dir` is the directory a LOADED FILE's own (possibly
2561    // relative) `extends` should resolve against — its own parent
2562    // directory, not the top-level caller's `base_dir`. A built-in preset
2563    // has no filesystem location, so it inherits whatever `base_dir` was
2564    // already in play (built-ins only ever `extends` other built-ins by
2565    // name, never a path, so this is never actually consulted for them).
2566    let (hc, next_base_dir): (HarnessConfig, Option<std::path::PathBuf>) =
2567        if let Some(toml_text) = crate::presets::lookup(name_or_path) {
2568            (
2569                HarnessConfig::from_toml_str(toml_text).map_err(ResolveError::Parse)?,
2570                base_dir.map(std::path::Path::to_path_buf),
2571            )
2572        } else {
2573            if !allow_path {
2574                return Err(ResolveError::PathExtendsNotAllowed(
2575                    name_or_path.to_string(),
2576                ));
2577            }
2578            let path = match base_dir {
2579                Some(dir) => dir.join(name_or_path),
2580                None => std::path::PathBuf::from(name_or_path),
2581            };
2582            let text = std::fs::read_to_string(&path)
2583                .map_err(|e| ResolveError::Io(path.clone(), e.to_string()))?;
2584            let hc = HarnessConfig::from_toml_str(&text).map_err(ResolveError::Parse)?;
2585            let dir = path.parent().map(std::path::Path::to_path_buf);
2586            (hc, dir)
2587        };
2588
2589    match hc.extends.clone() {
2590        Some(parent_ref) => {
2591            let parent = resolve_preset_chain(
2592                &parent_ref,
2593                allow_path,
2594                next_base_dir.as_deref(),
2595                depth + 1,
2596                seen,
2597            )?;
2598            Ok(parent.overlay(&hc))
2599        }
2600        None => Ok(hc),
2601    }
2602}
2603
2604/// §3.5 step 7: fold `[capabilities.permissions]`'s sandbox/approval/
2605/// auto_approved_tools, `[capabilities.deferred_tools]`, and
2606/// `[capabilities.cache]` into a [`ConfigProfile`] alongside
2607/// [`HarnessConfig::to_config_profile`]'s `[core]` fields, then materialize
2608/// one [`Config`] via the existing (fail-safe) [`ConfigBuilder::apply_profile`]
2609/// — extending P1's `[core]`-only resolution to the specific pre-existing
2610/// `Config` fields P2's validation needs (sandbox/approval for C3,
2611/// tool_advertising for `deferred_tools`, cache_plan for `cache`). Full
2612/// module-driven `ToolRegistry` construction (which TOOLS get registered)
2613/// stays P3 (design §5.2 P3: `ToolRegistry::from_config`) — this only
2614/// resolves fields `Config` already has a slot for.
2615fn materialize_config(hc: &HarnessConfig) -> Config {
2616    let mut profile = hc.to_config_profile();
2617    if let Some(cap) = hc.capabilities.get("permissions") {
2618        match cap.settings.get("sandbox") {
2619            Some(serde_json::Value::String(s)) => profile.sandbox = Some(s.clone()),
2620            Some(serde_json::Value::Object(o)) => {
2621                if let Some(t) = o.get("tier").and_then(|v| v.as_str()) {
2622                    profile.sandbox = Some(t.to_string());
2623                }
2624            }
2625            _ => {}
2626        }
2627        if let Some(a) = cap.settings.get("approval").and_then(|v| v.as_str()) {
2628            profile.approval = Some(a.to_string());
2629        }
2630        if let Some(list) = cap
2631            .settings
2632            .get("auto_approved_tools")
2633            .and_then(|v| v.as_array())
2634        {
2635            profile.auto_approved_tools = Some(
2636                list.iter()
2637                    .filter_map(|x| x.as_str().map(String::from))
2638                    .collect(),
2639            );
2640        }
2641        // P4 (design §5.2 "P4"): `capabilities.permissions.rules.deny`/
2642        // `.allow` — the S-sized pattern generalization of
2643        // `auto_approved_tools`, read at the same unconditional-on-`cap`
2644        // level as `auto_approved_tools` above (not gated on
2645        // `permissions.rules.enabled`, matching that sibling field's own
2646        // precedent). The full deny→ask→allow priority ENGINE (module 11)
2647        // stays P5 — this only resolves the two arrays into glob-pattern
2648        // lists `Config::needs_approval` consults. Shared with the CLI's
2649        // own `FileConfig`-driven `build_config` via
2650        // `permissions_rules_patterns`, same pattern as
2651        // `model_catalog::resolve`.
2652        let (deny, allow) = permissions_rules_patterns(cap);
2653        if !deny.is_empty() {
2654            profile.tool_deny_patterns = Some(deny);
2655        }
2656        if !allow.is_empty() {
2657            profile.tool_allow_patterns = Some(allow);
2658        }
2659    }
2660    if let Some(cap) = hc.capabilities.get("deferred_tools") {
2661        if cap.enabled == Some(true) {
2662            profile.tool_advertising = Some("deferred".to_string());
2663            profile.tool_advertising_core = deferred_tools_core(cap);
2664        }
2665    }
2666    if let Some(cap) = hc.capabilities.get("cache") {
2667        if cap.enabled == Some(true) {
2668            profile.cache_plan = cache_plan_str(cap);
2669        }
2670    }
2671    let mut config = ConfigBuilder::default().apply_profile(&profile).build();
2672
2673    // P3 (design §5.2): the resolved module-activation set + the risk-2
2674    // `[experimental] module_registry` gate, both carried on `Config` itself
2675    // so `ToolRegistry::from_config` (and prompt assembly) can consult them
2676    // without re-walking `HarnessConfig` — "pure config → set, testable
2677    // without the loop" (§5.3 risk 2).
2678    config.module_registry = experimental_flag(hc, "module_registry");
2679    config.module_activation = crate::modules::ModuleActivation::from_harness(hc);
2680    config.core_tools_enabled = effective_tools_enabled(hc);
2681    config.skills_enabled = hc.core.skills.enabled.unwrap_or(false);
2682
2683    if let Some(cap) = hc.capabilities.get("reduction") {
2684        let setting = |name: &str| cap.settings.get(name).and_then(|v| v.as_bool());
2685        config.reduction_policy = crate::config::ReductionPolicySettings {
2686            stale_reads: setting("stale_reads"),
2687            diff_reads: setting("diff_reads"),
2688            duplicates: setting("duplicates"),
2689            tool_input_elision: setting("tool_input_elision"),
2690            supersede: setting("supersede"),
2691            normalize_output: setting("normalize_output"),
2692            image_redaction: setting("image_redaction"),
2693            span_summaries: setting("span_summaries"),
2694        };
2695        // The module's `enabled` bit is the documented master switch for all
2696        // optional reduction policies, including the separate offline
2697        // handoff consumer. Preserve legacy availability when the master is
2698        // absent, but an explicit master-off must dominate inherited
2699        // `handoff = true` from a preset.
2700        config.handoff_enabled = cap.enabled.unwrap_or(true) && setting("handoff").unwrap_or(true);
2701    }
2702
2703    // P5-1 (design §2 modules 10-13, §5.3 risk 1's mitigation recipe): the
2704    // permissions ENGINE's runtime fields — carried on `Config` the same
2705    // "pure config → set" way the P3 module-activation fields just above
2706    // are, so `Agent::prepare_tool_call`'s gate can consult them without
2707    // re-walking `HarnessConfig`. `capabilities.permissions.enabled` (module
2708    // 10) is the master gate: `false` (the default, matching every
2709    // `HarnessConfig` that never sets this table) leaves every one of these
2710    // fields at `Config::default()`'s zero value, and
2711    // `Agent::prepare_tool_call` falls through to the pre-P5-1
2712    // `Config::needs_approval` gate byte-for-byte — see that method's doc
2713    // comment.
2714    if let Some(cap) = hc.capabilities.get("permissions") {
2715        config.permissions_enabled = cap.enabled.unwrap_or(false);
2716        config.permissions_ask_patterns = permissions_rules_ask_patterns(cap);
2717        config.permissions_protected_paths = permissions_protected_paths(cap);
2718        config.network_policy = permissions_network_policy(cap);
2719        // P5-10 (§2 module 12): the OS-level sandbox backstop's own knobs
2720        // — populated unconditionally here (same "pure config → set"
2721        // treatment as `network_policy` just above, NOT gated on
2722        // `capabilities.permissions.enabled`/`cap.enabled` — that master
2723        // gate is module 10/11's rule-ENGINE activation switch;
2724        // `capabilities.permissions.sandbox.enabled` is module 12's own,
2725        // independent gate, exactly like `network.enabled` already is for
2726        // `NetworkPolicy`).
2727        config.sandbox_os_enabled = permissions_sandbox_os_enabled(cap);
2728        config.sandbox_escalation = permissions_sandbox_escalation(cap);
2729        config.sandbox_env_policy = permissions_sandbox_env_policy(cap);
2730    }
2731
2732    // P5-3 (design §2 module 9, §2.1 D-1, §2.2 C6): the subagents ENGINE's
2733    // runtime fields — same "pure config → set" carry-forward as P5-1's
2734    // permissions block just above. `capabilities.subagents.enabled`
2735    // (`false`, the default, matching every `HarnessConfig` that never sets
2736    // this table) leaves every field below at `Config::default()`'s zero
2737    // value, and `Agent::tool_schemas`/`Agent::run_tool` never advertise or
2738    // intercept `spawn_subagent`/`subagent_status` at all — byte-identical
2739    // to today's no-subagents behavior.
2740    if let Some(cap) = hc.capabilities.get("subagents") {
2741        config.subagents_enabled = cap.enabled.unwrap_or(false);
2742        config.subagents_max_depth = cap
2743            .settings
2744            .get("max_depth")
2745            .and_then(serde_json::Value::as_u64)
2746            .map(|n| n as usize)
2747            .unwrap_or(2);
2748        config.subagents_max_concurrent = cap
2749            .settings
2750            .get("max_concurrent")
2751            .and_then(serde_json::Value::as_u64)
2752            .map(|n| n as usize)
2753            .unwrap_or(4);
2754        config.subagents_background = module_setting_bool(hc, "subagents", "background");
2755        config.subagents_background_prompts = cap
2756            .settings
2757            .get("background_prompts")
2758            .and_then(serde_json::Value::as_str)
2759            .and_then(crate::subagents::BackgroundPromptsPolicy::parse);
2760        config.subagents_definitions = subagent_definitions(cap);
2761    }
2762
2763    // P5-4 (design §2 module 30, §1.9, §3.1 `capabilities.tui`): the TUI's
2764    // own activation + display settings — same "pure config → set" carry-
2765    // forward as the P5-1/P5-3 blocks above. `capabilities.tui.enabled`
2766    // (`false`, the default, matching every `HarnessConfig` that never sets
2767    // this table) leaves `Config::tui_enabled` at `false`, and
2768    // `crates/cli`'s `chat()` runs the pre-P5-4 rustyline REPL loop
2769    // byte-for-byte — see `Config::tui_enabled`'s doc comment.
2770    if let Some(cap) = hc.capabilities.get("tui") {
2771        config.tui_enabled = cap.enabled.unwrap_or(false);
2772        if let Some(theme) = cap
2773            .settings
2774            .get("theme")
2775            .and_then(serde_json::Value::as_str)
2776        {
2777            config.tui_theme = theme.to_string();
2778        }
2779        config.tui_vim_mode = cap
2780            .settings
2781            .get("vim_mode")
2782            .and_then(serde_json::Value::as_bool)
2783            .unwrap_or(false);
2784        if let Some(keymap) = cap.settings.get("keymap").and_then(|v| v.as_object()) {
2785            config.tui_keymap = keymap
2786                .iter()
2787                .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
2788                .collect();
2789        }
2790    }
2791
2792    // P5-5 (design §2 module 21 `session.tree`, §3.1): the tree module's
2793    // advisory config fields — same "pure config → set" carry-forward as
2794    // P5-1/P5-3 above. `capabilities.session_tree.enabled` (`false`, the
2795    // default, matching every `HarnessConfig` that never sets this table)
2796    // leaves every field below at `Config::default()`'s zero value;
2797    // `crate::session_tree::SessionTree` itself has no runtime dependency on
2798    // any of these (see `Config::session_tree_enabled`'s doc comment), so
2799    // this block changes no BEHAVIOR — only what a future CLI/TUI caller can
2800    // read off the resolved `Config`.
2801    if let Some(cap) = hc.capabilities.get("session_tree") {
2802        config.session_tree_enabled = cap.enabled.unwrap_or(false);
2803        // §3.1's own schema default is `true` for both sub-flags when the
2804        // table is present but a key is unset — same shape as
2805        // `modules::tools_search_subflag`/`tools_web_subflag`.
2806        config.session_tree_branch_summaries = cap
2807            .settings
2808            .get("branch_summaries")
2809            .and_then(serde_json::Value::as_bool)
2810            .unwrap_or(true);
2811        config.session_tree_labels = cap
2812            .settings
2813            .get("labels")
2814            .and_then(serde_json::Value::as_bool)
2815            .unwrap_or(true);
2816    }
2817
2818    // P5-6 (design §2 module 4, §2.1 "tools.background → permissions.
2819    // approvals(auto-policy) [C6 as dep]", §2.2 C6): the tools_background
2820    // ENGINE's runtime fields — same "pure config → set" carry-forward as
2821    // the subagents block just above. `capabilities.tools_background.
2822    // enabled` (`false`, the default, matching every `HarnessConfig` that
2823    // never sets this table) leaves every field below at
2824    // `Config::default()`'s zero value, and `Agent::tool_schemas`/
2825    // `Agent::prepare_tool_call` never advertise or intercept
2826    // `background_exec`/`background_status`/`background_list`/
2827    // `background_kill` at all — byte-identical to today's no-
2828    // tools_background behavior. Note: the module's own C6 auto-policy
2829    // reuses `capabilities.subagents.background_prompts` (already parsed
2830    // above into `config.subagents_background_prompts`) rather than a
2831    // second key — see `Agent::background_permission_denial`'s doc comment
2832    // and `validate_modules`'s C6 check just below, both of which treat
2833    // that ONE schema key (module 9's) as covering both modules, exactly
2834    // as design §2.2 C6 states ("both values are §3.1 schema keys (module
2835    // 9)").
2836    if let Some(cap) = hc.capabilities.get("tools_background") {
2837        config.tools_background_enabled = cap.enabled.unwrap_or(false);
2838        config.tools_background_max_concurrent = cap
2839            .settings
2840            .get("max_concurrent")
2841            .and_then(serde_json::Value::as_u64)
2842            .map(|n| n as usize)
2843            .unwrap_or(crate::background::DEFAULT_MAX_CONCURRENT);
2844        config.tools_background_max_output_bytes = cap
2845            .settings
2846            .get("max_output_bytes")
2847            .and_then(serde_json::Value::as_u64)
2848            .map(|n| n as usize)
2849            .unwrap_or(crate::background::DEFAULT_MAX_OUTPUT_BYTES);
2850    }
2851
2852    // P5-9 (design §2 module 20 `checkpoint`, §3.1): the checkpoint
2853    // module's ENGINE-consumed fields — same "pure config → set" carry-
2854    // forward as the `tools_background` block just above.
2855    // `capabilities.checkpoint.enabled` (`false`, the default, matching
2856    // every `HarnessConfig` that never sets this table) leaves
2857    // `config.checkpoint_enabled` at `Config::default()`'s `false`, and
2858    // `crate::agent::build_tool_context`/`crate::checkpoint::observer_for_config`
2859    // then never touch disk at all — no shadow store, no
2860    // `ToolContext::write_observer` — byte-identical to before this module
2861    // existed. `retain` is NOT in the §3.1 illustrative schema snippet
2862    // (only `{ enabled = false }` is shown there) but IS a real, wired
2863    // knob — see `Config::checkpoint_retain`'s doc comment — never a
2864    // declared-but-dead key.
2865    if let Some(cap) = hc.capabilities.get("checkpoint") {
2866        config.checkpoint_enabled = cap.enabled.unwrap_or(false);
2867        config.checkpoint_retain = cap
2868            .settings
2869            .get("retain")
2870            .and_then(serde_json::Value::as_u64)
2871            .map(|n| n as usize)
2872            .unwrap_or(crate::checkpoint::DEFAULT_RETAIN);
2873    }
2874
2875    // P5-11 (§2 module 28 `lsp`): `capabilities.lsp` — the ENGINE-consumed
2876    // fields, same "pure config -> set" carry-forward as `checkpoint`
2877    // above. `capabilities.lsp.enabled` (`false`, the default, matching
2878    // every `HarnessConfig` that never sets this table) leaves
2879    // `config.lsp_enabled` at `Config::default()`'s `false`, and
2880    // `crate::agent::build_tool_context`/`crate::lsp::manager_for_config`
2881    // then never spawn a process at all — byte-identical to before this
2882    // module existed.
2883    if let Some(cap) = hc.capabilities.get("lsp") {
2884        config.lsp_enabled = cap.enabled.unwrap_or(false);
2885        config.lsp_servers = lsp_servers_from_settings(&cap.settings);
2886        config.lsp_max_diagnostics = cap
2887            .settings
2888            .get("max_diagnostics")
2889            .and_then(serde_json::Value::as_u64)
2890            .map(|n| n as usize)
2891            .unwrap_or(crate::lsp::DEFAULT_LSP_MAX_DIAGNOSTICS);
2892        config.lsp_timeout_secs = cap
2893            .settings
2894            .get("timeout_secs")
2895            .and_then(serde_json::Value::as_u64)
2896            .unwrap_or(crate::lsp::DEFAULT_LSP_TIMEOUT_SECS);
2897    }
2898
2899    // P5-11 (§2 module 29 `formatters`, C10): `capabilities.formatters` —
2900    // same carry-forward as `lsp` just above. `enabled = false` (the
2901    // default) leaves the shared D-5 write-observer chain without a
2902    // `FormatObserver` entry at all. `diff_back` defaults to `true` (C10-
2903    // SAFE) matching the design's own `[capabilities.formatters] { enabled
2904    // = false, diff_back = true }` default line (§3.1) — a config that sets
2905    // `enabled = true` but never touches `diff_back` still gets the safe
2906    // default, not an accidental `false`.
2907    if let Some(cap) = hc.capabilities.get("formatters") {
2908        config.formatters_enabled = cap.enabled.unwrap_or(false);
2909        config.formatters_diff_back = cap
2910            .settings
2911            .get("diff_back")
2912            .and_then(serde_json::Value::as_bool)
2913            .unwrap_or(true);
2914        config.formatters_timeout_secs = cap
2915            .settings
2916            .get("timeout_secs")
2917            .and_then(serde_json::Value::as_u64)
2918            .unwrap_or(crate::formatters::DEFAULT_FORMATTER_TIMEOUT_SECS);
2919        config.formatters = formatters_from_settings(&cap.settings);
2920    }
2921
2922    // P5-12 (§2 module 14 `trust`): `capabilities.trust` — the master gate
2923    // + decision `crate::plugins::is_trusted` reads. `enabled = false` (the
2924    // default, matching every `HarnessConfig` that never sets this table)
2925    // leaves `config.trust_enabled` at `Config::default()`'s `false`, so
2926    // `is_trusted` is always `false` regardless of `trust_default` — same
2927    // "master gate first" carry-forward as every other P5 module.
2928    if let Some(cap) = hc.capabilities.get("trust") {
2929        config.trust_enabled = cap.enabled.unwrap_or(false);
2930        config.trust_default = cap
2931            .settings
2932            .get("default")
2933            .and_then(serde_json::Value::as_str)
2934            .and_then(crate::plugins::TrustDecision::parse)
2935            .unwrap_or_default(); // TrustDecision::Ask — fails closed on an
2936                                  // unset/unparseable value, never `Always`.
2937    }
2938
2939    // P5-12 (§2 module 18 `plugins`, D-10): `capabilities.plugins` — the
2940    // ENGINE-consumed fields `crate::plugins::discover_and_load` reads.
2941    // `enabled = false` (the default) leaves `config.plugins_enabled` at
2942    // `Config::default()`'s `false`, and `crate::agent::Agent::with_parts`
2943    // never calls `crate::plugins::register_into` at all — no directory
2944    // read, no manifest parse, no subprocess — byte-identical to before
2945    // this module existed. `[capabilities.plugins]` (this whole table) is
2946    // project-forbidden (`PROJECT_FORBIDDEN_CAPABILITY_TABLES` above), so
2947    // `dirs` can only ever reach here from the trusted user/global layer.
2948    if let Some(cap) = hc.capabilities.get("plugins") {
2949        config.plugins_enabled = cap.enabled.unwrap_or(false);
2950        config.plugins_dirs = string_array(cap.settings.get("dirs"))
2951            .into_iter()
2952            .map(std::path::PathBuf::from)
2953            .collect();
2954    }
2955
2956    // P4 (design §5.2 "P4"): `capabilities.model_catalog` — alias
2957    // resolution (promoted into core, `crate::model_catalog`) for
2958    // `core.model`, plus the `small_model`/`fallback` knobs. See
2959    // `model_catalog::resolve`'s doc comment for why this is consulted
2960    // regardless of `capabilities.model_catalog.enabled`.
2961    let mc = crate::model_catalog::resolve(&hc.capabilities, &config.model);
2962    config.model = mc.model;
2963    config.small_model = mc.small_model;
2964    config.model_fallback = mc.fallback;
2965
2966    config
2967}
2968
2969/// P5-11 (`capabilities.lsp.servers.<name>`): parse the nested `servers`
2970/// table into `(name, LspServerSpec)` pairs, alphabetical by name (see
2971/// `Config::lsp_servers`'s doc comment for why). An entry missing a
2972/// string `command` is skipped (malformed, not a crash) — `args`/
2973/// `extensions` default to empty when absent or the wrong shape.
2974fn lsp_servers_from_settings(
2975    settings: &serde_json::Map<String, serde_json::Value>,
2976) -> Vec<(String, crate::lsp::LspServerSpec)> {
2977    let Some(servers) = settings.get("servers").and_then(|v| v.as_object()) else {
2978        return Vec::new();
2979    };
2980    let mut names: Vec<&String> = servers.keys().collect();
2981    names.sort();
2982    names
2983        .into_iter()
2984        .filter_map(|name| {
2985            let def = servers.get(name)?.as_object()?;
2986            let command = def.get("command")?.as_str()?.to_string();
2987            let args = string_array(def.get("args"));
2988            let extensions = string_array(def.get("extensions"));
2989            Some((
2990                name.clone(),
2991                crate::lsp::LspServerSpec {
2992                    command,
2993                    args,
2994                    extensions,
2995                },
2996            ))
2997        })
2998        .collect()
2999}
3000
3001/// P5-11 (`capabilities.formatters.<name>`): parse every OTHER key in the
3002/// `[capabilities.formatters]` table (i.e. every key besides the two
3003/// recognized scalars `diff_back`/`timeout_secs`) as a formatter
3004/// definition — mirrors the design's own schema shape
3005/// (`[capabilities.formatters.<name>] command=... extensions=[...]`,
3006/// SIBLINGS of `enabled`/`diff_back`, unlike `lsp`'s nested `servers`
3007/// table). Alphabetical by name, same rationale as
3008/// [`lsp_servers_from_settings`].
3009fn formatters_from_settings(
3010    settings: &serde_json::Map<String, serde_json::Value>,
3011) -> Vec<(String, crate::formatters::FormatterSpec)> {
3012    const RESERVED: &[&str] = &["diff_back", "timeout_secs"];
3013    let mut names: Vec<&String> = settings
3014        .keys()
3015        .filter(|k| !RESERVED.contains(&k.as_str()))
3016        .collect();
3017    names.sort();
3018    names
3019        .into_iter()
3020        .filter_map(|name| {
3021            let def = settings.get(name)?.as_object()?;
3022            let command = def.get("command")?.as_str()?.to_string();
3023            let args = string_array(def.get("args"));
3024            let extensions = string_array(def.get("extensions"));
3025            Some((
3026                name.clone(),
3027                crate::formatters::FormatterSpec {
3028                    command,
3029                    args,
3030                    extensions,
3031                },
3032            ))
3033        })
3034        .collect()
3035}
3036
3037/// Shared helper: a JSON array of strings, or an empty `Vec` for anything
3038/// else (absent, wrong shape, non-string entries skipped individually).
3039fn string_array(v: Option<&serde_json::Value>) -> Vec<String> {
3040    v.and_then(|v| v.as_array())
3041        .map(|a| {
3042            a.iter()
3043                .filter_map(|x| x.as_str().map(String::from))
3044                .collect()
3045        })
3046        .unwrap_or_default()
3047}
3048
3049/// P4 (design §5.2 "P4"): read `capabilities.permissions.rules.deny`/
3050/// `.allow` (module 11's two pattern arrays) into `(deny, allow)` glob
3051/// pattern lists — the S-sized generalization of `auto_approved_tools`
3052/// this phase lands, NOT the full P5 deny→ask→allow priority engine. `cap`
3053/// is the already-fetched `capabilities.permissions` table (both this
3054/// resolver's `materialize_config` and the CLI's own `build_config` fetch
3055/// it themselves first, since each has a different container type to fetch
3056/// it FROM — a `HarnessConfig` vs a `BTreeMap` on `FileConfig`). Empty
3057/// `Vec`s when the table or either key is absent — the default,
3058/// byte-identical-to-today shape.
3059pub fn permissions_rules_patterns(cap: &CapabilityConfig) -> (Vec<String>, Vec<String>) {
3060    let Some(rules) = cap.settings.get("rules").and_then(|v| v.as_object()) else {
3061        return (Vec::new(), Vec::new());
3062    };
3063    let deny = rules
3064        .get("deny")
3065        .and_then(|v| v.as_array())
3066        .map(|a| {
3067            a.iter()
3068                .filter_map(|x| x.as_str().map(String::from))
3069                .collect()
3070        })
3071        .unwrap_or_default();
3072    let allow = rules
3073        .get("allow")
3074        .and_then(|v| v.as_array())
3075        .map(|a| {
3076            a.iter()
3077                .filter_map(|x| x.as_str().map(String::from))
3078                .collect()
3079        })
3080        .unwrap_or_default();
3081    (deny, allow)
3082}
3083
3084/// P5-1 (design §2 module 11, §3.1 `capabilities.permissions.rules.ask`):
3085/// the `ask` sibling of [`permissions_rules_patterns`]'s `deny`/`allow` —
3086/// kept as its own function (rather than folded into that one) since only
3087/// the P5-1 engine consults `ask` at all; `Config::needs_approval` (the
3088/// legacy gate) has no `ask` concept, so `permissions_rules_patterns`
3089/// staying deny/allow-only keeps its existing callers (including the CLI's
3090/// `build_config`) untouched.
3091pub fn permissions_rules_ask_patterns(cap: &CapabilityConfig) -> Vec<String> {
3092    cap.settings
3093        .get("rules")
3094        .and_then(|v| v.as_object())
3095        .and_then(|rules| rules.get("ask"))
3096        .and_then(|v| v.as_array())
3097        .map(|a| {
3098            a.iter()
3099                .filter_map(|x| x.as_str().map(String::from))
3100                .collect()
3101        })
3102        .unwrap_or_default()
3103}
3104
3105/// P5-1 (design §2 module 13, §3.1
3106/// `capabilities.permissions.protected_paths.paths`): read the protected-
3107/// paths glob list — unconditional-on-`cap` like `auto_approved_tools`/
3108/// `permissions_rules_patterns` above (not gated on
3109/// `permissions.protected_paths.enabled`, same sibling-field precedent);
3110/// [`crate::permissions::rules::protected_path_deny_rules`] is what expands
3111/// this list into the engine's actual `deny` tier at the gate.
3112pub fn permissions_protected_paths(cap: &CapabilityConfig) -> Vec<String> {
3113    cap.settings
3114        .get("protected_paths")
3115        .and_then(|v| v.as_object())
3116        .and_then(|pp| pp.get("paths"))
3117        .and_then(|v| v.as_array())
3118        .map(|a| {
3119            a.iter()
3120                .filter_map(|x| x.as_str().map(String::from))
3121                .collect()
3122        })
3123        .unwrap_or_default()
3124}
3125
3126/// P5-3 (design §2 module 9, §3.1 `capabilities.subagents.agents.<name>`,
3127/// D3 "named-defs"): parse the named-subagent-definition sub-table into
3128/// [`crate::subagents::NamedAgentDefinition`]s, keyed by name. Missing or
3129/// malformed fields degrade gracefully (an entry with no `system_prompt`
3130/// gets an empty one — the caller falls back to the parent's own system
3131/// prompt, see `Agent::run_spawn_subagent`) rather than erroring the whole
3132/// resolve — a config-shape mistake here is a weaker agent definition, not
3133/// a security-relevant silent-allow (unlike the permissions-layer
3134/// case-sensitivity carry-forward elsewhere in this file).
3135pub fn subagent_definitions(
3136    cap: &CapabilityConfig,
3137) -> std::collections::HashMap<String, crate::subagents::NamedAgentDefinition> {
3138    let mut out = std::collections::HashMap::new();
3139    let Some(agents) = cap.settings.get("agents").and_then(|v| v.as_object()) else {
3140        return out;
3141    };
3142    for (name, def) in agents {
3143        let Some(obj) = def.as_object() else { continue };
3144        let system_prompt = obj
3145            .get("system_prompt")
3146            .and_then(|v| v.as_str())
3147            .unwrap_or("")
3148            .to_string();
3149        let tools = obj.get("tools").and_then(|v| v.as_array()).map(|a| {
3150            a.iter()
3151                .filter_map(|x| x.as_str().map(String::from))
3152                .collect::<Vec<_>>()
3153        });
3154        let model = obj.get("model").and_then(|v| v.as_str()).map(String::from);
3155        out.insert(
3156            name.clone(),
3157            crate::subagents::NamedAgentDefinition {
3158                name: name.clone(),
3159                system_prompt,
3160                tools,
3161                model,
3162            },
3163        );
3164    }
3165    out
3166}
3167
3168/// P5-1 (design §2 module 12 carry-forward, §3.1
3169/// `capabilities.permissions.sandbox.network.*`): give the
3170/// `crate::tools::NetworkPolicy` enforcement point (`ToolContext::check_network`,
3171/// wired since P4c) its real config source. Reads the network sub-table of
3172/// `capabilities.permissions.sandbox` — note this is nested under
3173/// `permissions`, not a separate `permissions.sandbox` capability entry (see
3174/// [`module_enabled`]'s doc comment on the dotted-name convention: nested
3175/// modules 11-13 all live in `permissions`'s own `settings`, never as
3176/// separate `BTreeMap` keys). `None` when `capabilities.permissions.sandbox`
3177/// (the TABLE form; the bare-string tier shorthand has no `network` to read)
3178/// is absent entirely — byte-identical to today's no-policy-configured gap.
3179/// Present-but-`network`-absent still yields `Some(NetworkPolicy::default())`
3180/// (`enabled: false`), which is a harmless no-op — see `NetworkPolicy`'s own
3181/// doc comment (`crate::tools`) on `enabled: false` behaving exactly like
3182/// `None` on the context.
3183pub fn permissions_network_policy(cap: &CapabilityConfig) -> Option<crate::tools::NetworkPolicy> {
3184    let sandbox = cap.settings.get("sandbox")?.as_object()?;
3185    let network = sandbox.get("network").and_then(|v| v.as_object());
3186    let enabled = network
3187        .and_then(|n| n.get("enabled"))
3188        .and_then(|v| v.as_bool())
3189        .unwrap_or(false);
3190    let string_list = |key: &str| -> Vec<String> {
3191        network
3192            .and_then(|n| n.get(key))
3193            .and_then(|v| v.as_array())
3194            .map(|a| {
3195                a.iter()
3196                    .filter_map(|x| x.as_str().map(String::from))
3197                    .collect()
3198            })
3199            .unwrap_or_default()
3200    };
3201    Some(crate::tools::NetworkPolicy {
3202        enabled,
3203        allow_domains: string_list("allow_domains"),
3204        deny_domains: string_list("deny_domains"),
3205    })
3206}
3207
3208/// P5-10 (§2 module 12, §3.1 `capabilities.permissions.sandbox.enabled`):
3209/// the OS-level backstop's own master gate — see `crate::sandbox::
3210/// os_sandbox_active`'s doc comment for why `None` (the TABLE form's
3211/// `enabled` key absent, OR the bare-string `sandbox = "<tier>"` shorthand
3212/// used instead, which has no `enabled` key to read at all) preserves the
3213/// pre-P5-10 tier-driven trigger rather than defaulting to `Some(false)`.
3214pub fn permissions_sandbox_os_enabled(cap: &CapabilityConfig) -> Option<bool> {
3215    cap.settings
3216        .get("sandbox")?
3217        .as_object()?
3218        .get("enabled")?
3219        .as_bool()
3220}
3221
3222/// P5-10 (§2 module 12, §3.1 `capabilities.permissions.sandbox.escalation`):
3223/// parses via `crate::sandbox::SandboxEscalation::parse` (the alias-
3224/// normalizing parser every sandbox-adjacent string in this crate uses);
3225/// an absent or unrecognized value fails safe to
3226/// [`crate::sandbox::SandboxEscalation::Deny`] (the type's own `Default`),
3227/// never silently to `Allow`.
3228pub fn permissions_sandbox_escalation(cap: &CapabilityConfig) -> crate::sandbox::SandboxEscalation {
3229    cap.settings
3230        .get("sandbox")
3231        .and_then(|v| v.as_object())
3232        .and_then(|o| o.get("escalation"))
3233        .and_then(|v| v.as_str())
3234        .and_then(crate::sandbox::SandboxEscalation::parse)
3235        .unwrap_or_default()
3236}
3237
3238/// P5-10 (§2 module 12, §3.1 `capabilities.permissions.sandbox.env_policy`):
3239/// same parse-or-fail-safe-to-`Default` treatment as
3240/// [`permissions_sandbox_escalation`] — an absent or unrecognized value
3241/// falls back to [`crate::sandbox::SandboxEnvPolicy::Inherit`] (today's
3242/// behavior), never silently to the stricter `None` (that would be a
3243/// surprising, unrequested behavior CHANGE, not a safe fail-closed
3244/// default — `env_policy` narrows what a *subprocess* sees, it isn't a
3245/// security gate the way `escalation`'s fail-closed direction is).
3246pub fn permissions_sandbox_env_policy(cap: &CapabilityConfig) -> crate::sandbox::SandboxEnvPolicy {
3247    cap.settings
3248        .get("sandbox")
3249        .and_then(|v| v.as_object())
3250        .and_then(|o| o.get("env_policy"))
3251        .and_then(|v| v.as_str())
3252        .and_then(crate::sandbox::SandboxEnvPolicy::parse)
3253        .unwrap_or_default()
3254}
3255
3256/// P4d (design §5.2 P1 CLI-adapter follow-up): read
3257/// `capabilities.deferred_tools.core` (module 24's eagerly-advertised
3258/// allowlist) — the S-sized read `materialize_config` inlined, extracted
3259/// so the CLI's own `build_config` can share it without re-deriving the same
3260/// JSON-array walk, same pattern as [`permissions_rules_patterns`]. Caller
3261/// is responsible for the `cap.enabled == Some(true)` gate (both call sites
3262/// already fetch the capability that way). `None` when the `core` key is
3263/// absent — leaves the caller's existing value untouched, matching
3264/// `ConfigProfile::tool_advertising_core`'s "only overridden if the profile
3265/// sets it" contract.
3266pub fn deferred_tools_core(cap: &CapabilityConfig) -> Option<Vec<String>> {
3267    cap.settings
3268        .get("core")
3269        .and_then(|v| v.as_array())
3270        .map(|list| {
3271            list.iter()
3272                .filter_map(|x| x.as_str().map(String::from))
3273                .collect()
3274        })
3275}
3276
3277/// P4d: read `capabilities.cache.plan` — same extraction rationale as
3278/// [`deferred_tools_core`].
3279pub fn cache_plan_str(cap: &CapabilityConfig) -> Option<String> {
3280    cap.settings
3281        .get("plan")
3282        .and_then(|v| v.as_str())
3283        .map(String::from)
3284}
3285
3286/// P5-8 (§2 module 31 `server`, D8 "remote attach"): read
3287/// `capabilities.server.bind` — the HTTP listen address `serve`/`--output-
3288/// format rpc --http`-class transports use. `None` (unset) means the
3289/// LOOPBACK DEFAULT the runtime itself picks (127.0.0.1, OS-assigned
3290/// ephemeral port) — this fn only surfaces an EXPLICIT override, so the
3291/// runtime can tell "the operator opted into a specific bind" (which may
3292/// warrant the non-loopback-exposure warning) from "nothing configured
3293/// (safe default)".
3294pub fn server_bind(cap: &CapabilityConfig) -> Option<String> {
3295    cap.settings
3296        .get("bind")
3297        .and_then(|v| v.as_str())
3298        .map(String::from)
3299}
3300
3301/// P5-8: read `capabilities.server.token` — the bearer token a remote HTTP
3302/// client must present (§ security posture: stdio transports are parent-
3303/// process-trusted and need no token; HTTP does). `None` (unset) means the
3304/// runtime mints a random per-session token instead of trusting a
3305/// operator-chosen fixed value.
3306pub fn server_token(cap: &CapabilityConfig) -> Option<String> {
3307    cap.settings
3308        .get("token")
3309        .and_then(|v| v.as_str())
3310        .map(String::from)
3311}
3312
3313/// `[experimental].<key>` as a bool (§3.1 obligation 8: "feature flags,
3314/// staged gates not yet promoted to `[core]`"). Absent or non-bool → `false`
3315/// — an experimental gate defaults OFF, never silently on.
3316fn experimental_flag(hc: &HarnessConfig, key: &str) -> bool {
3317    hc.experimental
3318        .get(key)
3319        .and_then(|v| v.as_bool())
3320        .unwrap_or(false)
3321}
3322
3323/// §3.5's resolver output: one materialized [`Config`] (step 7), the folded
3324/// `HarnessConfig` it came from (defaults < preset layer < user file <
3325/// sanitized project file, step 4), every named module's activation state
3326/// (step 7's "module-activation set"), the resolved preset chain
3327/// (root-first, informational), and any non-fatal warnings collected along
3328/// the way (lenient-mode unknown keys, D-7/D-9 fallbacks, C1/C3/C4/C6,
3329/// sanitizer/clamp notices from a project layer).
3330pub struct Resolved {
3331    /// The materialized SDK [`Config`].
3332    pub config: Config,
3333    /// The final folded `HarnessConfig`, before [`Config`] materialization.
3334    pub harness: HarnessConfig,
3335    /// Every named module's activation state ([`MODULE_NAMES`] +
3336    /// [`NESTED_MODULE_NAMES`]).
3337    pub modules: BTreeMap<String, bool>,
3338    /// The resolved `extends` chain, root-first (empty if the top file set
3339    /// no `extends`).
3340    pub preset_chain: Vec<String>,
3341    /// Non-fatal diagnostics.
3342    pub warnings: Vec<String>,
3343}
3344
3345/// Manual `Debug`: [`Config`] itself isn't `Debug` (it carries boxed
3346/// callbacks — hooks/handlers, config.rs), so this prints everything else,
3347/// which is what `Result::expect`/`expect_err` need to produce a useful
3348/// panic message in tests.
3349impl std::fmt::Debug for Resolved {
3350    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3351        f.debug_struct("Resolved")
3352            .field("config", &"<Config, not Debug>")
3353            .field("harness", &self.harness)
3354            .field("modules", &self.modules)
3355            .field("preset_chain", &self.preset_chain)
3356            .field("warnings", &self.warnings)
3357            .finish()
3358    }
3359}
3360
3361/// Options controlling [`resolve`]'s step 5 validation strictness.
3362#[derive(Debug, Clone, Copy, Default)]
3363pub struct ResolveOptions {
3364    /// `--strict-config` (§3.5 step 5): unknown keys under `schema_version =
3365    /// 1` are errors instead of warnings.
3366    pub strict: bool,
3367}
3368
3369/// Everything that can fail §3.5 resolution.
3370#[derive(Debug)]
3371pub enum ResolveError {
3372    /// The document isn't valid TOML/JSON, or doesn't match the schema.
3373    Parse(HarnessConfigError),
3374    /// `extends` formed a cycle (step 2). Carries the visitation chain,
3375    /// ending with the name that closed the loop.
3376    Cycle(Vec<String>),
3377    /// The `extends` chain exceeded the depth-8 cap (step 2).
3378    DepthExceeded(Vec<String>),
3379    /// `extends` named a path from a layer where only built-in preset names
3380    /// are legal (§3.3: a project file may never `extends` a path).
3381    PathExtendsNotAllowed(String),
3382    /// A preset file path could not be read.
3383    Io(std::path::PathBuf, String),
3384    /// Strict mode (step 5): the document set a key this build doesn't
3385    /// recognize under `schema_version = 1`.
3386    UnknownKey(String),
3387    /// Step 6: an enabled module's hard dependency is unmet.
3388    MissingDependency {
3389        /// The module that requires something.
3390        module: String,
3391        /// What it requires and doesn't have.
3392        requires: String,
3393    },
3394    /// Step 6: an unresolvable §2.2 conflict (only C6 today — every other
3395    /// implemented conflict degrades to a warning per §2.2's own resolution
3396    /// text).
3397    Conflict {
3398        /// The conflict's §2.2 name (e.g. `"C6"`).
3399        name: String,
3400        /// Human-readable detail.
3401        detail: String,
3402    },
3403}
3404
3405impl std::fmt::Display for ResolveError {
3406    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3407        match self {
3408            ResolveError::Parse(e) => write!(f, "{e}"),
3409            ResolveError::Cycle(chain) => {
3410                write!(f, "extends cycle detected: {}", chain.join(" -> "))
3411            }
3412            ResolveError::DepthExceeded(chain) => write!(
3413                f,
3414                "extends chain exceeds the depth-8 cap (§3.5 step 2): {}",
3415                chain.join(" -> ")
3416            ),
3417            ResolveError::PathExtendsNotAllowed(p) => write!(
3418                f,
3419                "extends = \"{p}\" names a path, which is only legal at the user/global layer \
3420                 (§3.3: a project file may never `extends` a path)"
3421            ),
3422            ResolveError::Io(path, e) => write!(f, "failed to read {}: {e}", path.display()),
3423            ResolveError::UnknownKey(k) => write!(
3424                f,
3425                "unknown key `{k}` under schema_version = 1 (strict mode, §3.5 step 5)"
3426            ),
3427            ResolveError::MissingDependency { module, requires } => write!(
3428                f,
3429                "{module} is enabled but its hard dependency is unmet: requires {requires} \
3430                 (§2.1, §3.5 step 6)"
3431            ),
3432            ResolveError::Conflict { name, detail } => write!(f, "{name}: {detail}"),
3433        }
3434    }
3435}
3436
3437impl std::error::Error for ResolveError {}
3438
3439/// §3.5's resolver entry point: `top_toml` is the file being resolved (e.g.
3440/// the user's config) — it may set `extends` (steps 1-3). `project_toml` is
3441/// an optional second, untrusted layer (§3.3) — ALWAYS sanitized and
3442/// clamped before merge (step 4), regardless of what it sets. `opts`
3443/// controls step 5's strictness. Steps 6-7 (module validation, `Config`
3444/// materialization) run last, over the fully-folded result.
3445pub fn resolve(
3446    top_toml: &str,
3447    project_toml: Option<&str>,
3448    opts: &ResolveOptions,
3449) -> Result<Resolved, ResolveError> {
3450    let mut warnings = Vec::new();
3451
3452    let top_unknown = unknown_keys(top_toml).map_err(ResolveError::Parse)?;
3453    if opts.strict {
3454        if let Some(first) = top_unknown.first() {
3455            return Err(ResolveError::UnknownKey(first.clone()));
3456        }
3457    } else {
3458        for k in &top_unknown {
3459            warnings.push(format!(
3460                "unknown key `{k}` (lenient mode; would error under --strict-config, §3.5 step 5)"
3461            ));
3462        }
3463    }
3464
3465    let top = HarnessConfig::from_toml_str(top_toml).map_err(ResolveError::Parse)?;
3466    resolve_top(top, project_toml, opts, warnings)
3467}
3468
3469/// P3 CLI-wiring entry point (design §5.2 P3, "CLI load path resolves
3470/// config through the P2 resolver"): resolve an already-*typed*
3471/// `HarnessConfig` — e.g. one assembled by the CLI from its own
3472/// `FileConfig`'s forward-compatible `extends`/`capabilities`/`experimental`
3473/// fields, which are ALREADY sanitized/merged by `userconfig.rs`'s own
3474/// project-layer handling (`sanitized_for_project`/`overlay_project`) before
3475/// this ever sees them — so there is no second untrusted text layer to
3476/// merge here, unlike [`resolve`]. `top.extends` is still chased (steps
3477/// 1-3) exactly as [`resolve`] does; when `top.extends` is `None`, callers
3478/// that want "no config file ⇒ `supercode-default` semantics" (design §4
3479/// intro: "supercode with no config file resolves to this preset") must set
3480/// `top.extends = Some("supercode-default".to_string())` themselves before
3481/// calling this — this function does not silently default it, since a
3482/// SILENT default would be exactly the kind of implicit behavior the
3483/// `supercode-default` preset exists to name instead of hide.
3484pub fn resolve_harness(
3485    top: HarnessConfig,
3486    opts: &ResolveOptions,
3487) -> Result<Resolved, ResolveError> {
3488    resolve_top(top, None, opts, Vec::new())
3489}
3490
3491/// Shared tail of [`resolve`]/[`resolve_harness`]: steps 1-7 over an
3492/// already-parsed top layer.
3493fn resolve_top(
3494    top: HarnessConfig,
3495    project_toml: Option<&str>,
3496    opts: &ResolveOptions,
3497    mut warnings: Vec<String>,
3498) -> Result<Resolved, ResolveError> {
3499    // Steps 1-3. Depth starts at 0: the top file's own `extends` is the
3500    // first hop, so an 8-hop chain (9 nodes total: the top file's target
3501    // plus 8 more ancestors) is exactly the depth-8 cap boundary.
3502    let mut preset_chain_names = Vec::new();
3503    let preset_layer = match &top.extends {
3504        Some(ext) => Some(resolve_preset_chain(
3505            ext,
3506            true,
3507            None,
3508            0,
3509            &mut preset_chain_names,
3510        )?),
3511        None => None,
3512    };
3513    preset_chain_names.reverse(); // visitation order is leaf-first; root-first for diagnostics.
3514
3515    let mut top_no_extends = top.clone();
3516    top_no_extends.extends = None;
3517    let user_layer = match &preset_layer {
3518        Some(pl) => pl.overlay(&top_no_extends),
3519        None => top_no_extends,
3520    };
3521
3522    // Step 4: sanitize-before-merge, exactly like `load()` today
3523    // (userconfig.rs:217-224) — then clamp sandbox/approval to no looser
3524    // than the (trusted) user layer's own effective posture.
3525    let final_hc = match project_toml {
3526        Some(proj_text) => {
3527            let proj_unknown = unknown_keys(proj_text).map_err(ResolveError::Parse)?;
3528            if opts.strict {
3529                if let Some(first) = proj_unknown.first() {
3530                    return Err(ResolveError::UnknownKey(first.clone()));
3531                }
3532            } else {
3533                for k in &proj_unknown {
3534                    warnings.push(format!(
3535                        "unknown key `{k}` in project config (lenient mode, §3.5 step 5)"
3536                    ));
3537                }
3538            }
3539            let proj = HarnessConfig::from_toml_str(proj_text).map_err(ResolveError::Parse)?;
3540            let (sanitized, dropped) = sanitize_for_project(&proj);
3541            for d in &dropped {
3542                warnings.push(format!(
3543                    "project config: dropped untrusted key `{d}` (§3.3 monotonic tightening)"
3544                ));
3545            }
3546            let mut merged = user_layer.overlay(&sanitized);
3547            // HIGH fix (Fable-5 P4a review, Attack A/B; §3.3 monotonic
3548            // tightening): `HarnessConfig::overlay`'s general
3549            // `merge_capabilities` already deep-merges (no Attack A here),
3550            // but still lets a sanitized project `rules.deny` REPLACE the
3551            // trusted layer's (Attack B) since arrays replace wholesale.
3552            // Recompute the merged `permissions` capability through the
3553            // canonical, deny-unioning `merge_permissions_capability` — the
3554            // exact same function the CLI route
3555            // (`userconfig.rs::overlay_project`) calls, so the two routes
3556            // agree.
3557            match merge_permissions_capability(
3558                user_layer.capabilities.get("permissions"),
3559                sanitized.capabilities.get("permissions"),
3560            ) {
3561                Some(mp) => {
3562                    merged.capabilities.insert("permissions".to_string(), mp);
3563                }
3564                None => {
3565                    merged.capabilities.remove("permissions");
3566                }
3567            }
3568            match merge_reduction_capability(
3569                user_layer.capabilities.get("reduction"),
3570                sanitized.capabilities.get("reduction"),
3571            ) {
3572                Some(reduction) => {
3573                    merged
3574                        .capabilities
3575                        .insert("reduction".to_string(), reduction);
3576                }
3577                None => {
3578                    merged.capabilities.remove("reduction");
3579                }
3580            }
3581            let clamped = clamp_project_permissions(&user_layer, &sanitized, &mut merged);
3582            for c in &clamped {
3583                warnings.push(format!(
3584                    "project config: clamped `{c}` to the stricter base-layer value \
3585                     (§3.3 monotonic tightening)"
3586                ));
3587            }
3588            merged
3589        }
3590        None => user_layer,
3591    };
3592
3593    // `resolve_harness` starts from an already-typed HarnessConfig, so it
3594    // cannot use the raw-TOML `unknown_keys` pass above. Capability names
3595    // intentionally remain forward-compatible map keys in that type; name
3596    // typos would therefore otherwise disappear silently at materialization.
3597    // Surface them in lenient mode just like raw `resolve` does, while
3598    // avoiding a duplicate when the raw pass already named the same path.
3599    for name in final_hc.capabilities.keys() {
3600        if !MODULE_NAMES.contains(&name.as_str()) {
3601            let path = format!("capabilities.{name}");
3602            if !warnings.iter().any(|warning| warning.contains(&path)) {
3603                warnings.push(format!(
3604                    "unknown capability module `{path}` (lenient mode; ignored)"
3605                ));
3606            }
3607        }
3608    }
3609
3610    // Step 6.
3611    let module_warnings = validate_modules(&final_hc, preset_layer.as_ref())?;
3612    warnings.extend(module_warnings);
3613
3614    // Step 7.
3615    let config = materialize_config(&final_hc);
3616    let modules = activation_set(&final_hc);
3617
3618    Ok(Resolved {
3619        config,
3620        harness: final_hc,
3621        modules,
3622        preset_chain: preset_chain_names,
3623        warnings,
3624    })
3625}
3626
3627#[cfg(test)]
3628mod tests {
3629    use super::*;
3630
3631    const SAMPLE_TOML: &str = r#"
3632schema_version = 1
3633extends = "pi-core"
3634
3635[core]
3636model = "anthropic/claude-opus-4-8"
3637effort = "high"
3638max_iterations = 40
3639project_context = true
3640
3641[core.compaction]
3642after_messages = 50
3643reserve_tokens = 24000
3644
3645[core.tools]
3646enabled = ["read_file", "bash", "edit_file", "write_file"]
3647schema_tier = "medium"
3648
3649[core.tools.bash]
3650enabled = true
3651timeout_secs = 120
3652
3653[capabilities.todos]
3654enabled = true
3655
3656[capabilities.reduction]
3657enabled = true
3658span_summaries = true
3659
3660[experimental]
3661some_staged_flag = true
3662"#;
3663
3664    #[test]
3665    fn harness_config_parses_the_annotated_schema() {
3666        let hc = HarnessConfig::from_toml_str(SAMPLE_TOML).expect("parses");
3667        assert_eq!(hc.schema_version, 1);
3668        // `extends` parses but P1 does not resolve it (§3.5 is P2).
3669        assert_eq!(hc.extends.as_deref(), Some("pi-core"));
3670        assert_eq!(hc.core.model.as_deref(), Some("anthropic/claude-opus-4-8"));
3671        assert_eq!(hc.core.effort.as_deref(), Some("high"));
3672        assert_eq!(hc.core.max_iterations, Some(40));
3673        assert_eq!(hc.core.compaction.after_messages, Some(50));
3674        assert_eq!(hc.core.compaction.reserve_tokens, Some(24000));
3675        assert_eq!(hc.core.tools.schema_tier.as_deref(), Some("medium"));
3676        assert_eq!(hc.core.tools.bash.enabled, Some(true));
3677        assert_eq!(hc.core.tools.bash.timeout_secs, Some(120));
3678        // Capability tables parse as the surface (settings uninterpreted).
3679        assert_eq!(hc.capabilities["todos"].enabled, Some(true));
3680        assert_eq!(hc.capabilities["reduction"].enabled, Some(true));
3681        assert_eq!(
3682            hc.capabilities["reduction"].settings.get("span_summaries"),
3683            Some(&serde_json::Value::Bool(true))
3684        );
3685        assert_eq!(
3686            hc.experimental.get("some_staged_flag"),
3687            Some(&serde_json::Value::Bool(true))
3688        );
3689    }
3690
3691    #[test]
3692    fn harness_config_resolves_core_into_a_real_config() {
3693        let hc = HarnessConfig::from_toml_str(SAMPLE_TOML).expect("parses");
3694        let config = hc.resolve_core();
3695        assert_eq!(config.model, "anthropic/claude-opus-4-8");
3696        assert_eq!(config.effort.as_deref(), Some("high"));
3697        assert_eq!(config.max_iterations, 40);
3698        assert_eq!(config.compact_after_messages, Some(50));
3699        assert_eq!(config.tool_schema_tier, crate::tools::SchemaTier::Medium);
3700        assert!(config.tool_enabled("bash"));
3701    }
3702
3703    #[test]
3704    fn harness_config_json_mirror_round_trips_the_same_shape() {
3705        // §3.0: "a JSON mirror is defined by the same field names for the
3706        // SDK" — the same struct must parse both formats identically.
3707        // F7 fix: this used to compare only 3 hand-picked fields, which
3708        // couldn't catch a field silently dropped or diverging elsewhere in
3709        // the struct; compare full structural equality instead (both
3710        // `HarnessConfig` and `CapabilityConfig` now derive `PartialEq`).
3711        let toml_parsed = HarnessConfig::from_toml_str(SAMPLE_TOML).expect("toml parses");
3712        let json_text = serde_json::to_string(&toml_parsed).expect("serializes to json");
3713        let json_parsed = HarnessConfig::from_json_str(&json_text).expect("json parses back");
3714        assert_eq!(
3715            json_parsed, toml_parsed,
3716            "TOML- and JSON-parsed HarnessConfig must be structurally identical"
3717        );
3718    }
3719
3720    /// F7: only `schema_version = 1` is understood in P1 — an unknown
3721    /// version must be rejected, not silently interpreted under today's
3722    /// field meanings.
3723    #[test]
3724    fn harness_config_rejects_unknown_schema_version() {
3725        let err = HarnessConfig::from_toml_str("schema_version = 2\n")
3726            .expect_err("schema_version 2 must be rejected");
3727        assert!(matches!(
3728            err,
3729            HarnessConfigError::UnsupportedSchemaVersion(2)
3730        ));
3731
3732        let err = HarnessConfig::from_json_str(r#"{"schema_version": 2}"#)
3733            .expect_err("schema_version 2 must be rejected (json)");
3734        assert!(matches!(
3735            err,
3736            HarnessConfigError::UnsupportedSchemaVersion(2)
3737        ));
3738
3739        // Version 1 (explicit or defaulted) still parses fine.
3740        assert!(HarnessConfig::from_toml_str("schema_version = 1\n").is_ok());
3741        assert!(HarnessConfig::from_toml_str("").is_ok());
3742    }
3743
3744    #[test]
3745    fn absent_core_table_defaults_the_whole_region() {
3746        // §3.0: "the region is always present, never absent from a resolved
3747        // config" — even a file with no `[core]` table at all must produce
3748        // an all-defaulted `CoreSection`, not a parse error.
3749        let hc = HarnessConfig::from_toml_str("schema_version = 1\n").expect("parses");
3750        assert_eq!(hc.core, CoreSection::default());
3751    }
3752
3753    #[test]
3754    fn extends_parses_as_a_stub_not_yet_resolved() {
3755        // §3.5 preset resolution is P2; P1 only needs `extends` to parse
3756        // without erroring and to be inspectable, not followed.
3757        let hc = HarnessConfig::from_toml_str(
3758            r#"
3759extends = "cc-parity"
3760[core]
3761model = "x"
3762"#,
3763        )
3764        .expect("parses");
3765        assert_eq!(hc.extends.as_deref(), Some("cc-parity"));
3766        // Resolving `[core]` alone must not error or attempt to chase the
3767        // preset — that's the whole point of deferring §3.5 to P2.
3768        let config = hc.resolve_core();
3769        assert_eq!(config.model, "x");
3770    }
3771
3772    // -----------------------------------------------------------------
3773    // P4: env-substitution in config values (§1.8, design §5.2 "P4").
3774    // -----------------------------------------------------------------
3775
3776    /// Default-off: a value with no `${...}` at all passes through
3777    /// byte-identical.
3778    #[test]
3779    fn expand_env_vars_no_placeholder_is_unchanged() {
3780        assert_eq!(
3781            expand_env_vars("https://openrouter.ai/api/v1"),
3782            "https://openrouter.ai/api/v1"
3783        );
3784        assert_eq!(expand_env_vars(""), "");
3785    }
3786
3787    /// Happy path: a set variable substitutes; multiple placeholders and
3788    /// surrounding literal text all resolve in one pass.
3789    #[test]
3790    fn expand_env_vars_substitutes_set_variables() {
3791        std::env::set_var("SUPERCODE_TEST_ENV_EXPAND_HOST", "my-proxy.example");
3792        std::env::set_var("SUPERCODE_TEST_ENV_EXPAND_PORT", "8080");
3793        assert_eq!(
3794            expand_env_vars(
3795                "https://${SUPERCODE_TEST_ENV_EXPAND_HOST}:${SUPERCODE_TEST_ENV_EXPAND_PORT}/v1"
3796            ),
3797            "https://my-proxy.example:8080/v1"
3798        );
3799        std::env::remove_var("SUPERCODE_TEST_ENV_EXPAND_HOST");
3800        std::env::remove_var("SUPERCODE_TEST_ENV_EXPAND_PORT");
3801    }
3802
3803    /// An unset variable is left LITERAL, not silently blanked — a config
3804    /// author must be able to tell a substitution didn't happen.
3805    #[test]
3806    fn expand_env_vars_unset_variable_stays_literal() {
3807        assert_eq!(
3808            expand_env_vars("token=${SUPERCODE_TEST_DEFINITELY_UNSET_VAR_XYZ}"),
3809            "token=${SUPERCODE_TEST_DEFINITELY_UNSET_VAR_XYZ}"
3810        );
3811    }
3812
3813    /// An unterminated `${` doesn't panic (slice-index safety) and is
3814    /// emitted literally.
3815    #[test]
3816    fn expand_env_vars_unterminated_brace_is_literal_and_safe() {
3817        assert_eq!(expand_env_vars("prefix ${OOPS"), "prefix ${OOPS");
3818    }
3819
3820    /// Wired end-to-end: `core.base_url`/`core.system_prompt` resolve
3821    /// through `to_config_profile`/`resolve_core` with `${VAR}` expanded.
3822    #[test]
3823    fn to_config_profile_expands_env_vars_in_base_url_and_system_prompt() {
3824        std::env::set_var("SUPERCODE_TEST_ENV_EXPAND_ENDPOINT", "vendor.example/v1");
3825        let hc = HarnessConfig::from_toml_str(
3826            r#"
3827schema_version = 1
3828[core]
3829base_url = "https://${SUPERCODE_TEST_ENV_EXPAND_ENDPOINT}"
3830system_prompt = "You are deployed at ${SUPERCODE_TEST_ENV_EXPAND_ENDPOINT}."
3831"#,
3832        )
3833        .expect("parses");
3834        let config = hc.resolve_core();
3835        assert_eq!(config.base_url, "https://vendor.example/v1");
3836        assert_eq!(
3837            config.system_prompt,
3838            "You are deployed at vendor.example/v1."
3839        );
3840        std::env::remove_var("SUPERCODE_TEST_ENV_EXPAND_ENDPOINT");
3841    }
3842
3843    /// `api_key_cmd` is deliberately NOT expanded here — the shell that
3844    /// runs it does its own env substitution; expanding it a second time in
3845    /// config resolution would double-substitute.
3846    #[test]
3847    fn to_config_profile_does_not_expand_api_key_cmd() {
3848        std::env::set_var("SUPERCODE_TEST_ENV_EXPAND_TOKEN", "should-not-appear");
3849        let hc = HarnessConfig::from_toml_str(
3850            r#"
3851schema_version = 1
3852[core]
3853api_key_cmd = "echo ${SUPERCODE_TEST_ENV_EXPAND_TOKEN}"
3854"#,
3855        )
3856        .expect("parses");
3857        let config = hc.resolve_core();
3858        assert_eq!(
3859            config.api_key_cmd.as_deref(),
3860            Some("echo ${SUPERCODE_TEST_ENV_EXPAND_TOKEN}")
3861        );
3862        std::env::remove_var("SUPERCODE_TEST_ENV_EXPAND_TOKEN");
3863    }
3864
3865    // ---- P4c: tool NEW-smalls + model_switch wire through resolve() ------
3866
3867    /// Default-off: no `[core.tools.*]`/`core.shell_env_snapshot`/
3868    /// `core.doom_loop_threshold`/`core.nested_instructions`/
3869    /// `core.model_switch` keys set at all resolves byte-identical to
3870    /// pre-P4c behavior.
3871    #[test]
3872    fn resolve_p4c_defaults_are_unset() {
3873        let resolved =
3874            resolve("schema_version = 1\n", None, &ResolveOptions::default()).expect("resolves");
3875        assert!(!resolved.config.read_file_multimodal);
3876        assert!(!resolved.config.edit_file_require_read_before_edit);
3877        assert!(!resolved.config.edit_file_notebook_aware);
3878        assert!(!resolved.config.shell_env_snapshot);
3879        assert_eq!(resolved.config.doom_loop_threshold, None);
3880        assert!(!resolved.config.nested_instructions);
3881        assert!(!resolved.config.model_switch_allow_switch);
3882    }
3883
3884    /// Happy path: every P4c `[core]`/`[core.tools.*]` key resolves onto the
3885    /// matching `Config` field through the full `resolve()` pipeline (not
3886    /// just `to_config_profile`/`apply_profile` in isolation).
3887    #[test]
3888    fn resolve_applies_every_p4c_core_key() {
3889        let toml = r#"
3890schema_version = 1
3891[core]
3892shell_env_snapshot = true
3893doom_loop_threshold = 4
3894nested_instructions = true
3895
3896[core.tools.read_file]
3897multimodal = true
3898
3899[core.tools.edit_file]
3900require_read_before_edit = true
3901notebook_aware = true
3902
3903[core.model_switch]
3904allow_switch = true
3905"#;
3906        let resolved = resolve(toml, None, &ResolveOptions::default()).expect("resolves");
3907        assert!(resolved.config.read_file_multimodal);
3908        assert!(resolved.config.edit_file_require_read_before_edit);
3909        assert!(resolved.config.edit_file_notebook_aware);
3910        assert!(resolved.config.shell_env_snapshot);
3911        assert_eq!(resolved.config.doom_loop_threshold, Some(4));
3912        assert!(resolved.config.nested_instructions);
3913        assert!(resolved.config.model_switch_allow_switch);
3914    }
3915
3916    /// Boundary: a user/global layer setting these keys survives being
3917    /// folded UNDER a project layer that sets none of them (project files
3918    /// never touch these — every P4c key here is narrowing/tool-behavior,
3919    /// not on the S3.3 forbidden list).
3920    #[test]
3921    fn resolve_p4c_keys_survive_an_empty_project_layer() {
3922        let top = r#"
3923schema_version = 1
3924[core]
3925doom_loop_threshold = 2
3926[core.tools.read_file]
3927multimodal = true
3928"#;
3929        let resolved = resolve(
3930            top,
3931            Some("schema_version = 1\n"),
3932            &ResolveOptions::default(),
3933        )
3934        .expect("resolves");
3935        assert_eq!(resolved.config.doom_loop_threshold, Some(2));
3936        assert!(resolved.config.read_file_multimodal);
3937    }
3938
3939    /// LOW (security, independent Fable-5 review of P4e): a project layer
3940    /// setting `[core.session] dir`/`retention_days`/`name`/`persist`/
3941    /// `export_format`/`git_metadata` is stripped, fail-closed — a
3942    /// malicious repo must not be able to redirect trusted session-
3943    /// transcript WRITES (`dir`) to an arbitrary path, steer `sessions
3944    /// prune`'s DELETIONS (`retention_days`), or otherwise puppet the
3945    /// user's own session store. `auto_title` is the one field in the
3946    /// table that DOES survive (Project-ALLOWED): it can only change a
3947    /// title STRING attached to a session already under the user's own
3948    /// store — no path redirection, no deletion.
3949    #[test]
3950    fn resolve_strips_core_session_operational_keys_from_a_project_layer() {
3951        let top = r#"
3952schema_version = 1
3953[core.session]
3954dir = "/home/user/.trusted-sessions"
3955"#;
3956        let project = r#"
3957schema_version = 1
3958[core.session]
3959dir = "/tmp/evil"
3960name = "attacker-named"
3961persist = false
3962retention_days = 0
3963export_format = "html"
3964git_metadata = true
3965auto_title = true
3966"#;
3967        let resolved = resolve(top, Some(project), &ResolveOptions::default()).expect("resolves");
3968        // The project's `dir` never wins — the trusted user/global value
3969        // survives untouched.
3970        assert_eq!(
3971            resolved.config.session_dir.as_deref(),
3972            Some("/home/user/.trusted-sessions")
3973        );
3974        assert_eq!(resolved.config.session_name, None);
3975        assert!(resolved.config.session_persist); // default true; project's `false` dropped
3976        assert_eq!(resolved.config.session_retention_days, None);
3977        assert_eq!(
3978            resolved.config.session_export_format,
3979            crate::human_export::HumanExportFormat::Text
3980        );
3981        assert!(!resolved.config.session_git_metadata);
3982        // auto_title is the one exception: it DOES survive from the project layer.
3983        assert!(resolved.config.auto_title);
3984
3985        for key in [
3986            "core.session.dir",
3987            "core.session.name",
3988            "core.session.persist",
3989            "core.session.retention_days",
3990            "core.session.export_format",
3991            "core.session.git_metadata",
3992        ] {
3993            assert!(
3994                resolved.warnings.iter().any(|w| w.contains(key)),
3995                "expected a dropped-key warning for `{key}`; warnings: {:?}",
3996                resolved.warnings
3997            );
3998        }
3999        assert!(
4000            !resolved
4001                .warnings
4002                .iter()
4003                .any(|w| w.contains("core.session.auto_title")),
4004            "auto_title should NOT be dropped from a project layer: {:?}",
4005            resolved.warnings
4006        );
4007    }
4008
4009    /// Boundary: the strip above is project-layer-scoped only — a
4010    /// user/global layer (no project layer at all) can still set every
4011    /// `[core.session]` operational key exactly as before.
4012    #[test]
4013    fn resolve_user_layer_session_config_is_unaffected_by_project_stripping() {
4014        let top = r#"
4015schema_version = 1
4016[core.session]
4017dir = "/home/user/.sessions"
4018name = "my-session"
4019persist = false
4020retention_days = 30
4021export_format = "html"
4022git_metadata = true
4023"#;
4024        let resolved = resolve(top, None, &ResolveOptions::default()).expect("resolves");
4025        assert_eq!(
4026            resolved.config.session_dir.as_deref(),
4027            Some("/home/user/.sessions")
4028        );
4029        assert_eq!(resolved.config.session_name.as_deref(), Some("my-session"));
4030        assert!(!resolved.config.session_persist);
4031        assert_eq!(resolved.config.session_retention_days, Some(30));
4032        assert_eq!(
4033            resolved.config.session_export_format,
4034            crate::human_export::HumanExportFormat::Html
4035        );
4036        assert!(resolved.config.session_git_metadata);
4037    }
4038}