mecha_core/config.rs
1//! Layered configuration.
2//!
3//! Later layers win, field by field:
4//! 1. built-in defaults
5//! 2. `~/.mecha/config.toml`
6//! 3. `./mecha.toml` in the working directory (project-local)
7//! 4. environment variables
8//! 5. CLI flags (applied by the caller, not here)
9
10use crate::message::Effort;
11use anyhow::{Context, Result};
12use serde::{Deserialize, Serialize};
13use std::collections::BTreeMap;
14use std::path::{Path, PathBuf};
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17#[serde(default, deny_unknown_fields)]
18pub struct Config {
19 /// Which entry in `providers` to use when `--provider` isn't given.
20 pub default_provider: String,
21 pub providers: BTreeMap<String, ProviderConfig>,
22 pub agent: AgentConfig,
23 pub tools: ToolsConfig,
24 pub security: SecurityConfig,
25 /// How `shell` is confined. See [`crate::sandbox`].
26 pub sandbox: crate::sandbox::SandboxConfig,
27 /// MCP servers to connect to at startup.
28 #[serde(rename = "mcp")]
29 pub mcp: Vec<McpServerConfig>,
30 /// Subagents the parent may delegate to, each exposed as one tool.
31 #[serde(rename = "subagent")]
32 pub subagents: Vec<crate::subagent::SubagentProfile>,
33 /// Search backends, in preference order. The chain falls through on
34 /// failure, which is what makes stacking two free tiers viable.
35 #[serde(rename = "search")]
36 pub search: Vec<SearchBackendConfig>,
37 /// User commands run at loop lifecycle points. See [`crate::hooks`].
38 #[serde(rename = "hook")]
39 pub hooks: Vec<HookConfig>,
40 /// Outbound tools staged for user review instead of executed. See
41 /// [`crate::outbox`].
42 pub outbox: OutboxConfig,
43 /// Retention for `~/.mecha/work/`. See [`crate::work`].
44 pub work: WorkConfig,
45 /// Tunables for `mecha slack`. Global-file only; see [`SlackConfig`].
46 pub slack: SlackConfig,
47 /// Inter-agent messages between mecha sessions on this machine. See
48 /// [`crate::mailbox`].
49 pub messages: MessagesConfig,
50 /// Which of `~/.mecha/skills/` a run carries. See [`crate::skill`].
51 pub skills: SkillsConfig,
52 /// The tailnet web surface (`mecha serve`). Global-file only, like
53 /// `[slack]`: it names a listening port and the one identity allowed
54 /// through the door. (Replaces the opaque `toml::Value` bridge main
55 /// carried while this arc was in flight.)
56 pub web: WebConfig,
57}
58
59/// Which skills a run carries.
60///
61/// **There is no way to author a skill here, and that absence is the whole
62/// design.** A skill body only ever comes from `~/.mecha/skills/`, which the
63/// user writes by hand; config names skills, and naming is not authoring. The
64/// threat this forecloses is the one Datadog named — *a cloned repository can
65/// bring skills into a trusted session even if the developer never installed
66/// one from a marketplace* — and it is foreclosed the same way it is for
67/// `[[trigger]]`: by there being nowhere to put one.
68///
69/// A project's `mecha.toml` may still narrow the set, because narrowing is
70/// always safe and a repository saying "these three are the relevant ones" is
71/// useful. It may never widen it: see [`SkillsLayer`] for how that is
72/// enforced rather than asked for.
73#[derive(Debug, Clone, Default, Serialize, Deserialize)]
74#[serde(default, deny_unknown_fields)]
75pub struct SkillsConfig {
76 /// Skills to carry. Empty means every skill in the store.
77 pub enabled: Vec<String>,
78 /// Skills to withhold, applied after `enabled` so it wins.
79 pub disabled: Vec<String>,
80 /// Where the store lives. Defaults to `~/.mecha/skills`.
81 ///
82 /// Global-file only — a project layer naming its own directory would be
83 /// the authoring hole this type exists to close, wearing a different hat.
84 pub dir: Option<PathBuf>,
85}
86
87/// Messaging between this machine's own mecha sessions.
88///
89/// Receiver-side policy, so it loads from the global file only, never a
90/// project's `mecha.toml`: a cloned repository must not be able to set
91/// `inbound = "accept"` on someone's session. Enforced structurally:
92/// `merge_file` strips the section from project layers, loudly.
93#[derive(Debug, Clone, Serialize, Deserialize)]
94#[serde(default, deny_unknown_fields)]
95pub struct MessagesConfig {
96 /// Off by default, like outbox routing: a mailbox is a policy decision.
97 pub enabled: bool,
98 /// Where messages live. Defaults to `~/.mecha/messages`
99 /// (or `$MECHA_MESSAGES_DIR`).
100 pub dir: Option<PathBuf>,
101 /// What a run does with inbound messages: `accept` folds them in at turn
102 /// boundaries, `hold` leaves them for `mecha msg`. Unset — the default —
103 /// resolves per surface: attended front-ends hold, unattended runs
104 /// accept. See [`crate::mailbox::InboundPolicy`].
105 pub inbound: Option<crate::mailbox::InboundPolicy>,
106 /// Pending messages one recipient may hold before senders are refused.
107 pub pending_cap: usize,
108 /// Largest message body, in bytes.
109 pub max_body_bytes: usize,
110 /// Resolved (delivered/dismissed) messages kept per recipient before the
111 /// oldest are pruned. Retention, so the per-turn claim scan stays bounded.
112 pub keep: usize,
113}
114
115impl Default for MessagesConfig {
116 fn default() -> Self {
117 MessagesConfig {
118 enabled: false,
119 dir: None,
120 inbound: None,
121 pending_cap: crate::mailbox::DEFAULT_PENDING_CAP,
122 max_body_bytes: crate::mailbox::DEFAULT_MAX_BODY_BYTES,
123 keep: crate::mailbox::DEFAULT_KEEP_RESOLVED,
124 }
125 }
126}
127
128/// Which tools are outbox-routed, and where staged items live.
129#[derive(Debug, Clone, Default, Serialize, Deserialize)]
130#[serde(default, deny_unknown_fields)]
131pub struct OutboxConfig {
132 /// Registry names (`email__send`, `web__fetch`). A call to one of these
133 /// is staged as a draft the user reviews with `mecha outbox`; the tool
134 /// itself never runs until they release it. Empty means the outbox is
135 /// off, which is the default — routing a tool is a policy decision.
136 pub tools: Vec<String>,
137 /// Where items are staged. Defaults to `~/.mecha/outbox`
138 /// (or `$MECHA_OUTBOX_DIR`).
139 pub dir: Option<PathBuf>,
140 /// Which of the routed names are *publications* rather than messages
141 /// (`factory__bundle_publish`, `factory__bundle_alias`). They stage
142 /// identically; they are **reviewed** differently — the reviewable object
143 /// is the rendered page, `edit` is refused, and the writing-reflection
144 /// miner skips them so a changed directory path never becomes a voice
145 /// rule. See [`crate::outbox::OutboxKind`].
146 ///
147 /// Config's to declare, not the tool's: the loop must not learn what a
148 /// publish is, and a third-party MCP server cannot be trusted to say.
149 pub publish_tools: Vec<String>,
150}
151
152/// How much of a producer's generated output survives a `mecha work clean`.
153///
154/// A policy rather than an intention: the lesson of this project is that
155/// anything without one becomes a pile nobody opens. The number is small on
156/// purpose — the directory is scratch, and what matters is published.
157#[derive(Debug, Clone, Serialize, Deserialize)]
158#[serde(default, deny_unknown_fields)]
159pub struct WorkConfig {
160 /// Entries kept per producer, newest first.
161 pub keep: usize,
162}
163
164/// `[slack]` — tunables for the Slack remote control. **Nothing here grants
165/// anything.** Who may drive the agent lives in `~/.mecha/slack/binding.json`,
166/// a store rather than config, for the reason `[messages]` is global-only and
167/// then some: a project file arrives with a cloned repository, and a repo that
168/// could name a Slack owner would have been handed the remote control.
169#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
170#[serde(default)]
171pub struct SlackConfig {
172 /// Threads that may have a run in flight at once. At the cap the connector
173 /// refuses and says so, rather than queueing: a run that starts twenty
174 /// minutes later against a workspace that has moved is worse than an
175 /// honest refusal.
176 pub max_concurrent: usize,
177 /// How long an approval card waits before the call is refused as
178 /// unanswered. Never a denial by the user — see `Decision::Blocked`.
179 pub approval_timeout_secs: u64,
180 /// `ask` (the default), `allow`, or `read-only`, for a thread nobody has
181 /// set a mode on.
182 pub default_mode: String,
183 pub max_turns: u32,
184 pub max_cost_usd: Option<f64>,
185 /// Flush a streamed chunk once this much text has accumulated, or this
186 /// long has passed — whichever comes first. Size first is Slack's own
187 /// guidance; the timer is so a slow model still shows progress.
188 pub stream_flush_chars: usize,
189 pub stream_flush_ms: u64,
190 /// Largest file to move between a workspace and Slack, in **both**
191 /// directions: an attachment fetched into a run's workspace, and anything
192 /// `/send` puts back. Slack allows 1 GB; a remote control does not need
193 /// to. One number rather than two, because "how big is too big to move
194 /// over this link" is one question and two answers to it drift.
195 pub max_upload_mb: u64,
196 /// Narrow the tool surface for Slack-driven runs.
197 ///
198 /// Empty means "everything configured", which is the default and is
199 /// usually too much: measured on the first live run, the schemas of every
200 /// wired MCP server cost ~7–8k input tokens *per turn* before any work
201 /// happened — against a 32k window whose compaction threshold is 21,845,
202 /// a run starts a third of the way there. A phone rarely needs the mail
203 /// and the calendar and the factory at once, and naming what it does need
204 /// is the cheapest context this system has to give.
205 pub tools: Vec<String>,
206}
207
208impl Default for SlackConfig {
209 fn default() -> Self {
210 SlackConfig {
211 max_concurrent: 3,
212 approval_timeout_secs: 600,
213 default_mode: "ask".into(),
214 max_turns: 40,
215 max_cost_usd: None,
216 stream_flush_chars: 800,
217 stream_flush_ms: 1000,
218 max_upload_mb: 25,
219 tools: Vec::new(),
220 }
221 }
222}
223
224impl Default for WorkConfig {
225 fn default() -> Self {
226 WorkConfig {
227 keep: crate::work::DEFAULT_KEEP,
228 }
229 }
230}
231
232/// One hook: a command run at a lifecycle point, with the event payload as
233/// JSON on stdin.
234#[derive(Debug, Clone, Default, Serialize, Deserialize)]
235#[serde(default, deny_unknown_fields)]
236pub struct HookConfig {
237 /// `pre_tool` | `post_tool` | `session_end`. An unknown event is a startup
238 /// error, not a warning — a policy hook that never fires because its event
239 /// name has a typo is the silently-degrading-sandbox mistake again.
240 pub event: String,
241 /// Run via `sh -c`, as the user, in the workspace.
242 pub command: String,
243 /// Only fire for these tools (`pre_tool`/`post_tool`). Empty means all.
244 pub tools: Vec<String>,
245 /// Kill the hook after this long. The default is deliberately short: a
246 /// `pre_tool` hook is on the critical path of every call it matches.
247 pub timeout_secs: Option<u64>,
248}
249
250impl Default for Config {
251 fn default() -> Self {
252 let mut providers = BTreeMap::new();
253 providers.insert(
254 "anthropic".to_string(),
255 ProviderConfig {
256 kind: "anthropic".to_string(),
257 model: Some(crate::provider::anthropic::DEFAULT_MODEL.to_string()),
258 api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
259 api_key: None,
260 base_url: None,
261 input_price_per_mtok: None,
262 output_price_per_mtok: None,
263 temperature: None,
264 seed: None,
265 context_window: None,
266 // `None`, not `Some(true)`: the per-kind default in
267 // `vision_enabled` is the one place that decision is made,
268 // and writing it here too would be a second copy that can
269 // drift from it.
270 vision: None,
271 max_retries: None,
272 retry_after_cap_secs: None,
273 fallbacks: Vec::new(),
274 },
275 );
276 Config {
277 default_provider: "anthropic".to_string(),
278 providers,
279 agent: AgentConfig::default(),
280 tools: ToolsConfig::default(),
281 security: SecurityConfig::default(),
282 skills: SkillsConfig::default(),
283 sandbox: crate::sandbox::SandboxConfig::default(),
284 mcp: Vec::new(),
285 subagents: Vec::new(),
286 search: Vec::new(),
287 hooks: Vec::new(),
288 outbox: OutboxConfig::default(),
289 work: WorkConfig::default(),
290 slack: SlackConfig::default(),
291 messages: MessagesConfig::default(),
292 web: WebConfig::default(),
293 }
294 }
295}
296
297#[derive(Debug, Clone, Default, Serialize, Deserialize)]
298#[serde(default, deny_unknown_fields)]
299pub struct ProviderConfig {
300 /// `anthropic` | `openai` | `local`
301 pub kind: String,
302 pub model: Option<String>,
303 /// Environment variable holding the key. Preferred over `api_key`.
304 pub api_key_env: Option<String>,
305 /// Inline key. Convenient, but it lands in a file on disk — prefer the env var.
306 pub api_key: Option<String>,
307 pub base_url: Option<String>,
308 /// Per-million-token prices, so budgets and reporting can be in dollars.
309 /// Leave unset for a local model — the marginal cost really is zero.
310 pub input_price_per_mtok: Option<f64>,
311 pub output_price_per_mtok: Option<f64>,
312 /// Sampling temperature, sent verbatim by providers that accept one. Unset
313 /// means the server's default. Do not reach for 0.0 to get repeatability:
314 /// measured on qwen3.6, greedy decoding walks into verbatim repetition
315 /// loops that sampling noise would have broken. Pin the server's own
316 /// default value and set `seed` instead — same distribution, repeatable
317 /// draws. The Anthropic API rejects the parameter, so setting this on an
318 /// `anthropic` provider is a startup error rather than a silent no-op.
319 pub temperature: Option<f64>,
320 /// Sampling seed, for repeatable draws at a nonzero temperature. Only as
321 /// deterministic as the backend: llama-server repeats exactly when requests
322 /// run one at a time, and does not once concurrent requests share a batch.
323 /// Rejected on `anthropic` for the same reason as `temperature`.
324 pub seed: Option<u64>,
325 /// How many tokens this model's context holds — for a local server, the
326 /// `-c` it was started with.
327 ///
328 /// Nothing here can discover this: a provider reports how many tokens a
329 /// prompt *used*, never how many are left. Without it the compaction
330 /// threshold has to be an absolute number somebody remembers to set, and
331 /// when nobody does, a long session dies on a raw
332 /// `exceed_context_size_error` from the server with the whole run lost.
333 /// With it, [`AgentConfig::compact_at`] derives a threshold and the CLI
334 /// can show how much room is left.
335 pub context_window: Option<u64>,
336 /// Whether the model on the other end can see an image.
337 ///
338 /// Declared rather than discovered, for the same reason `context_window`
339 /// is: the Anthropic API has no endpoint that answers it. llama-server
340 /// *does* — `GET /props` reports `modalities.vision` — so preflight
341 /// checks the two against each other and warns on a disagreement in
342 /// **either** direction. Both directions matter and for different
343 /// reasons: declared-but-not-served means every image silently degrades
344 /// to a line of text, and served-but-not-declared means a projector is
345 /// loaded, paid for in memory, and never used.
346 ///
347 /// Unset defaults to `true` for `kind = "anthropic"` (every Claude model
348 /// in the family sees) and `false` everywhere else. False is the safe
349 /// default for a local server because the failure it prevents is the
350 /// expensive one: an `image_url` part sent to a text-only llama-server is
351 /// a failed request, where an image rendered as text is merely a model
352 /// that cannot see — which is what it was before.
353 ///
354 /// **A vision model is two files.** The weights carry the language model;
355 /// the vision tower ships beside them as a separate `mmproj-*.gguf` that
356 /// `--mmproj` must name. `--mmproj-auto` is on by default and only fires
357 /// for `-hf` downloads, so every start script here using `-m <path>` gets
358 /// nothing from it. See `docs/LLAMA-SERVER.md`.
359 pub vision: Option<bool>,
360 /// Retries per request on transient failures — 429, 5xx, transport. 0
361 /// disables. Unset means 3. Auth, billing, invalid-request and
362 /// context-overflow errors are never retried: the same payload fails the
363 /// same way, and overflow belongs to the compaction path.
364 pub max_retries: Option<u32>,
365 /// A `Retry-After` above this many seconds is surfaced as a failure
366 /// instead of slept through (default 60) — a provider can name a wait
367 /// long enough that the process is simply asleep, and control never
368 /// returns to a layer that could fall back instead.
369 pub retry_after_cap_secs: Option<u64>,
370 /// Provider entries to try, in order, when this one exhausts its retries
371 /// on a *transient* failure. Turn-local: the next turn starts from this
372 /// provider again. Each fallback answers with its own model. Empty —
373 /// the default — means strict: fail rather than silently answer with a
374 /// different model. `mecha eval` never falls back regardless: a
375 /// scorecard grades the model it names.
376 pub fallbacks: Vec<String>,
377}
378
379impl ProviderConfig {
380 /// Whether to render images onto this provider's wire.
381 ///
382 /// The default is per-kind rather than a flat `false` because the two
383 /// kinds know different amounts: every model in the Anthropic family
384 /// this harness speaks to has vision, and nothing about a local server
385 /// is knowable from config alone.
386 pub fn vision_enabled(&self) -> bool {
387 self.vision
388 .unwrap_or(matches!(self.kind.as_str(), "anthropic"))
389 }
390
391 /// Prices, if configured. Both halves are required: knowing one is worse
392 /// than knowing neither, because it silently under-reports.
393 pub fn pricing(&self) -> Option<crate::message::Pricing> {
394 match (self.input_price_per_mtok, self.output_price_per_mtok) {
395 (Some(input), Some(output)) => Some(crate::message::Pricing {
396 input_per_mtok: input,
397 output_per_mtok: output,
398 ..Default::default()
399 }),
400 _ => None,
401 }
402 }
403
404 pub fn resolve_api_key(&self) -> Option<String> {
405 if let Some(var) = &self.api_key_env {
406 if let Ok(v) = std::env::var(var) {
407 if !v.is_empty() {
408 return Some(v);
409 }
410 }
411 }
412 self.api_key.clone().filter(|k| !k.is_empty())
413 }
414}
415
416#[derive(Debug, Clone, Serialize, Deserialize)]
417#[serde(default, deny_unknown_fields)]
418pub struct AgentConfig {
419 pub system_prompt: Option<String>,
420 /// Read the system prompt from a file. Wins over `system_prompt`.
421 pub system_prompt_file: Option<PathBuf>,
422 /// Hard stop on runaway loops: how many model turns one run may take.
423 pub max_turns: u32,
424 pub max_tokens: u32,
425 pub effort: Option<Effort>,
426 pub thinking: bool,
427 /// Mark the tools + system prefix as cacheable.
428 pub cache_prompt: bool,
429 /// When the turn budget runs out, spend one more turn with the tools
430 /// removed so the model has to answer with what it has. Without this a
431 /// model that never stops searching returns nothing at all.
432 pub force_final_answer: bool,
433 /// Stop once this many output tokens have been generated in one run.
434 /// `max_turns` bounds the number of round trips; this bounds their size,
435 /// which is what actually runs up a bill.
436 pub max_output_tokens: Option<u64>,
437 /// Stop once one run has cost this much. Requires prices on the provider.
438 pub max_cost_usd: Option<f64>,
439 /// Summarise the middle of the conversation once the prompt passes this
440 /// many tokens.
441 ///
442 /// Measured against what the provider *reported* for the last turn rather
443 /// than an estimate, so it tracks the real prompt including cached tokens.
444 /// Unset by default: compaction is lossy, and silently paraphrasing
445 /// someone's conversation because it got long is a decision they should
446 /// make. Set it to roughly two thirds of the model's context window — or
447 /// set `context_window` on the provider and let
448 /// [`AgentConfig::compact_at`] work it out.
449 pub compact_at_tokens: Option<u64>,
450 /// IANA timezone name for the user, e.g. `America/New_York`. Unset means
451 /// the machine's. See [`AgentConfig::timezone`].
452 pub timezone: Option<String>,
453 /// Turns kept verbatim after a compaction. The recent ones are where the
454 /// work is; a summary of the last two turns is worse than the turns.
455 pub compact_keep_recent: usize,
456 /// Stop a run that repeats an identical tool call, with an identical
457 /// result, right after a compaction (`StopCause::Loop`).
458 ///
459 /// On by default — the asymmetry is deliberate. A general repeated-call
460 /// detector would need a measurement to justify watching all of ordinary
461 /// work; this one exists to escape the specific loop that burns unbounded
462 /// tokens at the largest prompts a run will ever send, and a no-config
463 /// user should get that protection. Identical arguments with a *changing*
464 /// result is polling and never trips it.
465 pub loop_guard: bool,
466 /// Check each summary against the transcript it replaces before
467 /// installing it, and regenerate once with the omissions named.
468 ///
469 /// Summaries fail by *omission* — they preserve what is true and drop
470 /// task-critical specifics — and the producer cannot see its own gaps.
471 /// A separate grounded comparison can: it reads both texts side by side,
472 /// which is a different task from generating either. Measured elsewhere
473 /// (Slipstream) at +6.4–8.8 points on SWE-bench Verified for under 1%
474 /// latency, with ~90% of catches being omissions. Costs one extra
475 /// request per compaction, two when a regeneration is needed.
476 pub compact_validate: bool,
477}
478
479impl Default for AgentConfig {
480 fn default() -> Self {
481 AgentConfig {
482 system_prompt: None,
483 system_prompt_file: None,
484 max_turns: 40,
485 // Streaming is the default, so there's no HTTP-timeout reason to
486 // keep this small; leave room for thinking plus the answer.
487 max_tokens: 64_000,
488 effort: Some(Effort::High),
489 thinking: true,
490 cache_prompt: true,
491 force_final_answer: true,
492 // Unset by default: a ceiling that surprises you mid-task is worse
493 // than no ceiling. Set them once you run things unattended.
494 max_output_tokens: None,
495 max_cost_usd: None,
496 compact_at_tokens: None,
497 timezone: None,
498 compact_keep_recent: 6,
499 loop_guard: true,
500 compact_validate: true,
501 }
502 }
503}
504
505impl AgentConfig {
506 /// The user's IANA timezone (`America/New_York`), when it is not the
507 /// machine's.
508 ///
509 /// A server runs in UTC and the model has no clock, so without this every
510 /// "what's on Thursday" is answered in the wrong zone — and wrongly in a
511 /// way that looks right, since the times are internally consistent. An
512 /// IANA name rather than an offset, because an offset is wrong twice a
513 /// year.
514 pub fn timezone(&self) -> Option<chrono_tz::Tz> {
515 let name = self.timezone.as_deref()?;
516 match name.parse::<chrono_tz::Tz>() {
517 Ok(tz) => Some(tz),
518 Err(_) => {
519 tracing::warn!("unknown [agent] timezone `{name}`; using the machine's");
520 None
521 }
522 }
523 }
524
525 /// Fraction of a known context window at which to start compacting.
526 ///
527 /// Two thirds, because the threshold is checked *between* turns against
528 /// what the last one reported: the next turn still has to fit the model's
529 /// reply, and a burst of parallel tool results can add several thousand
530 /// tokens before anything gets to look again. Leaving a third of the
531 /// window is what makes the reactive check safe.
532 pub const COMPACT_FRACTION: f64 = 0.66;
533
534 /// Where compaction kicks in for a run: the explicit setting if there is
535 /// one, otherwise derived from the provider's context window.
536 ///
537 /// Deriving it is what turns compaction from something you must remember
538 /// to configure into something that just works — and the failure it
539 /// prevents is total, not gradual: one turn over the window and the
540 /// server refuses the request outright.
541 pub fn compact_at(&self, context_window: Option<u64>) -> Option<u64> {
542 self.compact_at_tokens
543 .or_else(|| context_window.map(|w| (w as f64 * Self::COMPACT_FRACTION) as u64))
544 }
545
546 pub fn resolve_system_prompt(&self) -> Result<Option<String>> {
547 if let Some(path) = &self.system_prompt_file {
548 let text = std::fs::read_to_string(path)
549 .with_context(|| format!("reading system_prompt_file {}", path.display()))?;
550 return Ok(Some(text));
551 }
552 Ok(self.system_prompt.clone())
553 }
554}
555
556#[derive(Debug, Clone, Serialize, Deserialize)]
557#[serde(default, deny_unknown_fields)]
558pub struct ToolsConfig {
559 /// Built-in tools to register. Empty means "all of them".
560 pub enabled: Vec<String>,
561 /// Built-in tools to withhold, applied after `enabled`.
562 pub disabled: Vec<String>,
563 /// Filesystem tools refuse to touch anything outside this root.
564 pub workspace: Option<PathBuf>,
565 /// Default answer when nothing is watching to approve a call.
566 pub permission_mode: PermissionMode,
567 pub shell_timeout_secs: u64,
568 /// The byte budget one turn's tool results share, divided across the
569 /// batch. Oversized results are spilled to a file in full and cut in the
570 /// transcript, with the marker naming the path and the line to resume
571 /// from. Unset means derive it from the provider's context window — see
572 /// [`ToolsConfig::resolved_output_budget`].
573 pub output_budget_bytes: Option<usize>,
574}
575
576impl ToolsConfig {
577 /// Ceiling when nothing pins the budget: right for the wide-window
578 /// frontier models the number was originally chosen against.
579 const OUTPUT_BUDGET_MAX: usize = 24_000;
580 /// Floor: below this, a single `cargo build` error listing stops fitting
581 /// and every result arrives pre-truncated — a budget that starves the
582 /// model of its own results is worse than a tight window.
583 const OUTPUT_BUDGET_MIN: usize = 6_000;
584
585 /// The per-turn tool-output budget, window-proportional when unpinned.
586 ///
587 /// An eighth of the window in tokens, ~3 bytes per token. The constraint
588 /// it serves: the between-turns compaction check reads the *previous*
589 /// turn's prompt size, so one turn's results must not leap the gap
590 /// between the threshold (two thirds of the window) and the window
591 /// itself — a third of the window, shared with the model's own output.
592 /// The old flat 24 KB is ~8–12k tokens of numeric data, *larger* than
593 /// that gap at a 32k window: on the 2026-08-07 Terminal-Bench subset a
594 /// trial jumped from under the threshold to 45k tokens in one turn and
595 /// died on the overflow. An eighth of the window (12,288 bytes at 32k)
596 /// keeps even token-dense results inside the gap with room for output.
597 pub fn resolved_output_budget(&self, context_window: Option<u64>) -> usize {
598 if let Some(pinned) = self.output_budget_bytes {
599 return pinned;
600 }
601 match context_window {
602 Some(window) => {
603 ((window as usize / 8) * 3).clamp(Self::OUTPUT_BUDGET_MIN, Self::OUTPUT_BUDGET_MAX)
604 }
605 None => Self::OUTPUT_BUDGET_MAX,
606 }
607 }
608}
609
610impl Default for ToolsConfig {
611 fn default() -> Self {
612 ToolsConfig {
613 enabled: Vec::new(),
614 disabled: Vec::new(),
615 workspace: None,
616 permission_mode: PermissionMode::Ask,
617 shell_timeout_secs: 120,
618 output_budget_bytes: None,
619 }
620 }
621}
622
623/// Defenses against the *lethal trifecta*: private data, untrusted content, and
624/// a way to send data out. An agent holding all three can be turned into an
625/// exfiltration tool by instructions hidden in the content it reads — a
626/// calendar invite title, an email footer, a web page.
627///
628/// The mitigation is structural, not a filter: once both private data and
629/// untrusted content have entered a conversation, refuse to let it send.
630#[derive(Debug, Clone, Serialize, Deserialize)]
631#[serde(default, deny_unknown_fields)]
632pub struct SecurityConfig {
633 pub trifecta: TrifectaPolicy,
634 /// Refuse HTTP requests to loopback, private, and link-local addresses.
635 /// Without this, `http_fetch` reaches your LAN and cloud metadata endpoints.
636 pub block_private_ips: bool,
637 /// If non-empty, HTTP requests may only go to these hosts (suffix match).
638 pub allowed_domains: Vec<String>,
639 /// Hosts that are always refused, checked before `allowed_domains`.
640 pub blocked_domains: Vec<String>,
641 /// Wrap third-party content in a marker telling the model to treat it as
642 /// data rather than instructions. Weak on its own — defense in depth.
643 pub mark_untrusted_output: bool,
644 /// Block *every* outbound call once private data is in context, whether or
645 /// not untrusted content has arrived.
646 ///
647 /// This is a different control from `trifecta`, guarding a different
648 /// threat. The trifecta interlock stops an *injection* turning the agent
649 /// into an exfiltration tool; it deliberately allows sends that happen
650 /// before any third-party content exists, because nothing could have
651 /// influenced them yet. That still lets the agent put your private data
652 /// into a search query because you asked it to, or because it judged that
653 /// helpful — an ordinary privacy leak rather than an attack.
654 ///
655 /// Turn this on when private data must not leave at all. It is
656 /// restrictive: it makes "read my notes, then look something up" fail.
657 pub block_sends_after_private: bool,
658}
659
660impl Default for SecurityConfig {
661 fn default() -> Self {
662 SecurityConfig {
663 trifecta: TrifectaPolicy::Block,
664 block_private_ips: true,
665 allowed_domains: Vec::new(),
666 blocked_domains: Vec::new(),
667 mark_untrusted_output: true,
668 // Off by default: it breaks common, legitimate workflows, and the
669 // right answer for most people is capability separation (put
670 // search in a subagent with no filesystem access) rather than a
671 // blanket ban.
672 block_sends_after_private: false,
673 }
674 }
675}
676
677#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
678#[serde(rename_all = "kebab-case")]
679pub enum TrifectaPolicy {
680 /// Refuse the send outright. The default.
681 Block,
682 /// Ask a human. Only meaningful when someone is watching.
683 Ask,
684 /// Allow it. Appropriate only when the "untrusted" content is in fact
685 /// trusted — e.g. an allowlist of internal hosts.
686 Allow,
687}
688
689/// Capabilities to force on a server's tools. Absent flags leave the server's
690/// own declaration alone; there is deliberately no way to switch one off.
691#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
692#[serde(default, deny_unknown_fields)]
693pub struct CapabilityOverride {
694 pub private_data: bool,
695 pub untrusted_input: bool,
696 pub external_send: bool,
697 pub destructive: bool,
698}
699
700impl From<CapabilityOverride> for crate::tool::Capabilities {
701 fn from(o: CapabilityOverride) -> Self {
702 crate::tool::Capabilities {
703 private_data: o.private_data,
704 untrusted_input: o.untrusted_input,
705 external_send: o.external_send,
706 destructive: o.destructive,
707 }
708 }
709}
710
711#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
712#[serde(rename_all = "kebab-case")]
713pub enum PermissionMode {
714 /// Prompt before anything that isn't read-only.
715 Ask,
716 /// Run everything without asking. For trusted, headless work.
717 Allow,
718 /// Read-only tools run; everything else is refused.
719 ReadOnly,
720}
721
722#[derive(Debug, Clone, Default, Serialize, Deserialize)]
723#[serde(default, deny_unknown_fields)]
724pub struct SearchBackendConfig {
725 /// `exa` | `tavily` | `searxng`
726 pub kind: String,
727 /// Environment variable holding the key. Preferred over `api_key`.
728 pub api_key_env: Option<String>,
729 pub api_key: Option<String>,
730 /// Required for `searxng` (your instance); optional override elsewhere.
731 pub base_url: Option<String>,
732 pub disabled: bool,
733 /// Try this backend first when the caller asked for a deep search.
734 ///
735 /// The chain is preference-ordered and first-to-answer-wins, which makes
736 /// the free backend the right head for ordinary lookups and the wrong one
737 /// for a research question. This is how config says "this backend earns
738 /// its price on the hard ones" without the loop learning what any backend
739 /// costs. It reorders and never filters, so every backend stays reachable
740 /// as a fallback at either depth.
741 #[serde(default)]
742 pub prefer_deep: bool,
743}
744
745impl SearchBackendConfig {
746 pub fn resolve_api_key(&self) -> Option<String> {
747 if let Some(var) = &self.api_key_env {
748 if let Ok(v) = std::env::var(var) {
749 if !v.is_empty() {
750 return Some(v);
751 }
752 }
753 }
754 self.api_key.clone().filter(|k| !k.is_empty())
755 }
756}
757
758#[derive(Debug, Clone, Default, Serialize, Deserialize)]
759#[serde(default, deny_unknown_fields)]
760pub struct McpServerConfig {
761 /// Prefixed onto every tool the server exposes, so two servers can both
762 /// have a `search` without colliding.
763 pub name: String,
764 pub command: String,
765 pub args: Vec<String>,
766 /// Values handed to the server explicitly. Use this for a token the server
767 /// needs, so granting it is a decision written down rather than a
768 /// side-effect of what happened to be exported.
769 pub env: BTreeMap<String, String>,
770 /// Variables inherited from mecha's own environment, by name.
771 ///
772 /// Empty by default, and that default is the point: an MCP server is
773 /// third-party code, and a process that inherits your whole environment
774 /// inherits every provider key in it. `PATH`, `HOME`, `LANG`, `LC_ALL` and
775 /// `TZ` always pass through — without them most runtimes cannot start.
776 pub env_passthrough: Vec<String>,
777 /// Confine this server with the configured `[sandbox]` backend.
778 ///
779 /// Off by default because a confined server sees only the workspace and,
780 /// unless allowed, no network — which is wrong for most of the servers
781 /// people actually run. Worth turning on for anything you did not write.
782 pub sandbox: bool,
783 /// Network for this server alone, overriding `[sandbox] network`.
784 ///
785 /// The case this exists for: a third-party server that has to reach its own
786 /// API, confined, while `shell` still has no way off the machine. With one
787 /// shared switch you would have to open `shell` to satisfy the server.
788 pub network: Option<bool>,
789 /// Register this server's tools under their own names, without the
790 /// `<name>__` prefix. Unset means prefixed — the default that lets two
791 /// servers both expose a `search`. Turn it off for a server whose tools
792 /// already carry their own namespace (`kg_*`), where the prefix is pure
793 /// stutter the model types in every call. The setting is a promise of
794 /// distinct names: an unprefixed tool that collides with anything
795 /// already registered fails startup loudly rather than shadowing it.
796 pub prefix_tools: Option<bool>,
797 /// Capabilities forced onto every tool this server exposes, on top of
798 /// whatever it declares for itself.
799 ///
800 /// MCP capability flags come from the server's own `annotations`, which
801 /// means a third-party server decides how much the interlock distrusts it.
802 /// An unannotated tool is treated as private-but-trusted — wrong in the
803 /// dangerous direction for anything that reaches the open world. A Google
804 /// Docs server is the worked example: a document someone shared with you is
805 /// third-party text, and writing into a document an attacker can read is an
806 /// exfiltration channel, so it is all three legs at once and says none of
807 /// them.
808 ///
809 /// Only ever widens — see [`crate::tool::Capabilities::union`].
810 pub capabilities: CapabilityOverride,
811 /// Skip this server without deleting its config.
812 pub disabled: bool,
813}
814
815impl Config {
816 pub fn global_path() -> Option<PathBuf> {
817 crate::work::mecha_home()
818 .ok()
819 .map(|h| h.join("config.toml"))
820 }
821
822 pub const PROJECT_FILE: &'static str = "mecha.toml";
823
824 /// Load defaults, then the global file, then the project file, then env.
825 pub fn load(project_dir: &Path) -> Result<Self> {
826 let mut cfg = Config::default();
827 // Harness overrides sit between defaults and every file layer: an
828 // accepted, measured change applies everywhere, and anything the
829 // user writes in a config file overwrites it. See `harness.rs`.
830 crate::harness::apply_accepted_overrides(&mut cfg);
831 if let Some(path) = Self::global_path() {
832 if path.exists() {
833 cfg.merge_file(&path, LayerTrust::Global)?;
834 }
835 }
836 let project = project_dir.join(Self::PROJECT_FILE);
837 if project.exists() {
838 cfg.merge_file(&project, LayerTrust::Project)?;
839 }
840 cfg.merge_env();
841 Ok(cfg)
842 }
843
844 /// Defaults plus `~/.mecha/config.toml` plus env — no project layer.
845 ///
846 /// For runs that must not be configurable by whatever directory they happen
847 /// to start in. A `mecha.toml` arrives with a cloned repository, and it can
848 /// name MCP servers to spawn, hooks to execute and tools to enable; that is
849 /// a reasonable bargain when a person is sitting there having just decided
850 /// to work in that repository, and not one at all for a
851 /// [`crate::trigger`] firing at 03:00 with nobody watching.
852 pub fn load_global() -> Result<Self> {
853 let mut cfg = Config::default();
854 // Same override layer as `load`: a trigger run benefits from an
855 // accepted change exactly as an interactive one does.
856 crate::harness::apply_accepted_overrides(&mut cfg);
857 if let Some(path) = Self::global_path() {
858 if path.exists() {
859 cfg.merge_file(&path, LayerTrust::Global)?;
860 }
861 }
862 cfg.merge_env();
863 Ok(cfg)
864 }
865
866 fn merge_file(&mut self, path: &Path, trust: LayerTrust) -> Result<()> {
867 let text =
868 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
869 let mut layer: ConfigLayer =
870 toml::from_str(&text).with_context(|| format!("parsing {}", path.display()))?;
871 // `[messages]` is receiver-side admission policy, and a project file
872 // arrives with a cloned repository — it must not be able to switch a
873 // session's inbound handling to `accept`. Dropped loudly rather than
874 // silently: an ignored section that looks applied is the
875 // silently-degrading-sandbox shape.
876 if trust == LayerTrust::Project && layer.messages.take().is_some() {
877 tracing::warn!(
878 "[messages] in {} is ignored — messaging policy loads from the \
879 global config only",
880 path.display()
881 );
882 }
883 // `[slack]` for the same reason, and a stronger one: a project file
884 // arrives with a cloned repository, and Slack is the remote control.
885 if trust == LayerTrust::Project && layer.slack.take().is_some() {
886 tracing::warn!(
887 "[slack] in {} is ignored — the Slack surface loads from the \
888 global config only",
889 path.display()
890 );
891 }
892 // `[web]` for the `[slack]` reason, in its web costume: the section
893 // names the remote control's door — a listening port and the one
894 // identity allowed through it.
895 if trust == LayerTrust::Project && layer.web.take().is_some() {
896 tracing::warn!(
897 "[web] in {} is ignored — the web surface loads from the \
898 global config only",
899 path.display()
900 );
901 }
902 // `[skills]` from a project layer may only ever *narrow*, and that is
903 // enforced here rather than asked for. `dir` is dropped outright — a
904 // project naming its own skill directory is the authoring hole
905 // `SkillsConfig` exists to close, wearing a different hat — and
906 // `enabled` is intersected with what is already selected rather than
907 // replacing it, so a repository cannot turn on a skill the user did
908 // not. `disabled` is left alone: withholding is always safe.
909 if trust == LayerTrust::Project {
910 if let Some(skills) = layer.skills.as_mut() {
911 if skills.dir.take().is_some() {
912 tracing::warn!(
913 "[skills] dir in {} is ignored — the skill store loads from the \
914 global config only",
915 path.display()
916 );
917 }
918 if let Some(wanted) = skills.enabled.as_mut() {
919 let already = &self.skills.enabled;
920 if !already.is_empty() {
921 wanted.retain(|name| already.contains(name));
922 }
923 // An empty global list means "everything", so a project
924 // list stands as written — still a narrowing, since the
925 // baseline was the whole store.
926 }
927 // `disabled` unions, and the union has to happen *here*
928 // rather than being left to `apply`, which assigns. A project
929 // shipping `disabled = []` would otherwise wipe the user's
930 // global list and carry the very skill they withheld —
931 // widening by writing an empty list, which is the exact hole
932 // this layer exists to close. Folding the global list into
933 // the project's makes the later assignment a union by
934 // construction.
935 if let Some(withheld) = skills.disabled.as_mut() {
936 for name in &self.skills.disabled {
937 if !withheld.contains(name) {
938 withheld.push(name.clone());
939 }
940 }
941 }
942 }
943 }
944 layer.apply(self);
945 Ok(())
946 }
947
948 fn merge_env(&mut self) {
949 if let Ok(v) = std::env::var("MECHA_PROVIDER") {
950 self.default_provider = v;
951 }
952 if let Ok(v) = std::env::var("MECHA_MODEL") {
953 let name = self.default_provider.clone();
954 if let Some(p) = self.providers.get_mut(&name) {
955 p.model = Some(v);
956 }
957 }
958 if let Ok(v) = std::env::var("MECHA_EFFORT") {
959 if let Ok(e) = v.parse() {
960 self.agent.effort = Some(e);
961 }
962 }
963 }
964
965 pub fn provider(&self, name: Option<&str>) -> Result<(String, &ProviderConfig)> {
966 let name = name.unwrap_or(&self.default_provider).to_string();
967 let cfg = self.providers.get(&name).with_context(|| {
968 format!(
969 "no provider named {name:?}. Configured: {}",
970 self.providers
971 .keys()
972 .cloned()
973 .collect::<Vec<_>>()
974 .join(", ")
975 )
976 })?;
977 Ok((name, cfg))
978 }
979
980 /// Write this config to `path`, creating parent directories.
981 pub fn save(&self, path: &Path) -> Result<()> {
982 if let Some(parent) = path.parent() {
983 std::fs::create_dir_all(parent)?;
984 }
985 let text = toml::to_string_pretty(self)?;
986 std::fs::write(path, text).with_context(|| format!("writing {}", path.display()))?;
987 Ok(())
988 }
989}
990
991/// Which file a layer came from, deciding what it may set.
992#[derive(Debug, Clone, Copy, PartialEq, Eq)]
993enum LayerTrust {
994 Global,
995 Project,
996}
997
998/// Tunables for `mecha serve` — the tailnet web surface.
999///
1000/// Global-file only, enforced in `merge_file`: a project file arrives with a
1001/// cloned repository, and this section names a listening port and the one
1002/// identity allowed through the door.
1003#[derive(Debug, Clone, Serialize, Deserialize)]
1004#[serde(default, deny_unknown_fields)]
1005pub struct WebConfig {
1006 /// Port bound on 127.0.0.1 — `tailscale serve` fronts it, and there is
1007 /// deliberately no setting to bind wider (the `dsh` refusal, adopted).
1008 pub port: u16,
1009 /// The Tailscale login every request must carry in
1010 /// `Tailscale-User-Login`, which `tailscale serve` injects. Unset means
1011 /// `mecha serve` refuses to start: a door with no owner check must not
1012 /// open at all.
1013 pub owner_login: Option<String>,
1014 /// Directory holding the built web app (`web/dist`). Unset serves the
1015 /// API routes only, which is what tests and a headless box want.
1016 pub assets: Option<PathBuf>,
1017}
1018
1019impl Default for WebConfig {
1020 fn default() -> Self {
1021 Self {
1022 // "mecha" typed on a phone keypad.
1023 port: 63242,
1024 owner_login: None,
1025 assets: None,
1026 }
1027 }
1028}
1029
1030#[derive(Debug, Default, Deserialize)]
1031#[serde(deny_unknown_fields)]
1032struct WebLayer {
1033 port: Option<u16>,
1034 owner_login: Option<String>,
1035 assets: Option<PathBuf>,
1036}
1037
1038/// A partially-specified config file. Every field is optional so a project file
1039/// can override one setting without restating the rest.
1040#[derive(Debug, Default, Deserialize)]
1041#[serde(deny_unknown_fields)]
1042struct ConfigLayer {
1043 default_provider: Option<String>,
1044 providers: Option<BTreeMap<String, ProviderConfig>>,
1045 agent: Option<AgentLayer>,
1046 tools: Option<ToolsLayer>,
1047 security: Option<SecurityLayer>,
1048 #[serde(rename = "mcp")]
1049 mcp: Option<Vec<McpServerConfig>>,
1050 #[serde(rename = "subagent")]
1051 subagents: Option<Vec<crate::subagent::SubagentProfile>>,
1052 #[serde(rename = "search")]
1053 search: Option<Vec<SearchBackendConfig>>,
1054 #[serde(rename = "hook")]
1055 hooks: Option<Vec<HookConfig>>,
1056 sandbox: Option<SandboxLayer>,
1057 outbox: Option<OutboxLayer>,
1058 work: Option<WorkLayer>,
1059 slack: Option<SlackLayer>,
1060 messages: Option<MessagesLayer>,
1061 skills: Option<SkillsLayer>,
1062 web: Option<WebLayer>,
1063}
1064
1065/// A layer's opinion about which skills to carry.
1066///
1067/// The merge is **narrowing-only, structurally**, which is why this cannot be
1068/// the usual "later layer wins" assignment:
1069///
1070/// - `enabled` **intersects** with what is already selected. A project asking
1071/// for a skill the global layer did not enable gets nothing, so the list can
1072/// only ever shrink.
1073/// - `disabled` **unions**. Withholding is always allowed.
1074///
1075/// The alternative — assignment, with a note asking project files not to
1076/// widen — is the shape this repository refuses everywhere else: a rule that
1077/// holds until the first person in a hurry. Note that the global layer is
1078/// where an intersection starts from nothing, so it assigns rather than
1079/// intersects; the distinction is [`LayerTrust`], applied in
1080/// [`Config::merge_file`].
1081#[derive(Debug, Default, Deserialize)]
1082#[serde(deny_unknown_fields)]
1083struct SkillsLayer {
1084 enabled: Option<Vec<String>>,
1085 disabled: Option<Vec<String>>,
1086 dir: Option<PathBuf>,
1087}
1088
1089#[derive(Debug, Default, Deserialize)]
1090#[serde(deny_unknown_fields)]
1091struct MessagesLayer {
1092 enabled: Option<bool>,
1093 dir: Option<PathBuf>,
1094 inbound: Option<crate::mailbox::InboundPolicy>,
1095 pending_cap: Option<usize>,
1096 max_body_bytes: Option<usize>,
1097 keep: Option<usize>,
1098}
1099
1100#[derive(Debug, Default, Deserialize)]
1101#[serde(deny_unknown_fields)]
1102struct WorkLayer {
1103 keep: Option<usize>,
1104}
1105
1106#[derive(Debug, Default, Deserialize)]
1107#[serde(deny_unknown_fields)]
1108struct SlackLayer {
1109 max_concurrent: Option<usize>,
1110 approval_timeout_secs: Option<u64>,
1111 default_mode: Option<String>,
1112 max_turns: Option<u32>,
1113 max_cost_usd: Option<f64>,
1114 stream_flush_chars: Option<usize>,
1115 stream_flush_ms: Option<u64>,
1116 max_upload_mb: Option<u64>,
1117 tools: Option<Vec<String>>,
1118}
1119
1120#[derive(Debug, Default, Deserialize)]
1121#[serde(deny_unknown_fields)]
1122struct OutboxLayer {
1123 tools: Option<Vec<String>>,
1124 dir: Option<PathBuf>,
1125 publish_tools: Option<Vec<String>>,
1126}
1127
1128#[derive(Debug, Default, Deserialize)]
1129#[serde(deny_unknown_fields)]
1130struct AgentLayer {
1131 system_prompt: Option<String>,
1132 system_prompt_file: Option<PathBuf>,
1133 max_turns: Option<u32>,
1134 max_tokens: Option<u32>,
1135 effort: Option<Effort>,
1136 thinking: Option<bool>,
1137 cache_prompt: Option<bool>,
1138 force_final_answer: Option<bool>,
1139 max_output_tokens: Option<u64>,
1140 max_cost_usd: Option<f64>,
1141 compact_at_tokens: Option<u64>,
1142 compact_keep_recent: Option<usize>,
1143 compact_validate: Option<bool>,
1144 loop_guard: Option<bool>,
1145 timezone: Option<String>,
1146}
1147
1148#[derive(Debug, Default, Deserialize)]
1149#[serde(deny_unknown_fields)]
1150struct SecurityLayer {
1151 trifecta: Option<TrifectaPolicy>,
1152 block_private_ips: Option<bool>,
1153 allowed_domains: Option<Vec<String>>,
1154 blocked_domains: Option<Vec<String>>,
1155 mark_untrusted_output: Option<bool>,
1156 block_sends_after_private: Option<bool>,
1157}
1158
1159#[derive(Debug, Default, Deserialize)]
1160#[serde(deny_unknown_fields)]
1161struct SandboxLayer {
1162 kind: Option<crate::sandbox::Backend>,
1163 network: Option<bool>,
1164 writable: Option<Vec<PathBuf>>,
1165 readable: Option<Vec<PathBuf>>,
1166 env: Option<Vec<String>>,
1167 image: Option<String>,
1168 memory_mb: Option<u64>,
1169 cpus: Option<f64>,
1170}
1171
1172#[derive(Debug, Default, Deserialize)]
1173#[serde(deny_unknown_fields)]
1174struct ToolsLayer {
1175 enabled: Option<Vec<String>>,
1176 disabled: Option<Vec<String>>,
1177 workspace: Option<PathBuf>,
1178 permission_mode: Option<PermissionMode>,
1179 shell_timeout_secs: Option<u64>,
1180 output_budget_bytes: Option<usize>,
1181}
1182
1183impl ConfigLayer {
1184 fn apply(self, cfg: &mut Config) {
1185 if let Some(v) = self.default_provider {
1186 cfg.default_provider = v;
1187 }
1188 // Providers merge by key so a project file can add a local endpoint
1189 // without redeclaring the Anthropic one.
1190 if let Some(providers) = self.providers {
1191 cfg.providers.extend(providers);
1192 }
1193 if let Some(a) = self.agent {
1194 let t = &mut cfg.agent;
1195 if a.system_prompt.is_some() {
1196 t.system_prompt = a.system_prompt;
1197 }
1198 if a.system_prompt_file.is_some() {
1199 t.system_prompt_file = a.system_prompt_file;
1200 }
1201 if let Some(v) = a.max_turns {
1202 t.max_turns = v;
1203 }
1204 if let Some(v) = a.max_tokens {
1205 t.max_tokens = v;
1206 }
1207 if a.effort.is_some() {
1208 t.effort = a.effort;
1209 }
1210 if let Some(v) = a.thinking {
1211 t.thinking = v;
1212 }
1213 if let Some(v) = a.cache_prompt {
1214 t.cache_prompt = v;
1215 }
1216 if let Some(v) = a.force_final_answer {
1217 t.force_final_answer = v;
1218 }
1219 if a.max_output_tokens.is_some() {
1220 t.max_output_tokens = a.max_output_tokens;
1221 }
1222 if a.max_cost_usd.is_some() {
1223 t.max_cost_usd = a.max_cost_usd;
1224 }
1225 if a.compact_at_tokens.is_some() {
1226 t.compact_at_tokens = a.compact_at_tokens;
1227 }
1228 if let Some(v) = a.compact_keep_recent {
1229 t.compact_keep_recent = v;
1230 }
1231 if let Some(v) = a.compact_validate {
1232 t.compact_validate = v;
1233 }
1234 if let Some(v) = a.loop_guard {
1235 t.loop_guard = v;
1236 }
1237 if a.timezone.is_some() {
1238 t.timezone = a.timezone;
1239 }
1240 }
1241 if let Some(x) = self.tools {
1242 let t = &mut cfg.tools;
1243 if let Some(v) = x.enabled {
1244 t.enabled = v;
1245 }
1246 if let Some(v) = x.disabled {
1247 t.disabled = v;
1248 }
1249 if x.workspace.is_some() {
1250 t.workspace = x.workspace;
1251 }
1252 if let Some(v) = x.permission_mode {
1253 t.permission_mode = v;
1254 }
1255 if let Some(v) = x.shell_timeout_secs {
1256 t.shell_timeout_secs = v;
1257 }
1258 if let Some(v) = x.output_budget_bytes {
1259 t.output_budget_bytes = Some(v);
1260 }
1261 }
1262 if let Some(x) = self.security {
1263 let t = &mut cfg.security;
1264 if let Some(v) = x.trifecta {
1265 t.trifecta = v;
1266 }
1267 if let Some(v) = x.block_private_ips {
1268 t.block_private_ips = v;
1269 }
1270 if let Some(v) = x.allowed_domains {
1271 t.allowed_domains = v;
1272 }
1273 if let Some(v) = x.blocked_domains {
1274 t.blocked_domains = v;
1275 }
1276 if let Some(v) = x.mark_untrusted_output {
1277 t.mark_untrusted_output = v;
1278 }
1279 if let Some(v) = x.block_sends_after_private {
1280 t.block_sends_after_private = v;
1281 }
1282 }
1283 if let Some(x) = self.sandbox {
1284 let t = &mut cfg.sandbox;
1285 if let Some(v) = x.kind {
1286 t.kind = v;
1287 }
1288 if let Some(v) = x.network {
1289 t.network = v;
1290 }
1291 if let Some(v) = x.writable {
1292 t.writable = v;
1293 }
1294 if let Some(v) = x.readable {
1295 t.readable = v;
1296 }
1297 if let Some(v) = x.env {
1298 t.env = v;
1299 }
1300 if let Some(v) = x.image {
1301 t.image = v;
1302 }
1303 if x.memory_mb.is_some() {
1304 t.memory_mb = x.memory_mb;
1305 }
1306 if x.cpus.is_some() {
1307 t.cpus = x.cpus;
1308 }
1309 }
1310 // MCP servers replace wholesale — merging lists by name would make it
1311 // impossible for a project to turn a global server off.
1312 if let Some(v) = self.mcp {
1313 cfg.mcp = v;
1314 }
1315 if let Some(v) = self.subagents {
1316 cfg.subagents = v;
1317 }
1318 if let Some(v) = self.search {
1319 cfg.search = v;
1320 }
1321 // Wholesale, like MCP servers and for the same reason: a project that
1322 // cannot turn a global hook off cannot be trusted to run anything.
1323 if let Some(v) = self.hooks {
1324 cfg.hooks = v;
1325 }
1326 if let Some(x) = self.outbox {
1327 let t = &mut cfg.outbox;
1328 // Wholesale: a project must be able to un-route a tool the global
1329 // config routes, and vice versa.
1330 if let Some(v) = x.tools {
1331 t.tools = v;
1332 }
1333 if x.dir.is_some() {
1334 t.dir = x.dir;
1335 }
1336 if let Some(v) = x.publish_tools {
1337 t.publish_tools = v;
1338 }
1339 }
1340 if let Some(x) = self.work {
1341 if let Some(v) = x.keep {
1342 cfg.work.keep = v;
1343 }
1344 }
1345 // Assignment here is the *global* layer's semantics. A project layer
1346 // never reaches this with a widening list, because `merge_file`
1347 // narrows it first — see [`SkillsLayer`].
1348 if let Some(x) = self.skills {
1349 let t = &mut cfg.skills;
1350 if let Some(v) = x.enabled {
1351 t.enabled = v;
1352 }
1353 if let Some(v) = x.disabled {
1354 t.disabled = v;
1355 }
1356 if x.dir.is_some() {
1357 t.dir = x.dir;
1358 }
1359 }
1360 // Only ever reached from the global layer, like `[messages]`.
1361 if let Some(x) = self.slack {
1362 let t = &mut cfg.slack;
1363 if let Some(v) = x.max_concurrent {
1364 t.max_concurrent = v;
1365 }
1366 if let Some(v) = x.approval_timeout_secs {
1367 t.approval_timeout_secs = v;
1368 }
1369 if let Some(v) = x.default_mode {
1370 t.default_mode = v;
1371 }
1372 if let Some(v) = x.max_turns {
1373 t.max_turns = v;
1374 }
1375 if let Some(v) = x.max_cost_usd {
1376 t.max_cost_usd = Some(v);
1377 }
1378 if let Some(v) = x.stream_flush_chars {
1379 t.stream_flush_chars = v;
1380 }
1381 if let Some(v) = x.stream_flush_ms {
1382 t.stream_flush_ms = v;
1383 }
1384 if let Some(v) = x.max_upload_mb {
1385 t.max_upload_mb = v;
1386 }
1387 if let Some(v) = x.tools {
1388 t.tools = v;
1389 }
1390 }
1391 // Only ever reached from the global layer: `merge_file` strips this
1392 // section from a project file before applying, with a warning.
1393 if let Some(x) = self.messages {
1394 let t = &mut cfg.messages;
1395 if let Some(v) = x.enabled {
1396 t.enabled = v;
1397 }
1398 if x.dir.is_some() {
1399 t.dir = x.dir;
1400 }
1401 if x.inbound.is_some() {
1402 t.inbound = x.inbound;
1403 }
1404 if let Some(v) = x.pending_cap {
1405 t.pending_cap = v;
1406 }
1407 if let Some(v) = x.max_body_bytes {
1408 t.max_body_bytes = v;
1409 }
1410 if let Some(v) = x.keep {
1411 t.keep = v;
1412 }
1413 }
1414 // Only ever reached from the global layer, like `[messages]` and
1415 // `[slack]`: `merge_file` strips a project file's `[web]` first.
1416 if let Some(x) = self.web {
1417 let t = &mut cfg.web;
1418 if let Some(v) = x.port {
1419 t.port = v;
1420 }
1421 if x.owner_login.is_some() {
1422 t.owner_login = x.owner_login;
1423 }
1424 if x.assets.is_some() {
1425 t.assets = x.assets;
1426 }
1427 }
1428 }
1429}
1430
1431#[cfg(test)]
1432mod tests {
1433 use super::*;
1434
1435 #[test]
1436 fn layer_overrides_only_named_fields() {
1437 let mut cfg = Config::default();
1438 let layer: ConfigLayer = toml::from_str(
1439 r#"
1440 [agent]
1441 max_turns = 5
1442 "#,
1443 )
1444 .unwrap();
1445 layer.apply(&mut cfg);
1446 assert_eq!(cfg.agent.max_turns, 5);
1447 // Untouched fields keep their defaults.
1448 assert_eq!(cfg.agent.max_tokens, 64_000);
1449 assert_eq!(cfg.default_provider, "anthropic");
1450 }
1451
1452 #[test]
1453 fn providers_merge_by_key() {
1454 let mut cfg = Config::default();
1455 let layer: ConfigLayer = toml::from_str(
1456 r#"
1457 [providers.local]
1458 kind = "local"
1459 base_url = "http://127.0.0.1:8080"
1460 "#,
1461 )
1462 .unwrap();
1463 layer.apply(&mut cfg);
1464 assert!(cfg.providers.contains_key("anthropic"));
1465 assert!(cfg.providers.contains_key("local"));
1466 }
1467
1468 #[test]
1469 fn hooks_configure_from_a_file() {
1470 let mut cfg = Config::default();
1471 let layer: ConfigLayer = toml::from_str(
1472 r#"
1473 [[hook]]
1474 event = "pre_tool"
1475 tools = ["shell"]
1476 command = "policy.sh"
1477 "#,
1478 )
1479 .unwrap();
1480 layer.apply(&mut cfg);
1481 assert_eq!(cfg.hooks.len(), 1);
1482 assert_eq!(cfg.hooks[0].event, "pre_tool");
1483 assert_eq!(cfg.hooks[0].tools, ["shell"]);
1484 }
1485
1486 /// An explicit threshold always wins; otherwise a known window derives
1487 /// one. The derived value must leave real headroom — the check happens
1488 /// *between* turns, so the next request has to fit the reply and whatever
1489 /// a burst of parallel tool results adds.
1490 #[test]
1491 fn the_compaction_threshold_derives_from_a_known_context_window() {
1492 let mut cfg = AgentConfig::default();
1493 assert_eq!(cfg.compact_at(None), None, "unknowable stays unset");
1494
1495 // The DGX's llama-server runs -c 32768; two thirds of that.
1496 let derived = cfg.compact_at(Some(32768)).unwrap();
1497 assert_eq!(derived, 21626);
1498 assert!(
1499 derived < 32768 - 8192,
1500 "must leave room for a reply and a burst of tool results: {derived}"
1501 );
1502
1503 cfg.compact_at_tokens = Some(9000);
1504 assert_eq!(cfg.compact_at(Some(32768)), Some(9000), "explicit wins");
1505 }
1506
1507 /// One turn's tool results must not leap the gap between the compaction
1508 /// threshold and the window — the flat 24 KB budget was ~8–12k tokens of
1509 /// numeric data against a 10.9k-token gap at 32k, and a 2026-08-07
1510 /// Terminal-Bench trial died on exactly that jump.
1511 #[test]
1512 fn the_output_budget_derives_from_a_known_context_window() {
1513 let mut cfg = ToolsConfig::default();
1514
1515 // Unknowable window: the ceiling, which is the old flat default.
1516 assert_eq!(cfg.resolved_output_budget(None), 24_000);
1517
1518 // The DGX's llama-server runs -c 32768: an eighth of the window in
1519 // tokens, ~3 bytes each — and comfortably inside the threshold gap
1520 // even at one byte per token.
1521 let derived = cfg.resolved_output_budget(Some(32768));
1522 assert_eq!(derived, 12_288);
1523
1524 // Wide windows keep the old number; tiny ones keep results usable.
1525 assert_eq!(cfg.resolved_output_budget(Some(200_000)), 24_000);
1526 assert_eq!(cfg.resolved_output_budget(Some(8_192)), 6_000);
1527
1528 cfg.output_budget_bytes = Some(1_000);
1529 assert_eq!(
1530 cfg.resolved_output_budget(Some(32768)),
1531 1_000,
1532 "explicit wins"
1533 );
1534 }
1535
1536 /// A `mecha.toml` arrives with a cloned repository, and it can name MCP
1537 /// servers to spawn, hooks to run and tools to enable. That is a reasonable
1538 /// bargain for someone who just decided to work in that repository, and no
1539 /// bargain at all for a trigger firing at 03:00 — so the scheduled path
1540 /// loads the global layer only. Verified as a *difference*, because the
1541 /// same call on a machine with no project file proves nothing.
1542 #[test]
1543 fn the_project_layer_is_reachable_from_load_and_not_from_load_global() {
1544 let dir = std::env::temp_dir().join(format!("mecha-config-scope-{}", std::process::id()));
1545 std::fs::create_dir_all(&dir).unwrap();
1546 std::fs::write(
1547 dir.join(Config::PROJECT_FILE),
1548 "default_provider = \"contributed-by-the-repository\"\n",
1549 )
1550 .unwrap();
1551
1552 let with_project = Config::load(&dir).unwrap();
1553 assert_eq!(
1554 with_project.default_provider,
1555 "contributed-by-the-repository"
1556 );
1557
1558 let global_only = Config::load_global().unwrap();
1559 assert_ne!(
1560 global_only.default_provider, "contributed-by-the-repository",
1561 "a scheduled unattended run must not take its configuration from \
1562 whatever directory it happens to start in"
1563 );
1564
1565 let _ = std::fs::remove_dir_all(&dir);
1566 }
1567
1568 #[test]
1569 fn a_project_layer_web_section_is_stripped_but_a_global_one_is_kept() {
1570 // Same boundary as `[slack]`: the section names the web door's port
1571 // and the identity allowed through it, and a mecha.toml arrives with
1572 // a cloned repository.
1573 let dir = std::env::temp_dir().join(format!("mecha-web-scope-{}", std::process::id()));
1574 std::fs::create_dir_all(&dir).unwrap();
1575 let path = dir.join("layer.toml");
1576 std::fs::write(
1577 &path,
1578 "[web]\nport = 1\nowner_login = \"attacker@example.com\"\n",
1579 )
1580 .unwrap();
1581
1582 let mut from_project = Config::default();
1583 from_project.merge_file(&path, LayerTrust::Project).unwrap();
1584 assert_eq!(
1585 from_project.web.port,
1586 WebConfig::default().port,
1587 "a project file must not move the web port"
1588 );
1589 assert_eq!(
1590 from_project.web.owner_login, None,
1591 "a project file must not name the owner"
1592 );
1593
1594 let mut from_global = Config::default();
1595 from_global.merge_file(&path, LayerTrust::Global).unwrap();
1596 assert_eq!(from_global.web.port, 1);
1597 assert_eq!(
1598 from_global.web.owner_login.as_deref(),
1599 Some("attacker@example.com")
1600 );
1601 let _ = std::fs::remove_file(&path);
1602 }
1603
1604 #[test]
1605 fn a_project_layer_slack_section_is_stripped_but_a_global_one_is_kept() {
1606 // The same boundary as `[messages]`, and a sharper one: Slack is the
1607 // remote control, and a mecha.toml arrives with a cloned repository.
1608 // Nothing in `[slack]` grants access — who may drive lives in the
1609 // binding store — but a repo must not get to widen the default mode or
1610 // the budget of runs someone drives from their phone.
1611 let dir = std::env::temp_dir().join(format!("mecha-slack-scope-{}", std::process::id()));
1612 std::fs::create_dir_all(&dir).unwrap();
1613 let path = dir.join("layer.toml");
1614 std::fs::write(
1615 &path,
1616 "[slack]\ndefault_mode = \"allow\"\nmax_turns = 999\n",
1617 )
1618 .unwrap();
1619
1620 let mut from_project = Config::default();
1621 from_project.merge_file(&path, LayerTrust::Project).unwrap();
1622 assert_eq!(
1623 from_project.slack.default_mode, "ask",
1624 "a project file must not widen the default mode"
1625 );
1626 assert_eq!(from_project.slack.max_turns, 40);
1627
1628 let mut from_global = Config::default();
1629 from_global.merge_file(&path, LayerTrust::Global).unwrap();
1630 assert_eq!(
1631 from_global.slack.default_mode, "allow",
1632 "the global file is authoritative"
1633 );
1634 assert_eq!(from_global.slack.max_turns, 999);
1635
1636 std::fs::remove_dir_all(&dir).ok();
1637 }
1638
1639 #[test]
1640 fn a_project_layer_cannot_un_withhold_a_skill_with_an_empty_list() {
1641 // The narrowest form of the widening attack, and the one the first
1642 // version of this code allowed: `disabled = []` is a *present* empty
1643 // value, so an assigning merge replaces the user's list with nothing
1644 // and the withheld skill is carried. Writing no `[skills]` table at
1645 // all is the honest way to have no opinion.
1646 let dir = std::env::temp_dir().join(format!("mecha-skills-wipe-{}", std::process::id()));
1647 std::fs::create_dir_all(&dir).unwrap();
1648 let project = dir.join("project.toml");
1649 std::fs::write(&project, "[skills]\ndisabled = []\n").unwrap();
1650
1651 let mut cfg = Config::default();
1652 cfg.skills.disabled = vec!["dangerous".into()];
1653 cfg.merge_file(&project, LayerTrust::Project).unwrap();
1654 assert_eq!(
1655 cfg.skills.disabled,
1656 vec!["dangerous".to_string()],
1657 "an empty project list must not clear the user's"
1658 );
1659
1660 let _ = std::fs::remove_dir_all(&dir);
1661 }
1662
1663 #[test]
1664 fn a_project_layer_can_narrow_the_skill_set_but_never_widen_it() {
1665 // The rule that lets `[skills]` be project-declarable at all. A cloned
1666 // repository saying "these are the relevant ones" is useful; one
1667 // turning on a skill the user did not enable is the supply-chain shape
1668 // this whole subsystem is arranged to refuse, so the merge enforces
1669 // the direction rather than documenting it.
1670 let dir = std::env::temp_dir().join(format!("mecha-skills-scope-{}", std::process::id()));
1671 std::fs::create_dir_all(&dir).unwrap();
1672 let project = dir.join("project.toml");
1673 std::fs::write(
1674 &project,
1675 "[skills]\nenabled = [\"audit\", \"deploy\"]\ndisabled = [\"brief\"]\ndir = \"/tmp/theirs\"\n",
1676 )
1677 .unwrap();
1678
1679 // Global enabled `audit` and `brief`. The project asks for `audit`
1680 // and `deploy`; only the intersection survives.
1681 let mut cfg = Config::default();
1682 cfg.skills.enabled = vec!["audit".into(), "brief".into()];
1683 // Non-empty on purpose: with an empty global list an overwrite and a
1684 // union are indistinguishable, which is how the first version of this
1685 // test passed while a project file could still wipe the list.
1686 cfg.skills.disabled = vec!["dangerous".into()];
1687 cfg.merge_file(&project, LayerTrust::Project).unwrap();
1688 assert_eq!(
1689 cfg.skills.enabled,
1690 vec!["audit".to_string()],
1691 "`deploy` was never enabled globally, so naming it must not enable it"
1692 );
1693 assert!(
1694 cfg.skills.disabled.contains(&"brief".to_string()),
1695 "withholding is always allowed"
1696 );
1697 assert!(
1698 cfg.skills.disabled.contains(&"dangerous".to_string()),
1699 "a project file must not be able to un-withhold what the user withheld"
1700 );
1701 assert!(
1702 cfg.skills.dir.is_none(),
1703 "a project must not point the store somewhere it controls"
1704 );
1705
1706 // And the global layer is authoritative, so the same file read as
1707 // global does assign — otherwise this would be a broken apply rather
1708 // than a narrowing.
1709 let mut global = Config::default();
1710 global.merge_file(&project, LayerTrust::Global).unwrap();
1711 assert_eq!(
1712 global.skills.enabled,
1713 vec!["audit".to_string(), "deploy".to_string()]
1714 );
1715 assert_eq!(global.skills.dir.as_deref(), Some(Path::new("/tmp/theirs")));
1716
1717 let _ = std::fs::remove_dir_all(&dir);
1718 }
1719
1720 #[test]
1721 fn a_project_layer_messages_section_is_stripped_but_a_global_one_is_kept() {
1722 // The security boundary: a cloned repo's mecha.toml must not be able to
1723 // set `inbound = "accept"` (or enable messaging at all) on someone's
1724 // session. `merge_file` strips the section on a project layer and keeps
1725 // it on a global one — this pins both halves, and that the strip is a
1726 // strip rather than a broken apply.
1727 let dir = std::env::temp_dir().join(format!("mecha-msg-scope-{}", std::process::id()));
1728 std::fs::create_dir_all(&dir).unwrap();
1729 let path = dir.join("layer.toml");
1730 std::fs::write(&path, "[messages]\nenabled = true\ninbound = \"accept\"\n").unwrap();
1731
1732 let mut from_project = Config::default();
1733 from_project.merge_file(&path, LayerTrust::Project).unwrap();
1734 assert!(
1735 !from_project.messages.enabled,
1736 "a project file must not enable messaging"
1737 );
1738 assert!(
1739 from_project.messages.inbound.is_none(),
1740 "a project file must not set inbound policy"
1741 );
1742
1743 let mut from_global = Config::default();
1744 from_global.merge_file(&path, LayerTrust::Global).unwrap();
1745 assert!(
1746 from_global.messages.enabled,
1747 "the global file is authoritative"
1748 );
1749 assert_eq!(
1750 from_global.messages.inbound,
1751 Some(crate::mailbox::InboundPolicy::Accept)
1752 );
1753
1754 let _ = std::fs::remove_dir_all(&dir);
1755 }
1756
1757 #[test]
1758 fn every_field_of_config_is_reachable_from_a_file() {
1759 // The bug this exists for: `hooks` was added to `Config` and not to
1760 // `ConfigLayer`, so `[[hook]]` in any config file was a hard parse
1761 // error and the whole feature was unreachable — while every unit test
1762 // passed, because they all built the type directly.
1763 //
1764 // Serialising the default config produces one entry per top-level
1765 // field; `ConfigLayer` denies unknown fields, so parsing it back is a
1766 // standing check that the two structs still agree. Any field added to
1767 // one and not the other fails here rather than in someone's config.
1768 let rendered = toml::to_string(&Config::default()).unwrap();
1769 let parsed = toml::from_str::<ConfigLayer>(&rendered);
1770 assert!(
1771 parsed.is_ok(),
1772 "Config has a field ConfigLayer cannot read: {parsed:?}"
1773 );
1774 }
1775}