Skip to main content

loopsmith_core/config/
protected.rs

1//! What the loop may never change about itself.
2//!
3//! Self-evolution is only safe if the thing being evolved cannot reach the
4//! machinery that constrains it. A loop that may rewrite its own stop gates has
5//! no stop gates; a loop that may rewrite its own permission grant has no
6//! permissions. The list here is the fixed point of that argument — it is
7//! checked by the gate, which is compiled code the loop cannot dispatch to.
8//!
9//! The defaults are deliberately the whole reference list. Removing an entry is
10//! possible but is exactly the kind of edit that should be visible in a diff.
11
12use schemars::JsonSchema;
13use serde::{Deserialize, Serialize};
14
15/// A part of the configuration that evolution proposals may not touch.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
17#[serde(rename_all = "snake_case")]
18pub enum ProtectedComponent {
19    /// `safety.gates` — the layered exits.
20    Gates,
21    /// `safety.limits` — forbidden paths and commands, budget ceilings.
22    Limits,
23    /// `safety.recovery` — how failures are answered.
24    Recovery,
25    /// This list itself. Always protected; see [`Protected::is_protected`].
26    Protected,
27    /// Human checkpoints and approval requirements.
28    Approvals,
29    /// Credential and secret configuration.
30    Credentials,
31    /// The ledger, what is recorded in it, and the alerts that watch it.
32    Audit,
33    /// `evolution.baseline` — what a proposal is measured against. A loop that
34    /// can move its own baseline can declare any change an improvement.
35    Baselines,
36    /// Memory retention and expiry policy.
37    Retention,
38    /// `environment` and `features`.
39    Environment,
40}
41
42impl ProtectedComponent {
43    /// The config paths this component covers, as dotted keys.
44    pub fn paths(self) -> &'static [&'static str] {
45        match self {
46            ProtectedComponent::Gates => &["safety.gates"],
47            ProtectedComponent::Limits => &["safety.limits"],
48            ProtectedComponent::Recovery => &["safety.recovery"],
49            ProtectedComponent::Protected => &["safety.protected"],
50            ProtectedComponent::Approvals => &[
51                "safety.limits.global.human_checkpoint",
52                "safety.limits.per_node",
53                "safety.gates.approval",
54            ],
55            ProtectedComponent::Credentials => {
56                &["execution.providers.providers.requires_env", "secrets"]
57            }
58            ProtectedComponent::Audit => &["safety.alerts"],
59            ProtectedComponent::Baselines => &["evolution.baseline"],
60            ProtectedComponent::Retention => &["execution.memory.namespaces"],
61            ProtectedComponent::Environment => &["environment", "features"],
62        }
63    }
64}
65
66/// The set of components evolution may not propose changes to.
67#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
68#[serde(deny_unknown_fields)]
69pub struct Protected {
70    #[serde(default = "default_components")]
71    pub components: Vec<ProtectedComponent>,
72    /// Extra dotted config paths to protect beyond the named components, for
73    /// anything this enum does not anticipate.
74    #[serde(default)]
75    pub extra_paths: Vec<String>,
76}
77
78fn default_components() -> Vec<ProtectedComponent> {
79    use ProtectedComponent::*;
80    vec![
81        Gates,
82        Limits,
83        Recovery,
84        Protected,
85        Approvals,
86        Credentials,
87        Audit,
88        Baselines,
89        Retention,
90        Environment,
91    ]
92}
93
94impl Default for Protected {
95    fn default() -> Self {
96        Self {
97            components: default_components(),
98            extra_paths: Vec::new(),
99        }
100    }
101}
102
103impl Protected {
104    /// Whether a dotted config path is off limits to evolution.
105    ///
106    /// `safety.protected` is hard-coded rather than looked up: a config that
107    /// dropped `Protected` from its own component list would otherwise be free
108    /// to propose putting everything else back, which defeats the section.
109    pub fn is_protected(&self, path: &str) -> bool {
110        if path == "safety.protected" || path.starts_with("safety.protected.") {
111            return true;
112        }
113        let covered = self
114            .components
115            .iter()
116            .flat_map(|c| c.paths().iter())
117            .copied()
118            .chain(self.extra_paths.iter().map(String::as_str));
119        covered.into_iter().any(|p| under(path, p))
120    }
121
122    /// Whether writing `path` would change anything protected: the path is
123    /// protected itself, or a protected path sits beneath it.
124    ///
125    /// The second half is the one that matters for a patch. Replacing all of
126    /// `safety` touches no *protected* key by name, and overwrites every one.
127    pub fn touches(&self, path: &str) -> bool {
128        self.is_protected(path) || self.paths().iter().any(|p| under(p, path))
129    }
130
131    /// Every protected path, for reporting.
132    pub fn paths(&self) -> Vec<String> {
133        let mut out: Vec<String> = self
134            .components
135            .iter()
136            .flat_map(|c| c.paths().iter().map(|p| p.to_string()))
137            .chain(self.extra_paths.iter().cloned())
138            .collect();
139        out.push("safety.protected".into());
140        out.sort();
141        out.dedup();
142        out
143    }
144}
145
146/// Whether `path` is `prefix` or sits beneath it.
147///
148/// The segment check matters: without it `safety.limits_extra` would be judged
149/// protected by the prefix `safety.limits`, silently freezing a section the
150/// author never listed.
151fn under(path: &str, prefix: &str) -> bool {
152    path == prefix
153        || (path.len() > prefix.len()
154            && path.starts_with(prefix)
155            && path.as_bytes()[prefix.len()] == b'.')
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    #[test]
163    fn the_protected_list_protects_itself_even_if_removed_from_its_own_list() {
164        let p = Protected {
165            components: vec![],
166            extra_paths: vec![],
167        };
168        assert!(p.is_protected("safety.protected"));
169        assert!(p.is_protected("safety.protected.components"));
170    }
171
172    #[test]
173    fn a_sibling_sharing_a_prefix_is_not_protected_by_accident() {
174        let p = Protected::default();
175        assert!(p.is_protected("safety.limits"));
176        assert!(p.is_protected("safety.limits.global.rules"));
177        assert!(!p.is_protected("safety.limits_extra"));
178    }
179
180    #[test]
181    fn replacing_a_parent_touches_the_protected_children_beneath_it() {
182        let p = Protected::default();
183        assert!(p.touches("safety"), "all of safety includes its gates");
184        assert!(p.touches("safety.gates.stop.max_iterations"));
185        assert!(!p.touches("safety.checks"), "checks are not protected");
186        assert!(!p.touches("execution.skills.explore"));
187    }
188
189    #[test]
190    fn an_unlisted_section_stays_editable() {
191        let p = Protected::default();
192        assert!(!p.is_protected("intent.goals"));
193        assert!(!p.is_protected("execution.graph.nodes"));
194    }
195}