agentsec_core/emergency_stop/
mod.rs1use std::collections::HashSet;
39
40use serde::{Deserialize, Serialize};
41use sysinfo::{Pid, ProcessRefreshKind, RefreshKind, Signal, System};
42
43pub fn default_patterns() -> Vec<String> {
45 [
46 "claude", "cursor", "cline", "aider", "windsurf", "ollama", "mcp-server", ]
54 .iter()
55 .map(|s| (*s).to_string())
56 .collect()
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct TargetProcess {
63 pub pid: u32,
65 pub name: String,
67 pub cmdline_excerpt: String,
70 pub matched_pattern: String,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct StopOutcome {
78 pub rows: Vec<StopRow>,
80 pub applied: bool,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct StopRow {
87 pub target: TargetProcess,
89 pub action: StopAction,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
95pub enum StopAction {
96 Signalled,
98 SignalFailed,
101 WouldSignal,
103}
104
105const CMDLINE_EXCERPT_MAX: usize = 120;
106
107pub fn find_targets(patterns: &[String]) -> Vec<TargetProcess> {
113 let mut sys = System::new_with_specifics(
114 RefreshKind::new().with_processes(ProcessRefreshKind::everything()),
115 );
116 sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true);
117
118 let protected = protected_pids();
119 let mut out = Vec::new();
120
121 for (pid, proc) in sys.processes() {
122 let pid_u32 = pid.as_u32();
123 if pid_u32 <= 1 {
124 continue;
125 }
126 if protected.contains(&pid_u32) {
127 continue;
128 }
129 let name = proc.name().to_string_lossy().to_string();
130 let name_lc = name.to_lowercase();
131 let Some(matched) = patterns
132 .iter()
133 .find(|p| name_lc.contains(&p.to_lowercase()))
134 else {
135 continue;
136 };
137 let cmdline_excerpt = cmdline_excerpt(proc);
138 out.push(TargetProcess {
139 pid: pid_u32,
140 name,
141 cmdline_excerpt,
142 matched_pattern: matched.clone(),
143 });
144 }
145
146 out.sort_by_key(|t| t.pid);
147 out
148}
149
150pub fn stop(targets: &[TargetProcess], dry_run: bool) -> StopOutcome {
153 if dry_run {
154 let rows = targets
155 .iter()
156 .map(|t| StopRow {
157 target: t.clone(),
158 action: StopAction::WouldSignal,
159 })
160 .collect();
161 return StopOutcome {
162 rows,
163 applied: false,
164 };
165 }
166
167 let mut sys = System::new_with_specifics(
169 RefreshKind::new().with_processes(ProcessRefreshKind::everything()),
170 );
171 sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true);
172
173 let rows = targets
174 .iter()
175 .map(|t| {
176 let action = match sys.process(Pid::from_u32(t.pid)) {
177 Some(p) => match p.kill_with(Signal::Term) {
178 Some(true) => StopAction::Signalled,
179 _ => StopAction::SignalFailed,
180 },
181 None => StopAction::SignalFailed,
182 };
183 StopRow {
184 target: t.clone(),
185 action,
186 }
187 })
188 .collect();
189
190 StopOutcome {
191 rows,
192 applied: true,
193 }
194}
195
196fn cmdline_excerpt(proc: &sysinfo::Process) -> String {
197 let joined: String = proc
198 .cmd()
199 .iter()
200 .map(|s| s.to_string_lossy())
201 .collect::<Vec<_>>()
202 .join(" ");
203 if joined.chars().count() <= CMDLINE_EXCERPT_MAX {
204 joined
205 } else {
206 let truncated: String = joined.chars().take(CMDLINE_EXCERPT_MAX).collect();
207 format!("{truncated}…")
208 }
209}
210
211fn protected_pids() -> HashSet<u32> {
217 let mut set = HashSet::new();
218 set.insert(std::process::id());
219 let mut sys = System::new_with_specifics(
220 RefreshKind::new().with_processes(ProcessRefreshKind::everything()),
221 );
222 sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true);
223 if let Some(self_proc) = sys.process(Pid::from_u32(std::process::id()))
224 && let Some(parent) = self_proc.parent()
225 {
226 set.insert(parent.as_u32());
227 }
228 set
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234
235 #[test]
236 fn default_patterns_contains_common_agents() {
237 let p = default_patterns();
238 assert!(p.iter().any(|s| s == "claude"));
239 assert!(p.iter().any(|s| s == "cursor"));
240 assert!(p.iter().any(|s| s == "mcp-server"));
241 }
242
243 #[test]
244 fn find_targets_never_returns_own_pid() {
245 let own = std::process::id();
250 let mut patterns = default_patterns();
251 patterns.push("agentsec".to_string());
252 let targets = find_targets(&patterns);
253 assert!(
254 targets.iter().all(|t| t.pid != own),
255 "find_targets must never return own pid {own}; got {targets:?}"
256 );
257 }
258
259 #[test]
260 fn find_targets_with_no_patterns_returns_empty() {
261 let targets = find_targets(&[]);
262 assert!(targets.is_empty());
263 }
264
265 #[test]
266 fn stop_dry_run_marks_all_would_signal() {
267 let synthetic = vec![TargetProcess {
268 pid: 99_999_999, name: "fake".into(),
270 cmdline_excerpt: "fake".into(),
271 matched_pattern: "fake".into(),
272 }];
273 let outcome = stop(&synthetic, true);
274 assert!(!outcome.applied);
275 assert_eq!(outcome.rows.len(), 1);
276 assert_eq!(outcome.rows[0].action, StopAction::WouldSignal);
277 }
278
279 #[test]
280 fn stop_apply_against_nonexistent_pid_yields_signal_failed() {
281 let synthetic = vec![TargetProcess {
284 pid: 99_999_999,
285 name: "fake".into(),
286 cmdline_excerpt: "fake".into(),
287 matched_pattern: "fake".into(),
288 }];
289 let outcome = stop(&synthetic, false);
290 assert!(outcome.applied);
291 assert_eq!(outcome.rows[0].action, StopAction::SignalFailed);
292 }
293
294 #[test]
295 fn cmdline_excerpt_truncates() {
296 let s = "x".repeat(CMDLINE_EXCERPT_MAX + 50);
300 let truncated: String = s.chars().take(CMDLINE_EXCERPT_MAX).collect();
301 let result = format!("{truncated}…");
302 assert_eq!(result.chars().count(), CMDLINE_EXCERPT_MAX + 1);
303 }
304}