loopsmith_core/config/
protected.rs1use schemars::JsonSchema;
13use serde::{Deserialize, Serialize};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
17#[serde(rename_all = "snake_case")]
18pub enum ProtectedComponent {
19 Gates,
21 Limits,
23 Recovery,
25 Protected,
27 Approvals,
29 Credentials,
31 Audit,
33 Baselines,
36 Retention,
38 Environment,
40}
41
42impl ProtectedComponent {
43 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#[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 #[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 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 pub fn touches(&self, path: &str) -> bool {
128 self.is_protected(path) || self.paths().iter().any(|p| under(p, path))
129 }
130
131 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
146fn 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}