zeroclaw_runtime/security/
estop.rs1use 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}