Skip to main content

zeroclaw_runtime/security/
estop.rs

1use crate::security::domain_matcher::DomainMatcher;
2use crate::security::otp::OtpValidator;
3use anyhow::{Context, Result};
4use serde::{Deserialize, Serialize};
5use std::fs;
6use std::path::{Path, PathBuf};
7use std::time::{SystemTime, UNIX_EPOCH};
8use zeroclaw_config::schema::EstopConfig;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum EstopLevel {
12    KillAll,
13    NetworkKill,
14    DomainBlock(Vec<String>),
15    ToolFreeze(Vec<String>),
16}
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum ResumeSelector {
20    KillAll,
21    Network,
22    Domains(Vec<String>),
23    Tools(Vec<String>),
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
27pub struct EstopState {
28    #[serde(default)]
29    pub kill_all: bool,
30    #[serde(default)]
31    pub network_kill: bool,
32    #[serde(default)]
33    pub blocked_domains: Vec<String>,
34    #[serde(default)]
35    pub frozen_tools: Vec<String>,
36    #[serde(default)]
37    pub updated_at: Option<String>,
38}
39
40impl EstopState {
41    pub fn fail_closed() -> Self {
42        Self {
43            kill_all: true,
44            network_kill: false,
45            blocked_domains: Vec::new(),
46            frozen_tools: Vec::new(),
47            updated_at: Some(now_rfc3339()),
48        }
49    }
50
51    pub fn is_engaged(&self) -> bool {
52        self.kill_all
53            || self.network_kill
54            || !self.blocked_domains.is_empty()
55            || !self.frozen_tools.is_empty()
56    }
57
58    fn normalize(&mut self) {
59        self.blocked_domains = dedup_sort(&self.blocked_domains);
60        self.frozen_tools = dedup_sort(&self.frozen_tools);
61    }
62}
63
64#[derive(Debug, Clone)]
65pub struct EstopManager {
66    config: EstopConfig,
67    state_path: PathBuf,
68    state: EstopState,
69}
70
71impl EstopManager {
72    pub fn load(config: &EstopConfig, config_dir: &Path) -> Result<Self> {
73        let state_path = resolve_state_file_path(config_dir, &config.state_file);
74        let mut should_fail_closed = false;
75        let mut state = if state_path.exists() {
76            match fs::read_to_string(&state_path) {
77                Ok(raw) => match serde_json::from_str::<EstopState>(&raw) {
78                    Ok(mut parsed) => {
79                        parsed.normalize();
80                        parsed
81                    }
82                    Err(error) => {
83                        ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"path": state_path.display().to_string(), "error": format!("{}", error)})), "Failed to parse estop state file; entering fail-closed mode: ");
84                        should_fail_closed = true;
85                        EstopState::fail_closed()
86                    }
87                },
88                Err(error) => {
89                    ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"path": state_path.display().to_string(), "error": format!("{}", error)})), "Failed to read estop state file; entering fail-closed mode: ");
90                    should_fail_closed = true;
91                    EstopState::fail_closed()
92                }
93            }
94        } else {
95            EstopState::default()
96        };
97
98        state.normalize();
99
100        let mut manager = Self {
101            config: config.clone(),
102            state_path,
103            state,
104        };
105
106        if should_fail_closed {
107            let _ = manager.persist_state();
108        }
109
110        Ok(manager)
111    }
112
113    pub fn state_path(&self) -> &Path {
114        &self.state_path
115    }
116
117    pub fn status(&self) -> EstopState {
118        self.state.clone()
119    }
120
121    pub fn engage(&mut self, level: EstopLevel) -> Result<()> {
122        match level {
123            EstopLevel::KillAll => {
124                self.state.kill_all = true;
125            }
126            EstopLevel::NetworkKill => {
127                self.state.network_kill = true;
128            }
129            EstopLevel::DomainBlock(domains) => {
130                for domain in domains {
131                    let normalized = domain.trim().to_ascii_lowercase();
132                    DomainMatcher::validate_pattern(&normalized)?;
133                    self.state.blocked_domains.push(normalized);
134                }
135            }
136            EstopLevel::ToolFreeze(tools) => {
137                for tool in tools {
138                    let normalized = normalize_tool_name(&tool)?;
139                    self.state.frozen_tools.push(normalized);
140                }
141            }
142        }
143
144        self.state.updated_at = Some(now_rfc3339());
145        self.state.normalize();
146        self.persist_state()
147    }
148
149    pub fn resume(
150        &mut self,
151        selector: ResumeSelector,
152        otp_code: Option<&str>,
153        otp_validator: Option<&OtpValidator>,
154    ) -> Result<()> {
155        self.ensure_resume_is_authorized(otp_code, otp_validator)?;
156
157        match selector {
158            ResumeSelector::KillAll => {
159                self.state.kill_all = false;
160            }
161            ResumeSelector::Network => {
162                self.state.network_kill = false;
163            }
164            ResumeSelector::Domains(domains) => {
165                let normalized = domains
166                    .iter()
167                    .map(|domain| domain.trim().to_ascii_lowercase())
168                    .collect::<Vec<_>>();
169                self.state
170                    .blocked_domains
171                    .retain(|existing| !normalized.iter().any(|target| target == existing));
172            }
173            ResumeSelector::Tools(tools) => {
174                let normalized = tools
175                    .iter()
176                    .map(|tool| normalize_tool_name(tool))
177                    .collect::<Result<Vec<_>>>()?;
178                self.state
179                    .frozen_tools
180                    .retain(|existing| !normalized.iter().any(|target| target == existing));
181            }
182        }
183
184        self.state.updated_at = Some(now_rfc3339());
185        self.state.normalize();
186        self.persist_state()
187    }
188
189    fn ensure_resume_is_authorized(
190        &self,
191        otp_code: Option<&str>,
192        otp_validator: Option<&OtpValidator>,
193    ) -> Result<()> {
194        if !self.config.require_otp_to_resume {
195            return Ok(());
196        }
197
198        let code = otp_code
199            .map(str::trim)
200            .filter(|value| !value.is_empty())
201            .context("OTP code is required to resume estop state")?;
202        let validator = otp_validator
203            .context("OTP validator is required to resume estop state with OTP enabled")?;
204        let valid = validator.validate(code)?;
205        if !valid {
206            anyhow::bail!("Invalid OTP code; estop resume denied");
207        }
208        Ok(())
209    }
210
211    fn persist_state(&mut self) -> Result<()> {
212        if let Some(parent) = self.state_path.parent() {
213            fs::create_dir_all(parent).with_context(|| {
214                format!(
215                    "Failed to create estop state dir {}",
216                    parent.display().to_string()
217                )
218            })?;
219        }
220
221        let body =
222            serde_json::to_string_pretty(&self.state).context("Failed to serialize estop state")?;
223
224        let temp_path = self
225            .state_path
226            .with_extension(format!("tmp-{}", uuid::Uuid::new_v4()));
227        fs::write(&temp_path, body).with_context(|| {
228            format!(
229                "Failed to write temporary estop state file {}",
230                temp_path.display()
231            )
232        })?;
233
234        #[cfg(unix)]
235        {
236            use std::os::unix::fs::PermissionsExt;
237            let _ = fs::set_permissions(&temp_path, fs::Permissions::from_mode(0o600));
238        }
239
240        fs::rename(&temp_path, &self.state_path).with_context(|| {
241            format!(
242                "Failed to atomically replace estop state file {}",
243                self.state_path.display()
244            )
245        })?;
246
247        Ok(())
248    }
249}
250
251pub fn resolve_state_file_path(config_dir: &Path, state_file: &str) -> PathBuf {
252    let expanded = shellexpand::tilde(state_file).into_owned();
253    let path = PathBuf::from(expanded);
254    if path.is_absolute() {
255        path
256    } else {
257        config_dir.join(path)
258    }
259}
260
261fn normalize_tool_name(raw: &str) -> Result<String> {
262    let value = raw.trim().to_ascii_lowercase();
263    if value.is_empty() {
264        anyhow::bail!("Tool name must not be empty");
265    }
266    if !value
267        .chars()
268        .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')
269    {
270        anyhow::bail!("Tool name '{raw}' contains invalid characters");
271    }
272    Ok(value)
273}
274
275fn dedup_sort(values: &[String]) -> Vec<String> {
276    let mut deduped = values
277        .iter()
278        .map(|value| value.trim())
279        .filter(|value| !value.is_empty())
280        .map(ToString::to_string)
281        .collect::<Vec<_>>();
282    deduped.sort_unstable();
283    deduped.dedup();
284    deduped
285}
286
287fn now_rfc3339() -> String {
288    let secs = SystemTime::now()
289        .duration_since(UNIX_EPOCH)
290        .map(|duration| duration.as_secs())
291        .unwrap_or(0);
292    chrono::DateTime::<chrono::Utc>::from_timestamp(secs as i64, 0)
293        .unwrap_or(chrono::DateTime::<chrono::Utc>::UNIX_EPOCH)
294        .to_rfc3339()
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300    use crate::security::SecretStore;
301    use crate::security::otp::OtpValidator;
302    use tempfile::tempdir;
303    use zeroclaw_config::schema::OtpConfig;
304
305    fn estop_config(path: &Path) -> EstopConfig {
306        EstopConfig {
307            enabled: true,
308            state_file: path.display().to_string(),
309            require_otp_to_resume: false,
310        }
311    }
312
313    #[test]
314    fn estop_levels_compose_and_resume() {
315        let dir = tempdir().unwrap();
316        let state_path = dir.path().join("estop-state.json");
317        let cfg = estop_config(&state_path);
318        let mut manager = EstopManager::load(&cfg, dir.path()).unwrap();
319
320        manager
321            .engage(EstopLevel::DomainBlock(vec!["*.chase.com".into()]))
322            .unwrap();
323        manager
324            .engage(EstopLevel::ToolFreeze(vec!["shell".into()]))
325            .unwrap();
326        manager.engage(EstopLevel::NetworkKill).unwrap();
327        assert!(manager.status().network_kill);
328        assert_eq!(manager.status().blocked_domains, vec!["*.chase.com"]);
329        assert_eq!(manager.status().frozen_tools, vec!["shell"]);
330
331        manager
332            .resume(
333                ResumeSelector::Domains(vec!["*.chase.com".into()]),
334                None,
335                None,
336            )
337            .unwrap();
338        assert!(manager.status().blocked_domains.is_empty());
339        assert!(manager.status().network_kill);
340
341        manager
342            .resume(ResumeSelector::Tools(vec!["shell".into()]), None, None)
343            .unwrap();
344        assert!(manager.status().frozen_tools.is_empty());
345    }
346
347    #[test]
348    fn estop_state_survives_reload() {
349        let dir = tempdir().unwrap();
350        let state_path = dir.path().join("estop-state.json");
351        let cfg = estop_config(&state_path);
352
353        {
354            let mut manager = EstopManager::load(&cfg, dir.path()).unwrap();
355            manager.engage(EstopLevel::KillAll).unwrap();
356            manager
357                .engage(EstopLevel::DomainBlock(vec!["*.paypal.com".into()]))
358                .unwrap();
359        }
360
361        let reloaded = EstopManager::load(&cfg, dir.path()).unwrap();
362        let state = reloaded.status();
363        assert!(state.kill_all);
364        assert_eq!(state.blocked_domains, vec!["*.paypal.com"]);
365    }
366
367    #[test]
368    fn corrupted_state_defaults_to_fail_closed_kill_all() {
369        let dir = tempdir().unwrap();
370        let state_path = dir.path().join("estop-state.json");
371        fs::write(&state_path, "{not-valid-json").unwrap();
372        let cfg = estop_config(&state_path);
373        let manager = EstopManager::load(&cfg, dir.path()).unwrap();
374        assert!(manager.status().kill_all);
375    }
376
377    #[test]
378    fn resume_requires_valid_otp_when_enabled() {
379        let dir = tempdir().unwrap();
380        let state_path = dir.path().join("estop-state.json");
381        let mut cfg = estop_config(&state_path);
382        cfg.require_otp_to_resume = true;
383
384        let mut manager = EstopManager::load(&cfg, dir.path()).unwrap();
385        manager.engage(EstopLevel::KillAll).unwrap();
386
387        let err = manager
388            .resume(ResumeSelector::KillAll, None, None)
389            .expect_err("resume should require OTP");
390        assert!(err.to_string().contains("OTP code is required"));
391    }
392
393    #[test]
394    fn resume_accepts_valid_otp_code() {
395        let dir = tempdir().unwrap();
396        let state_path = dir.path().join("estop-state.json");
397        let mut cfg = estop_config(&state_path);
398        cfg.require_otp_to_resume = true;
399
400        let otp_cfg = OtpConfig {
401            enabled: true,
402            ..OtpConfig::default()
403        };
404        let store = SecretStore::new(dir.path(), true);
405        let (validator, _) = OtpValidator::from_config(&otp_cfg, dir.path(), &store).unwrap();
406        let now = SystemTime::now()
407            .duration_since(UNIX_EPOCH)
408            .map(|duration| duration.as_secs())
409            .unwrap_or(0);
410        let code = validator.code_for_timestamp(now);
411
412        let mut manager = EstopManager::load(&cfg, dir.path()).unwrap();
413        manager.engage(EstopLevel::KillAll).unwrap();
414        manager
415            .resume(ResumeSelector::KillAll, Some(&code), Some(&validator))
416            .unwrap();
417        assert!(!manager.status().kill_all);
418    }
419}