use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ProtectedComponent {
Gates,
Limits,
Recovery,
Protected,
Approvals,
Credentials,
Audit,
Baselines,
Retention,
Environment,
}
impl ProtectedComponent {
pub fn paths(self) -> &'static [&'static str] {
match self {
ProtectedComponent::Gates => &["safety.gates"],
ProtectedComponent::Limits => &["safety.limits"],
ProtectedComponent::Recovery => &["safety.recovery"],
ProtectedComponent::Protected => &["safety.protected"],
ProtectedComponent::Approvals => &[
"safety.limits.global.human_checkpoint",
"safety.limits.per_node",
"safety.gates.approval",
],
ProtectedComponent::Credentials => {
&["execution.providers.providers.requires_env", "secrets"]
}
ProtectedComponent::Audit => &["safety.alerts"],
ProtectedComponent::Baselines => &["evolution.baseline"],
ProtectedComponent::Retention => &["execution.memory.namespaces"],
ProtectedComponent::Environment => &["environment", "features"],
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Protected {
#[serde(default = "default_components")]
pub components: Vec<ProtectedComponent>,
#[serde(default)]
pub extra_paths: Vec<String>,
}
fn default_components() -> Vec<ProtectedComponent> {
use ProtectedComponent::*;
vec![
Gates,
Limits,
Recovery,
Protected,
Approvals,
Credentials,
Audit,
Baselines,
Retention,
Environment,
]
}
impl Default for Protected {
fn default() -> Self {
Self {
components: default_components(),
extra_paths: Vec::new(),
}
}
}
impl Protected {
pub fn is_protected(&self, path: &str) -> bool {
if path == "safety.protected" || path.starts_with("safety.protected.") {
return true;
}
let covered = self
.components
.iter()
.flat_map(|c| c.paths().iter())
.copied()
.chain(self.extra_paths.iter().map(String::as_str));
covered.into_iter().any(|p| under(path, p))
}
pub fn touches(&self, path: &str) -> bool {
self.is_protected(path) || self.paths().iter().any(|p| under(p, path))
}
pub fn paths(&self) -> Vec<String> {
let mut out: Vec<String> = self
.components
.iter()
.flat_map(|c| c.paths().iter().map(|p| p.to_string()))
.chain(self.extra_paths.iter().cloned())
.collect();
out.push("safety.protected".into());
out.sort();
out.dedup();
out
}
}
fn under(path: &str, prefix: &str) -> bool {
path == prefix
|| (path.len() > prefix.len()
&& path.starts_with(prefix)
&& path.as_bytes()[prefix.len()] == b'.')
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_protected_list_protects_itself_even_if_removed_from_its_own_list() {
let p = Protected {
components: vec![],
extra_paths: vec![],
};
assert!(p.is_protected("safety.protected"));
assert!(p.is_protected("safety.protected.components"));
}
#[test]
fn a_sibling_sharing_a_prefix_is_not_protected_by_accident() {
let p = Protected::default();
assert!(p.is_protected("safety.limits"));
assert!(p.is_protected("safety.limits.global.rules"));
assert!(!p.is_protected("safety.limits_extra"));
}
#[test]
fn replacing_a_parent_touches_the_protected_children_beneath_it() {
let p = Protected::default();
assert!(p.touches("safety"), "all of safety includes its gates");
assert!(p.touches("safety.gates.stop.max_iterations"));
assert!(!p.touches("safety.checks"), "checks are not protected");
assert!(!p.touches("execution.skills.explore"));
}
#[test]
fn an_unlisted_section_stays_editable() {
let p = Protected::default();
assert!(!p.is_protected("intent.goals"));
assert!(!p.is_protected("execution.graph.nodes"));
}
}