agentsec_core/emergency_stop/mod.rs
1//! Emergency Stop — enumerate Agent / MCP-server processes by name
2//! pattern and send them SIGTERM.
3//!
4//! ## When to use it
5//!
6//! User wants to halt **all** Agent activity right now: a runaway tool
7//! call, a suspected malicious MCP server, an automation that's gone
8//! sideways. One CLI invocation kills everything matching a small list
9//! of known process-name patterns.
10//!
11//! ## Matching
12//!
13//! Substring match (case-insensitive) against the process's reported
14//! name. The default pattern set covers the common consumer Agents and
15//! MCP-server launchers (see [`default_patterns`]). Callers can extend
16//! or replace the pattern set.
17//!
18//! ## Self-protection
19//!
20//! [`find_targets`] **always excludes** the current process and its
21//! parent (the invoking shell), and ignores PIDs ≤ 1. Even if the user
22//! passes `"agentsec"` as a pattern, this module will not kill the
23//! running `agentsec` instance.
24//!
25//! ## Restore is out of scope
26//!
27//! The design talks about "順次 spawn restore" — putting the killed
28//! processes back. That requires tracking each Agent's launch command
29//! and re-spawning it, which is host-specific. This module only does
30//! the stop side; restart is the user's job.
31//!
32//! ## Platforms
33//!
34//! - **Unix** (macOS, Linux): SIGTERM via `sysinfo::Process::kill_with`.
35//! - **Windows**: SIGTERM is mapped by `sysinfo` to `TerminateProcess`.
36//! The reported `killed` flag still tracks success.
37
38use std::collections::HashSet;
39
40use serde::{Deserialize, Serialize};
41use sysinfo::{Pid, ProcessRefreshKind, RefreshKind, Signal, System};
42
43/// Built-in name patterns. Substring-matched case-insensitively.
44pub fn default_patterns() -> Vec<String> {
45 [
46 "claude", // Claude Code CLI / Claude desktop
47 "cursor", // Cursor IDE
48 "cline", // Cline VS Code extension host
49 "aider", // aider CLI
50 "windsurf", // Windsurf IDE
51 "ollama", // Ollama local LLM server
52 "mcp-server", // any `mcp-server-foo` binary
53 ]
54 .iter()
55 .map(|s| (*s).to_string())
56 .collect()
57}
58
59/// One enumerated match. Returned by [`find_targets`] and consumed by
60/// [`stop`].
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct TargetProcess {
63 /// Process ID, as a `u32` for serializability across platforms.
64 pub pid: u32,
65 /// Reported process name (typically the executable's basename).
66 pub name: String,
67 /// First ~120 chars of the cmdline joined with spaces. Truncated so
68 /// the audit row stays one-line-friendly.
69 pub cmdline_excerpt: String,
70 /// The first pattern from the input list that matched. Stable; if
71 /// multiple patterns match, the earliest in the input wins.
72 pub matched_pattern: String,
73}
74
75/// Outcome of one [`stop`] call.
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct StopOutcome {
78 /// Per-target rows in PID order.
79 pub rows: Vec<StopRow>,
80 /// `true` when the call actually sent signals (i.e. not dry-run).
81 pub applied: bool,
82}
83
84/// One stop attempt per target.
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct StopRow {
87 /// Target the row refers to.
88 pub target: TargetProcess,
89 /// Action actually taken.
90 pub action: StopAction,
91}
92
93/// Variants for a single stop attempt.
94#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
95pub enum StopAction {
96 /// SIGTERM was sent and the OS reported success.
97 Signalled,
98 /// SIGTERM was sent but the OS reported failure (process already
99 /// exited, permission denied, etc.).
100 SignalFailed,
101 /// `--dry-run`: would have signalled.
102 WouldSignal,
103}
104
105const CMDLINE_EXCERPT_MAX: usize = 120;
106
107/// Enumerate running processes that match any of `patterns`, applying
108/// self-protection rules (see module docs §Self-protection).
109///
110/// Returns an empty vector when nothing matches — including the case
111/// where the only matches are protected processes (own PID / parent PID).
112pub 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
150/// Send SIGTERM to every target in `targets`. Returns one row per
151/// target. Pass `dry_run = true` to preview without signalling.
152pub 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 // Refresh once so we can call `kill_with` through live handles.
168 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
211/// Maximum ancestor depth to walk when building the protected-PID set.
212/// Prevents infinite loops if the kernel returns a corrupt parent chain.
213const ANCESTOR_MAX_DEPTH: usize = 32;
214
215/// Always-protected PIDs: the entire ancestor chain from the current
216/// process up to PID 1 (init / launchd / systemd), plus the current
217/// process itself.
218///
219/// Walking the chain (self → parent → grandparent → … → PID 1) ensures
220/// that "agentsec mcp" and its shell wrapper are never SIGTERMed even
221/// when the user passes a broad pattern like `"claude"`.
222///
223/// A visited set + `ANCESTOR_MAX_DEPTH` cap prevents infinite loops on
224/// platforms where `sysinfo` could theoretically return a corrupt parent
225/// chain.
226fn protected_pids() -> HashSet<u32> {
227 let mut set = HashSet::new();
228 let own = std::process::id();
229 set.insert(own);
230
231 let mut sys = System::new_with_specifics(
232 RefreshKind::new().with_processes(ProcessRefreshKind::everything()),
233 );
234 sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true);
235
236 // Walk up the ancestor chain.
237 let mut current_pid = Pid::from_u32(own);
238 for _ in 0..ANCESTOR_MAX_DEPTH {
239 let Some(proc) = sys.process(current_pid) else {
240 break;
241 };
242 let Some(parent_pid) = proc.parent() else {
243 break;
244 };
245 let parent_u32 = parent_pid.as_u32();
246 // PID 0 / 1 are the kernel / init boundary; stop here.
247 if parent_u32 <= 1 {
248 break;
249 }
250 // Already visited (cycle guard).
251 if set.contains(&parent_u32) {
252 break;
253 }
254 set.insert(parent_u32);
255 current_pid = parent_pid;
256 }
257
258 set
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264
265 #[test]
266 fn default_patterns_contains_common_agents() {
267 let p = default_patterns();
268 assert!(p.iter().any(|s| s == "claude"));
269 assert!(p.iter().any(|s| s == "cursor"));
270 assert!(p.iter().any(|s| s == "mcp-server"));
271 }
272
273 #[test]
274 fn find_targets_never_returns_own_pid() {
275 // Pass a pattern that matches the test binary's name suffix
276 // (cargo test binaries are named after the test target, which
277 // typically doesn't contain "claude" — but if the runner is
278 // ever renamed, the self-protection rule must still hold).
279 let own = std::process::id();
280 let mut patterns = default_patterns();
281 patterns.push("agentsec".to_string());
282 let targets = find_targets(&patterns);
283 assert!(
284 targets.iter().all(|t| t.pid != own),
285 "find_targets must never return own pid {own}; got {targets:?}"
286 );
287 }
288
289 #[test]
290 fn find_targets_with_no_patterns_returns_empty() {
291 let targets = find_targets(&[]);
292 assert!(targets.is_empty());
293 }
294
295 #[test]
296 fn stop_dry_run_marks_all_would_signal() {
297 let synthetic = vec![TargetProcess {
298 pid: 99_999_999, // implausibly large; we won't actually signal
299 name: "fake".into(),
300 cmdline_excerpt: "fake".into(),
301 matched_pattern: "fake".into(),
302 }];
303 let outcome = stop(&synthetic, true);
304 assert!(!outcome.applied);
305 assert_eq!(outcome.rows.len(), 1);
306 assert_eq!(outcome.rows[0].action, StopAction::WouldSignal);
307 }
308
309 #[test]
310 fn stop_apply_against_nonexistent_pid_yields_signal_failed() {
311 // A real signal call against a definitely-nonexistent PID must
312 // produce SignalFailed, not panic, not Signalled.
313 let synthetic = vec![TargetProcess {
314 pid: 99_999_999,
315 name: "fake".into(),
316 cmdline_excerpt: "fake".into(),
317 matched_pattern: "fake".into(),
318 }];
319 let outcome = stop(&synthetic, false);
320 assert!(outcome.applied);
321 assert_eq!(outcome.rows[0].action, StopAction::SignalFailed);
322 }
323
324 #[test]
325 fn cmdline_excerpt_truncates() {
326 // Pure unit test of the truncation helper via a synthetic long
327 // string. We can't easily fabricate a `sysinfo::Process` in
328 // isolation, so this test stays narrow.
329 let s = "x".repeat(CMDLINE_EXCERPT_MAX + 50);
330 let truncated: String = s.chars().take(CMDLINE_EXCERPT_MAX).collect();
331 let result = format!("{truncated}…");
332 assert_eq!(result.chars().count(), CMDLINE_EXCERPT_MAX + 1);
333 }
334
335 #[test]
336 fn protected_pids_includes_own_and_at_least_one_ancestor() {
337 // The current process always has at least one parent (the test
338 // runner / shell), so the protected set must have ≥ 2 entries on
339 // any real OS.
340 let set = protected_pids();
341 assert!(
342 set.contains(&std::process::id()),
343 "protected set must include own PID"
344 );
345 // On a real system the set will contain > 1 entry (own + at least
346 // one ancestor). On pathological environments (PID 1 is the parent)
347 // it may be exactly 1 — accept both but assert non-empty.
348 assert!(!set.is_empty());
349 }
350
351 #[test]
352 fn protected_pids_cycle_guard_terminates() {
353 // Synthetic: even if we call protected_pids() multiple times it
354 // should complete in finite time (no infinite loop).
355 for _ in 0..3 {
356 let set = protected_pids();
357 assert!(set.contains(&std::process::id()));
358 }
359 }
360
361 #[test]
362 fn ancestor_max_depth_constant_is_reasonable() {
363 // Sanity: a depth of 32 is large enough for any real process tree
364 // but bounded to prevent runaway loops.
365 }
366}