Skip to main content

kernel/
config.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! Three-layer config resolution and the frozen/live split. `FrozenConfig` and `LiveConfig` share no field: the freeze
7//! line is a machine-checkable property, not a review note.
8//! The part `crates/kernel/spec/Config.lean` specifies this module.
9
10use serde::{Deserialize, Serialize};
11
12use crate::address::Address;
13use crate::consts_policy::CLOCK_STAMP_DEFAULT;
14use crate::error::{AxCode, AxError};
15use crate::model::Effort;
16use crate::tool::ServerLabel;
17
18mod interpreter;
19pub use interpreter::Interpreter;
20
21/// One environment variable name a scope declares its runs may inherit.
22///
23/// A `String` would put the judgement in whichever caller remembered it.
24/// This has one constructor, and that constructor is reached where a
25/// configuration file is read — which is the only moment the refusal is
26/// still worth anything, because a name handed to a child process is a
27/// name the child cannot be asked to forget.
28#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
29#[serde(try_from = "String", into = "String")]
30#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
31pub struct EnvVarName(String);
32
33impl EnvVarName {
34    /// Sole constructor.
35    ///
36    /// # Errors
37    /// Refuses an empty name, a name carrying the `=` that separates a
38    /// name from a value, a name carrying a control character, and a
39    /// name [`crate::secret::names_a_credential`] reads as a credential.
40    pub fn parse(raw: &str) -> Result<EnvVarName, AxError> {
41        let reject = |violation: &str| {
42            Err(AxError::failure(
43                AxCode::InvalidArgs,
44                "declare an environment variable",
45                raw.to_owned(),
46            )
47            .with_recovery(format!(
48                "{violation}; declare only the names a build needs, and keep a credential in \
49                 the vault as a `secret:realm/name` reference"
50            )))
51        };
52        if raw.is_empty() {
53            return reject("the name is empty");
54        }
55        if raw.contains('=') {
56            return reject("the name carries `=`, which separates a name from its value");
57        }
58        if raw.chars().any(char::is_control) {
59            return reject("the name carries a control character");
60        }
61        if crate::secret::names_a_credential(raw) {
62            return reject("the name reads as a credential");
63        }
64        Ok(EnvVarName(raw.to_owned()))
65    }
66
67    #[must_use]
68    pub fn as_str(&self) -> &str {
69        &self.0
70    }
71}
72
73impl std::fmt::Display for EnvVarName {
74    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75        f.write_str(&self.0)
76    }
77}
78
79impl TryFrom<String> for EnvVarName {
80    type Error = AxError;
81
82    fn try_from(raw: String) -> Result<EnvVarName, AxError> {
83        EnvVarName::parse(&raw)
84    }
85}
86
87impl From<EnvVarName> for String {
88    fn from(name: EnvVarName) -> String {
89        name.0
90    }
91}
92
93/// Clock-stamp cadence for result envelopes. The
94/// granularity rides the enum; `Off` costs zero bytes in the window.
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(rename_all = "snake_case")]
97pub enum ClockStampGranularity {
98    Off,
99    Minute,
100    FiveMinute,
101    Hour,
102}
103
104/// Where the context reminder's second rung sits: a whole percent of the
105/// window, `31` through `90` (the three numbers live in `consts_policy`).
106///
107/// One construction point, [`SecondThreshold::parse`], so a rung the city
108/// refuses cannot be spelled, and its refusal carries the domain: that is
109/// the only sentence a person editing a `CONFIG.toml` gets. Why the domain
110/// is bounded is `consts_policy::INTERVAL_CAP_BYTES`'s derivation.
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
112#[serde(try_from = "u64", into = "u64")]
113pub struct SecondThreshold(u64);
114
115impl SecondThreshold {
116    /// Sole constructor. `raw` is a whole percent of the window.
117    ///
118    /// # Errors
119    /// `E_INVALID_ARGS`, naming the value and the domain it is outside,
120    /// when `raw` is not `31..=90`. Never clamped: a file that states 30
121    /// means something its writer has to be told is not accepted.
122    pub fn parse(raw: u64) -> Result<SecondThreshold, AxError> {
123        let floor = crate::consts_policy::CTX_REMINDER_SECOND_MIN;
124        let ceiling = crate::consts_policy::CTX_REMINDER_SECOND_MAX;
125        if (floor..=ceiling).contains(&raw) {
126            return Ok(SecondThreshold(raw));
127        }
128        Err(AxError::failure(
129            AxCode::InvalidArgs,
130            "set the second context-reminder threshold",
131            raw.to_string(),
132        )
133        .with_recovery(format!(
134            "the legal domain is {floor} through {ceiling} percent of the window; state a whole \
135             percent in it, or leave the key out for {}",
136            crate::consts_policy::CTX_REMINDER_SECOND_DEFAULT
137        )))
138    }
139
140    /// The rung, as a whole percent of the window.
141    #[must_use]
142    pub fn percent(self) -> u64 {
143        self.0
144    }
145}
146
147impl TryFrom<u64> for SecondThreshold {
148    type Error = AxError;
149
150    fn try_from(raw: u64) -> Result<SecondThreshold, AxError> {
151        SecondThreshold::parse(raw)
152    }
153}
154
155impl From<SecondThreshold> for u64 {
156    fn from(rung: SecondThreshold) -> u64 {
157        rung.0
158    }
159}
160
161/// One value across the City -> Building -> Resident override ladder.
162/// Lower layers win; absence falls through upward.
163#[derive(Debug, Clone, PartialEq, Eq)]
164pub struct LayeredValue<T> {
165    pub city: Option<T>,
166    pub building: Option<T>,
167    pub resident: Option<T>,
168}
169
170// Manual impl: the derive would demand `T: Default`, an undesired bound —
171// an all-None ladder is a valid default for any T.
172impl<T> Default for LayeredValue<T> {
173    fn default() -> Self {
174        LayeredValue {
175            city: None,
176            building: None,
177            resident: None,
178        }
179    }
180}
181
182impl<T> LayeredValue<T> {
183    pub fn resolve(&self) -> Option<&T> {
184        self.resident
185            .as_ref()
186            .or(self.building.as_ref())
187            .or(self.city.as_ref())
188    }
189}
190
191/// One concern timezone: an already-resolved offset, never a tz name —
192/// re-resolving names against a moving tz database would fork replayed
193/// history.
194#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
195pub struct ClockZone {
196    pub id: String,
197    pub offset_min: i32,
198}
199
200/// What a run's execution boundary allows. Resolved as one value rather
201/// than field by field: a layer that speaks about the sandbox speaks
202/// about all of it, so an under-specified layer can only ever reduce
203/// what a run may do, never silently grant something the layer above it
204/// never mentioned.
205///
206/// Host facts are deliberately absent — where the Python artifact lives
207/// and which shell binary exists belong to the machine, not to the city,
208/// and a city carried to another machine must not carry its paths.
209#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
210#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
211pub struct SandboxLimits {
212    /// Whether the shell arm may be offered at all. Off by default: a
213    /// shell line is the one arm whose reach cannot be read off its
214    /// arguments.
215    pub shell: bool,
216    /// Which interpreter the shell arm runs a line under: a name, never a path.
217    #[serde(default)]
218    pub interpreter: Interpreter,
219    /// Instruction budget for one sandboxed call.
220    pub fuel: u64,
221    /// Extra readable paths, relative to the city root. The write domain
222    /// is decided elsewhere; this only widens what may be read.
223    pub mounts: Vec<Address>,
224    /// Environment variable names a child process started by this scope
225    /// may inherit, on top of the four every run gets.
226    ///
227    /// Declared name by name rather than widened once for everybody: a
228    /// build chain that needs to find its linker needs a handful of
229    /// names this machine happens to set, and a longer built-in list
230    /// would hand every run in every city whatever those names hold.
231    #[serde(default)]
232    pub env_passthrough: Vec<EnvVarName>,
233    /// Which connector servers this floor lets reach effects nothing
234    /// in the city can take back — today, this machine's own desktop.
235    ///
236    /// Named server by server, and empty by default, for the reason
237    /// `env_passthrough` is: a run that presses keys on somebody's
238    /// keyboard is doing something no checkpoint puts back, so the floor
239    /// says which connector may, in the one file a person edits.
240    #[serde(default)]
241    pub trusted: Vec<ServerLabel>,
242}
243
244impl SandboxLimits {
245    /// Whether this floor lets `label` reach what nothing here can
246    /// undo. The one reader of the trusted list, so the answer cannot
247    /// be spelled two ways.
248    #[must_use]
249    pub fn trusts(&self, label: &ServerLabel) -> bool {
250        self.trusted.iter().any(|allowed| allowed == label)
251    }
252}
253
254impl Default for SandboxLimits {
255    fn default() -> Self {
256        SandboxLimits {
257            shell: false,
258            interpreter: Interpreter::System,
259            fuel: crate::consts_policy::SANDBOX_FUEL_DEFAULT,
260            mounts: Vec::new(),
261            env_passthrough: Vec::new(),
262            trusted: Vec::new(),
263        }
264    }
265}
266
267/// One external tool server this run may reach, as its configuration
268/// states it: a label, a program on this machine, and that program's
269/// arguments.
270///
271/// The command and its arguments are host facts, for the same reason
272/// the sandbox keeps them out: a city carried to another machine must
273/// not carry this machine's paths inside its history. They live in
274/// `CONFIG.toml` and never in a ledger payload.
275#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
276#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
277pub struct McpServer {
278    pub label: ServerLabel,
279    pub transport: McpTransport,
280}
281
282/// How this city reaches one server. Exhaustive rather than a URL that
283/// might also be a command: the three are different machines to start
284/// talking to, they fail differently, and a configuration that leaves it
285/// to be guessed is one that guesses wrong on the day it matters.
286///
287/// Closed rather than `#[non_exhaustive]`: every reader of this enum is
288/// inside this one binary, so a wildcard arm here would buy nothing and
289/// would hide the next transport from the three modules that must
290/// decide about it.
291#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
292#[serde(rename_all = "snake_case")]
293#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
294pub enum McpTransport {
295    /// A program on this machine, spoken to over its own pipes.
296    Stdio {
297        command: String,
298        args: Vec<String>,
299        /// What the child is started with, name before value, in the
300        /// order the configuration lists them.
301        ///
302        /// A value may be a `secret:realm/name` reference, which is
303        /// redeemed at the moment the process is started and never
304        /// written into the configuration: an API key a server needs is
305        /// still a credential, and `claude mcp add -e KEY=value` is the
306        /// place people are used to putting one.
307        env: Vec<(String, String)>,
308    },
309    /// A server reached over HTTP, which is how a hosted catalogue is
310    /// published. The city holds no account with it: whatever it needs
311    /// travels in the headers the configuration names.
312    Http {
313        url: String,
314        /// Headers to send, name before value. A value may be a
315        /// `secret:realm/name` reference, redeemed at the last slot
316        /// before the wire.
317        ///
318        /// A list rather than one header, because a hosted server
319        /// commonly wants an account header beside its key, and a
320        /// configuration that can spell only one of the two describes a
321        /// server nobody can reach.
322        headers: Vec<(String, String)>,
323    },
324    /// A server that answers on a stream of server-sent events: the
325    /// stream is opened first and names where messages are posted, and
326    /// every answer comes back down the stream rather than in the body
327    /// of the request that asked.
328    ///
329    /// Its own variant rather than a flag on [`McpTransport::Http`],
330    /// because the two open differently and fail differently: an HTTP
331    /// server refuses a request, and this one can accept every request
332    /// and answer none.
333    Sse {
334        url: String,
335        /// As [`McpTransport::Http`], and sent on both the stream and
336        /// the posts.
337        headers: Vec<(String, String)>,
338    },
339}
340
341/// Frozen at Run start, never re-read within the Run.
342/// Fields only grow.
343#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
344pub struct FrozenConfig {
345    pub clock_stamp: ClockStampGranularity,
346    pub clock_zones: Vec<ClockZone>,
347    /// Frozen for the same reason the tool table is: what a run may
348    /// reach decides what it was told it could do, and a capability that
349    /// widens mid-run is one nobody reviewed.
350    pub sandbox: SandboxLimits,
351    /// External tool servers, frozen for the same reason as the tool
352    /// table itself: their tools enter the catalog at run start, a
353    /// provider hashes that array ahead of the system prompt, and a
354    /// table that widened mid-run would both discard the cache and
355    /// hand the model a capability nobody reviewed.
356    pub mcp: Vec<McpServer>,
357    /// How hard the model may think. Frozen rather than live because the
358    /// provider renders it into the cached prompt prefix: changing the
359    /// effort value mid-run invalidates the message cache breakpoints,
360    /// so a run that could retune itself would keep paying to rebuild
361    /// the cache it just discarded. A change applies to the next run.
362    pub effort: Option<Effort>,
363    /// Where the context reminder's second rung sits. `None` when no
364    /// layer states one: the rung falls back to
365    /// `consts_policy::CTX_REMINDER_SECOND_DEFAULT` where it is read.
366    /// Frozen with the run: a rung that moved mid-run would make "each
367    /// rung sounds once per run" depend on when somebody edited a file.
368    pub second_threshold: Option<SecondThreshold>,
369}
370
371/// Hot-reloadable surface. Empty: the type exists so the no-field-overlap
372/// assertion guards every field added to it.
373#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
374pub struct LiveConfig {}
375
376/// Resolves the ladder into the Run-start snapshot. Absence everywhere
377/// falls back to the policy default; the zones ladder overrides as a
378/// whole list (a building that writes zones replaces the city list).
379pub fn freeze(
380    clock_stamp: &LayeredValue<ClockStampGranularity>,
381    clock_zones: &LayeredValue<Vec<ClockZone>>,
382    effort: &LayeredValue<Effort>,
383    sandbox: &LayeredValue<SandboxLimits>,
384    mcp: &LayeredValue<Vec<McpServer>>,
385    second_threshold: &LayeredValue<SecondThreshold>,
386) -> FrozenConfig {
387    FrozenConfig {
388        clock_stamp: *clock_stamp.resolve().unwrap_or(&CLOCK_STAMP_DEFAULT),
389        clock_zones: clock_zones.resolve().cloned().unwrap_or_default(),
390        sandbox: sandbox.resolve().cloned().unwrap_or_default(),
391        effort: effort.resolve().copied(),
392        // A layer that speaks about servers speaks about all of them:
393        // an unstated layer reaches nothing rather than inheriting a
394        // reach nobody at that layer wrote down.
395        mcp: mcp.resolve().cloned().unwrap_or_default(),
396        second_threshold: second_threshold.resolve().copied(),
397    }
398}
399
400#[cfg(test)]
401#[allow(
402    clippy::unwrap_used,
403    clippy::expect_used,
404    clippy::panic,
405    clippy::indexing_slicing,
406    reason = "test code"
407)]
408mod tests;