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