Expand description
§3 “The Single Config File” (docs/composable-harness/COMPOSABLE-HARNESS-DESIGN.md)
— P1 of the composable-harness migration (design §5.2, phase P1).
HarnessConfig is the one schema described in §3.1: schema_version +
extends + [core] (+ its subtables) + [capabilities.*] +
[experimental]. It is a plain serde struct with no format-specific
logic, so it parses identically from TOML (HarnessConfig::from_toml_str,
the CLI’s format) or JSON (HarnessConfig::from_json_str, the SDK
mirror §3.0 describes as superseding the old 9-field ConfigProfile).
P1 scope (design §5.2’s exact wording): the config surface, not the
module runtime. Fields with no Config runtime home yet are captured
typed-but-unconsumed with a P3/P4: doc-comment rather than inventing
behavior ahead of the phase that consumes them. extends is parsed but
not resolved — preset resolution (§3.5, the preset table itself in
§4) is P2. [capabilities.*] module settings are likewise parsed but
not consumed — module runtime wiring is P3 (design’s explicit framing:
“consumed later phases”).
Naming note (P1 judgment call). crates/harness/src/config.rs already
defines a small ConfigFile { profiles: HashMap<String, ConfigProfile> }
— a named-profile table (the SDK’s --profile/from_profile_file
mechanism). That shape is not what §3.1 describes (one resolved harness,
not a table of named alternatives), so this module introduces the new
type under a distinct name, HarnessConfig, rather than repurposing or
renaming the existing ConfigFile. This is the least-breaking path: zero
changes to Config::from_profile_file or its existing test
(crates/harness/tests/agent_loop.rs:640-652).
[core.model] schema conflict (P1 judgment call). §3.1 literally
shows a scalar core.model (the model id string, line 582) and a table
[core.model] a few lines later (allow_switch, line 611-612). Those are
not simultaneously representable in one TOML document — a table cannot
redefine a key already set as a string in the same parent table (verified
empirically: both tomllib and the toml crate reject it as “cannot
overwrite a value”). Rather than silently working around a spec bug, this
is exposed under a distinct table name, [core.model_switch]
(CoreModelSwitchConfig), until the design doc is corrected upstream.
Structs§
- Bash
Tool Config [core.tools.bash]— maps onto a realcrate::config::ToolOverride(enabled/description/schema_tier/timeout_secs) viaHarnessConfig::to_config_profile(P4e closes thetimeout_secsgap S14 flagged —BashTool’s timeout is consumed viatools::ToolContext::bash_timeout_secs, threaded fromagent::build_tool_context, not theToolOverridestruct directly, sinceTool::executeonly sees aToolContext, not the resolvedConfig/ToolOverridemap — see that field’s doc comment).- Capability
Config [capabilities.<name>](§2 modules). Every module table carriesenabledplus module-specific settings. P1 captures the settings as an untyped catch-all: the modules themselves are P3+ (“consumed later phases” per design §5.2’s P1 description) — this struct is the config surface for them, not their runtime.- Core
Compaction Config [core.compaction](obligation 5).after_messagesmaps to the realConfig.compact_after_messages, resolved in P1; the rest are P4 NEW pressure-trigger fields.- Core
Model Switch Config [core.model_switch](design’s[core.model]; see the naming-conflict doc comment above).- Core
Output Config [core.output](obligation 9). P3/P4:Config.event_sinkis code-only (“Callbacks/handlers are code-only”, config.rs); this is its declarative equivalent, not yet wired to anything.- Core
Retry Config [core.retry]. P4: consumed by a request-retry loop that doesn’t exist as aConfigfield yet (obligation 1; pi§3 naming).- Core
Section [core](§3.1 lines 581-609 + the named subtables that follow).- Core
Session Config [core.session](obligation 6). P3/P4: entirely NEW — noConfigfield represents a session store location/policy today.- Core
Skills Config [core.skills](obligation 4, D-7). P3/P4: a NEW subsystem extendingConfig.prompts; not yet consumed.- Core
Steering Config [core.steering](obligation 7; pi§3 semantics). P4: NEW, noConfigfield yet.- Core
Tools Config [core.tools]— registry shaping (§3.1 line 619; replaceswith_builtins()hardcoding,tools/mod.rs:179-192, in P3).- Edit
File Tool Config [core.tools.edit_file]. P3/P4:require_read_before_edit/notebook_awarehave noToolOverridehome yet (S6/S12 catalog rows 32, 40 —ToolContextstate).schema_tierDOES resolve (P2 addition, mirroring[core.tools.bash].schema_tier’s existing P1 handling inHarnessConfig::to_config_profile) — needed fortoken-saver’s own C9 resolution (§2.2: “per-toolFulloverride survives a globalminimal”, design §4.5) to actually materialize into the resolvedConfigrather than silently parsing-and-dropping the one field the preset relies on.- Harness
Config - The top-level schema (§3.1): one TOML/JSON document that fully determines the harness’s shape (§3.0: “Everything the harness does is a function of the resolved file”).
- Read
File Tool Config [core.tools.read_file]. P3/P4:multimodalhas noToolOverridehome yet (catalog §4a small).- Resolve
Options - Options controlling
resolve’s step 5 validation strictness. - Resolved
- §3.5’s resolver output: one materialized
Config(step 7), the foldedHarnessConfigit came from (defaults < preset layer < user file < sanitized project file, step 4), every named module’s activation state (step 7’s “module-activation set”), the resolved preset chain (root-first, informational), and any non-fatal warnings collected along the way (lenient-mode unknown keys, D-7/D-9 fallbacks, C1/C3/C4/C6, sanitizer/clamp notices from a project layer).
Enums§
- Harness
Config Error - F7 fix:
schema_versionpreviously parsed anyu32silently — a future (or simply typo’d) version number would be interpreted under TODAY’s field meanings with no warning at all, exactly the kind of silent misinterpretation §3.5 step 5’s “fail SAFE” precedent exists to prevent elsewhere in this migration. Only1is understood in P1. - Resolve
Error - Everything that can fail §3.5 resolution.
Constants§
- MODULE_
NAMES - The 31 top-level
[capabilities.<name>]table names (§2’s 35 modules, minus the 4 that nest as sub-tables of a family:permissions.rules,permissions.sandbox,permissions.protected_pathsnest underpermissions;mcp.serveris thecapabilities.mcp.servebool, not a separate top-level table). - NESTED_
MODULE_ NAMES - The 3 nested sub-modules under
capabilities.permissions(module family 10-13, §2 table) — dotted pathsmodule_enabledunderstands.
Functions§
- cache_
plan_ str - P4d: read
capabilities.cache.plan— same extraction rationale asdeferred_tools_core. - clamp_
project_ permissions - §3.3’s monotonic clamp, applied specifically at the project-layer merge
(not the general
HarnessConfig::overlay, which is also used for the preset chain and the user layer — a user’s OWN config extending a preset and then setting a looser value is fine; only the UNTRUSTED project layer is clamped). Mirrorsuserconfig.rs’sclamp_sandbox/clamp_approval(F2/F3 fix precedent): even a project value that survivedsanitize_for_project(because it isn’t the single GLOBAL loosest value) must still be no looser than the base layer’s OWN effective posture — e.g. a project settingworkspace_writewhen the base layer hasread_onlyis a real widening and must be clamped back. - deferred_
tools_ core - P4d (design §5.2 P1 CLI-adapter follow-up): read
capabilities.deferred_tools.core(module 24’s eagerly-advertised allowlist) — the S-sized readmaterialize_configinlined, extracted so the CLI’s ownbuild_configcan share it without re-deriving the same JSON-array walk, same pattern aspermissions_rules_patterns. Caller is responsible for thecap.enabled == Some(true)gate (both call sites already fetch the capability that way).Nonewhen thecorekey is absent — leaves the caller’s existing value untouched, matchingConfigProfile::tool_advertising_core’s “only overridden if the profile sets it” contract. - expand_
env_ vars - P4 (design §5.2 “P4”, §1.8: “env substitution in values”): expand
${VAR}references insagainst the process environment. Applied atHarnessConfig::to_config_profileto the string-valued[core]fields that plausibly vary per deployment —base_url,system_prompt,additional_dirs,extra_headersvalues, andextra_bodystring values (judgment call, §1.8’s “in values” wording names no exhaustive field list;api_key_env/api_key_cmdare deliberately EXCLUDED — the former is already an env var NAME not a value, the latter is a shell command the shell itself expands when it runs, see the call site’s comment). - merge_
permissions_ capability - Merge a project-layer
capabilities.permissionstable onto the trusted (user/global) layer’s — the single canonical merge BOTH the CLI route (crates/cli/src/userconfig.rs::overlay_project) and this core resolver (resolve_top, below) call, so the two routes cannot diverge the way the independent Fable-5 review of P4a found (proven attacks, both against the hard approval floorConfig::needs_approvalgivesrules.deny— true even underApprovalPolicy::Never): - merge_
reduction_ capability - Merge the project-layer reduction module without allowing an untrusted
repository to widen an explicit trusted disable. Reduction is the one
capability a project may enable when the trusted layer is silent, but an
explicit
falseon either the module master switch or a documented pass gate is narrowing and therefore dominatestruefrom the other layer. Settings still deep-merge so a sibling project key cannot discard trusted gates that it did not mention. - module_
enabled - Whether a module (a top-level name, or a dotted
top.subpath for theNESTED_MODULE_NAMES) is enabled in a resolvedHarnessConfig. - permissions_
network_ policy - P5-1 (design §2 module 12 carry-forward, §3.1
capabilities.permissions.sandbox.network.*): give thecrate::tools::NetworkPolicyenforcement point (ToolContext::check_network, wired since P4c) its real config source. Reads the network sub-table ofcapabilities.permissions.sandbox— note this is nested underpermissions, not a separatepermissions.sandboxcapability entry (seemodule_enabled’s doc comment on the dotted-name convention: nested modules 11-13 all live inpermissions’s ownsettings, never as separateBTreeMapkeys).Nonewhencapabilities.permissions.sandbox(the TABLE form; the bare-string tier shorthand has nonetworkto read) is absent entirely — byte-identical to today’s no-policy-configured gap. Present-but-network-absent still yieldsSome(NetworkPolicy::default())(enabled: false), which is a harmless no-op — seeNetworkPolicy’s own doc comment (crate::tools) onenabled: falsebehaving exactly likeNoneon the context. - permissions_
protected_ paths - P5-1 (design §2 module 13, §3.1
capabilities.permissions.protected_paths.paths): read the protected- paths glob list — unconditional-on-caplikeauto_approved_tools/permissions_rules_patternsabove (not gated onpermissions.protected_paths.enabled, same sibling-field precedent);crate::permissions::rules::protected_path_deny_rulesis what expands this list into the engine’s actualdenytier at the gate. - permissions_
rules_ ask_ patterns - P5-1 (design §2 module 11, §3.1
capabilities.permissions.rules.ask): theasksibling ofpermissions_rules_patterns’sdeny/allow— kept as its own function (rather than folded into that one) since only the P5-1 engine consultsaskat all;Config::needs_approval(the legacy gate) has noaskconcept, sopermissions_rules_patternsstaying deny/allow-only keeps its existing callers (including the CLI’sbuild_config) untouched. - permissions_
rules_ patterns - P4 (design §5.2 “P4”): read
capabilities.permissions.rules.deny/.allow(module 11’s two pattern arrays) into(deny, allow)glob pattern lists — the S-sized generalization ofauto_approved_toolsthis phase lands, NOT the full P5 deny→ask→allow priority engine.capis the already-fetchedcapabilities.permissionstable (both this resolver’smaterialize_configand the CLI’s ownbuild_configfetch it themselves first, since each has a different container type to fetch it FROM — aHarnessConfigvs aBTreeMaponFileConfig). EmptyVecs when the table or either key is absent — the default, byte-identical-to-today shape. - permissions_
sandbox_ env_ policy - P5-10 (§2 module 12, §3.1
capabilities.permissions.sandbox.env_policy): same parse-or-fail-safe-to-Defaulttreatment aspermissions_sandbox_escalation— an absent or unrecognized value falls back tocrate::sandbox::SandboxEnvPolicy::Inherit(today’s behavior), never silently to the stricterNone(that would be a surprising, unrequested behavior CHANGE, not a safe fail-closed default —env_policynarrows what a subprocess sees, it isn’t a security gate the wayescalation’s fail-closed direction is). - permissions_
sandbox_ escalation - P5-10 (§2 module 12, §3.1
capabilities.permissions.sandbox.escalation): parses viacrate::sandbox::SandboxEscalation::parse(the alias- normalizing parser every sandbox-adjacent string in this crate uses); an absent or unrecognized value fails safe tocrate::sandbox::SandboxEscalation::Deny(the type’s ownDefault), never silently toAllow. - permissions_
sandbox_ os_ enabled - P5-10 (§2 module 12, §3.1
capabilities.permissions.sandbox.enabled): the OS-level backstop’s own master gate — seecrate::sandbox:: os_sandbox_active’s doc comment for whyNone(the TABLE form’senabledkey absent, OR the bare-stringsandbox = "<tier>"shorthand used instead, which has noenabledkey to read at all) preserves the pre-P5-10 tier-driven trigger rather than defaulting toSome(false). - resolve
- §3.5’s resolver entry point:
top_tomlis the file being resolved (e.g. the user’s config) — it may setextends(steps 1-3).project_tomlis an optional second, untrusted layer (§3.3) — ALWAYS sanitized and clamped before merge (step 4), regardless of what it sets.optscontrols step 5’s strictness. Steps 6-7 (module validation,Configmaterialization) run last, over the fully-folded result. - resolve_
harness - P3 CLI-wiring entry point (design §5.2 P3, “CLI load path resolves
config through the P2 resolver”): resolve an already-typed
HarnessConfig— e.g. one assembled by the CLI from its ownFileConfig’s forward-compatibleextends/capabilities/experimentalfields, which are ALREADY sanitized/merged byuserconfig.rs’s own project-layer handling (sanitized_for_project/overlay_project) before this ever sees them — so there is no second untrusted text layer to merge here, unlikeresolve.top.extendsis still chased (steps 1-3) exactly asresolvedoes; whentop.extendsisNone, callers that want “no config file ⇒supercode-defaultsemantics” (design §4 intro: “supercode with no config file resolves to this preset”) must settop.extends = Some("supercode-default".to_string())themselves before calling this — this function does not silently default it, since a SILENT default would be exactly the kind of implicit behavior thesupercode-defaultpreset exists to name instead of hide. - sanitize_
for_ project - §3.3’s monotonic-tightening rule for a project-layer
HarnessConfig: strip/narrow everything an untrusted repo must not control, recording what it touched. Mirrorsuserconfig.rs::sanitized_for_project’s contract on the new unified schema (see the module note above). - server_
bind - P5-8 (§2 module 31
server, D8 “remote attach”): readcapabilities.server.bind— the HTTP listen addressserve/--output- format rpc --http-class transports use.None(unset) means the LOOPBACK DEFAULT the runtime itself picks (127.0.0.1, OS-assigned ephemeral port) — this fn only surfaces an EXPLICIT override, so the runtime can tell “the operator opted into a specific bind” (which may warrant the non-loopback-exposure warning) from “nothing configured (safe default)”. - server_
token - P5-8: read
capabilities.server.token— the bearer token a remote HTTP client must present (§ security posture: stdio transports are parent- process-trusted and need no token; HTTP does).None(unset) means the runtime mints a random per-session token instead of trusting a operator-chosen fixed value. - subagent_
definitions - P5-3 (design §2 module 9, §3.1
capabilities.subagents.agents.<name>, D3 “named-defs”): parse the named-subagent-definition sub-table intocrate::subagents::NamedAgentDefinitions, keyed by name. Missing or malformed fields degrade gracefully (an entry with nosystem_promptgets an empty one — the caller falls back to the parent’s own system prompt, seeAgent::run_spawn_subagent) rather than erroring the whole resolve — a config-shape mistake here is a weaker agent definition, not a security-relevant silent-allow (unlike the permissions-layer case-sensitivity carry-forward elsewhere in this file).