Skip to main content

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}