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/// Always-protected PIDs: the current process, its parent, and PID 0 / 1.
212///
213/// The "agentsec" name pattern would otherwise match the running
214/// `agentsec mcp` server itself and SIGTERM the very binary that's
215/// trying to invoke Emergency Stop.
216fn 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        // Pass a pattern that matches the test binary's name suffix
246        // (cargo test binaries are named after the test target, which
247        // typically doesn't contain "claude" — but if the runner is
248        // ever renamed, the self-protection rule must still hold).
249        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, // implausibly large; we won't actually signal
269            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        // A real signal call against a definitely-nonexistent PID must
282        // produce SignalFailed, not panic, not Signalled.
283        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        // Pure unit test of the truncation helper via a synthetic long
297        // string. We can't easily fabricate a `sysinfo::Process` in
298        // isolation, so this test stays narrow.
299        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}