agentsec-core 0.5.0

AgentSec core library — scan / web / paste logic, pure Rust
Documentation
//! Emergency Stop — enumerate Agent / MCP-server processes by name
//! pattern and send them SIGTERM.
//!
//! ## When to use it
//!
//! User wants to halt **all** Agent activity right now: a runaway tool
//! call, a suspected malicious MCP server, an automation that's gone
//! sideways. One CLI invocation kills everything matching a small list
//! of known process-name patterns.
//!
//! ## Matching
//!
//! Substring match (case-insensitive) against the process's reported
//! name. The default pattern set covers the common consumer Agents and
//! MCP-server launchers (see [`default_patterns`]). Callers can extend
//! or replace the pattern set.
//!
//! ## Self-protection
//!
//! [`find_targets`] **always excludes** the current process and its
//! parent (the invoking shell), and ignores PIDs ≤ 1. Even if the user
//! passes `"agentsec"` as a pattern, this module will not kill the
//! running `agentsec` instance.
//!
//! ## Restore is out of scope
//!
//! The design talks about "順次 spawn restore" — putting the killed
//! processes back. That requires tracking each Agent's launch command
//! and re-spawning it, which is host-specific. This module only does
//! the stop side; restart is the user's job.
//!
//! ## Platforms
//!
//! - **Unix** (macOS, Linux): SIGTERM via `sysinfo::Process::kill_with`.
//! - **Windows**: SIGTERM is mapped by `sysinfo` to `TerminateProcess`.
//!   The reported `killed` flag still tracks success.

use std::collections::HashSet;

use serde::{Deserialize, Serialize};
use sysinfo::{Pid, ProcessRefreshKind, RefreshKind, Signal, System};

/// Built-in name patterns. Substring-matched case-insensitively.
pub fn default_patterns() -> Vec<String> {
    [
        "claude",     // Claude Code CLI / Claude desktop
        "cursor",     // Cursor IDE
        "cline",      // Cline VS Code extension host
        "aider",      // aider CLI
        "windsurf",   // Windsurf IDE
        "ollama",     // Ollama local LLM server
        "mcp-server", // any `mcp-server-foo` binary
    ]
    .iter()
    .map(|s| (*s).to_string())
    .collect()
}

/// One enumerated match. Returned by [`find_targets`] and consumed by
/// [`stop`].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TargetProcess {
    /// Process ID, as a `u32` for serializability across platforms.
    pub pid: u32,
    /// Reported process name (typically the executable's basename).
    pub name: String,
    /// First ~120 chars of the cmdline joined with spaces. Truncated so
    /// the audit row stays one-line-friendly.
    pub cmdline_excerpt: String,
    /// The first pattern from the input list that matched. Stable; if
    /// multiple patterns match, the earliest in the input wins.
    pub matched_pattern: String,
}

/// Outcome of one [`stop`] call.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StopOutcome {
    /// Per-target rows in PID order.
    pub rows: Vec<StopRow>,
    /// `true` when the call actually sent signals (i.e. not dry-run).
    pub applied: bool,
}

/// One stop attempt per target.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StopRow {
    /// Target the row refers to.
    pub target: TargetProcess,
    /// Action actually taken.
    pub action: StopAction,
}

/// Variants for a single stop attempt.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum StopAction {
    /// SIGTERM was sent and the OS reported success.
    Signalled,
    /// SIGTERM was sent but the OS reported failure (process already
    /// exited, permission denied, etc.).
    SignalFailed,
    /// `--dry-run`: would have signalled.
    WouldSignal,
}

const CMDLINE_EXCERPT_MAX: usize = 120;

/// Enumerate running processes that match any of `patterns`, applying
/// self-protection rules (see module docs §Self-protection).
///
/// Returns an empty vector when nothing matches — including the case
/// where the only matches are protected processes (own PID / parent PID).
pub fn find_targets(patterns: &[String]) -> Vec<TargetProcess> {
    let mut sys = System::new_with_specifics(
        RefreshKind::new().with_processes(ProcessRefreshKind::everything()),
    );
    sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true);

    let protected = protected_pids();
    let mut out = Vec::new();

    for (pid, proc) in sys.processes() {
        let pid_u32 = pid.as_u32();
        if pid_u32 <= 1 {
            continue;
        }
        if protected.contains(&pid_u32) {
            continue;
        }
        let name = proc.name().to_string_lossy().to_string();
        let name_lc = name.to_lowercase();
        let Some(matched) = patterns
            .iter()
            .find(|p| name_lc.contains(&p.to_lowercase()))
        else {
            continue;
        };
        let cmdline_excerpt = cmdline_excerpt(proc);
        out.push(TargetProcess {
            pid: pid_u32,
            name,
            cmdline_excerpt,
            matched_pattern: matched.clone(),
        });
    }

    out.sort_by_key(|t| t.pid);
    out
}

/// Send SIGTERM to every target in `targets`. Returns one row per
/// target. Pass `dry_run = true` to preview without signalling.
pub fn stop(targets: &[TargetProcess], dry_run: bool) -> StopOutcome {
    if dry_run {
        let rows = targets
            .iter()
            .map(|t| StopRow {
                target: t.clone(),
                action: StopAction::WouldSignal,
            })
            .collect();
        return StopOutcome {
            rows,
            applied: false,
        };
    }

    // Refresh once so we can call `kill_with` through live handles.
    let mut sys = System::new_with_specifics(
        RefreshKind::new().with_processes(ProcessRefreshKind::everything()),
    );
    sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true);

    let rows = targets
        .iter()
        .map(|t| {
            let action = match sys.process(Pid::from_u32(t.pid)) {
                Some(p) => match p.kill_with(Signal::Term) {
                    Some(true) => StopAction::Signalled,
                    _ => StopAction::SignalFailed,
                },
                None => StopAction::SignalFailed,
            };
            StopRow {
                target: t.clone(),
                action,
            }
        })
        .collect();

    StopOutcome {
        rows,
        applied: true,
    }
}

fn cmdline_excerpt(proc: &sysinfo::Process) -> String {
    let joined: String = proc
        .cmd()
        .iter()
        .map(|s| s.to_string_lossy())
        .collect::<Vec<_>>()
        .join(" ");
    if joined.chars().count() <= CMDLINE_EXCERPT_MAX {
        joined
    } else {
        let truncated: String = joined.chars().take(CMDLINE_EXCERPT_MAX).collect();
        format!("{truncated}")
    }
}

/// Maximum ancestor depth to walk when building the protected-PID set.
/// Prevents infinite loops if the kernel returns a corrupt parent chain.
const ANCESTOR_MAX_DEPTH: usize = 32;

/// Always-protected PIDs: the entire ancestor chain from the current
/// process up to PID 1 (init / launchd / systemd), plus the current
/// process itself.
///
/// Walking the chain (self → parent → grandparent → … → PID 1) ensures
/// that "agentsec mcp" and its shell wrapper are never SIGTERMed even
/// when the user passes a broad pattern like `"claude"`.
///
/// A visited set + `ANCESTOR_MAX_DEPTH` cap prevents infinite loops on
/// platforms where `sysinfo` could theoretically return a corrupt parent
/// chain.
fn protected_pids() -> HashSet<u32> {
    let mut set = HashSet::new();
    let own = std::process::id();
    set.insert(own);

    let mut sys = System::new_with_specifics(
        RefreshKind::new().with_processes(ProcessRefreshKind::everything()),
    );
    sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true);

    // Walk up the ancestor chain.
    let mut current_pid = Pid::from_u32(own);
    for _ in 0..ANCESTOR_MAX_DEPTH {
        let Some(proc) = sys.process(current_pid) else {
            break;
        };
        let Some(parent_pid) = proc.parent() else {
            break;
        };
        let parent_u32 = parent_pid.as_u32();
        // PID 0 / 1 are the kernel / init boundary; stop here.
        if parent_u32 <= 1 {
            break;
        }
        // Already visited (cycle guard).
        if set.contains(&parent_u32) {
            break;
        }
        set.insert(parent_u32);
        current_pid = parent_pid;
    }

    set
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn default_patterns_contains_common_agents() {
        let p = default_patterns();
        assert!(p.iter().any(|s| s == "claude"));
        assert!(p.iter().any(|s| s == "cursor"));
        assert!(p.iter().any(|s| s == "mcp-server"));
    }

    #[test]
    fn find_targets_never_returns_own_pid() {
        // Pass a pattern that matches the test binary's name suffix
        // (cargo test binaries are named after the test target, which
        // typically doesn't contain "claude" — but if the runner is
        // ever renamed, the self-protection rule must still hold).
        let own = std::process::id();
        let mut patterns = default_patterns();
        patterns.push("agentsec".to_string());
        let targets = find_targets(&patterns);
        assert!(
            targets.iter().all(|t| t.pid != own),
            "find_targets must never return own pid {own}; got {targets:?}"
        );
    }

    #[test]
    fn find_targets_with_no_patterns_returns_empty() {
        let targets = find_targets(&[]);
        assert!(targets.is_empty());
    }

    #[test]
    fn stop_dry_run_marks_all_would_signal() {
        let synthetic = vec![TargetProcess {
            pid: 99_999_999, // implausibly large; we won't actually signal
            name: "fake".into(),
            cmdline_excerpt: "fake".into(),
            matched_pattern: "fake".into(),
        }];
        let outcome = stop(&synthetic, true);
        assert!(!outcome.applied);
        assert_eq!(outcome.rows.len(), 1);
        assert_eq!(outcome.rows[0].action, StopAction::WouldSignal);
    }

    #[test]
    fn stop_apply_against_nonexistent_pid_yields_signal_failed() {
        // A real signal call against a definitely-nonexistent PID must
        // produce SignalFailed, not panic, not Signalled.
        let synthetic = vec![TargetProcess {
            pid: 99_999_999,
            name: "fake".into(),
            cmdline_excerpt: "fake".into(),
            matched_pattern: "fake".into(),
        }];
        let outcome = stop(&synthetic, false);
        assert!(outcome.applied);
        assert_eq!(outcome.rows[0].action, StopAction::SignalFailed);
    }

    #[test]
    fn cmdline_excerpt_truncates() {
        // Pure unit test of the truncation helper via a synthetic long
        // string. We can't easily fabricate a `sysinfo::Process` in
        // isolation, so this test stays narrow.
        let s = "x".repeat(CMDLINE_EXCERPT_MAX + 50);
        let truncated: String = s.chars().take(CMDLINE_EXCERPT_MAX).collect();
        let result = format!("{truncated}");
        assert_eq!(result.chars().count(), CMDLINE_EXCERPT_MAX + 1);
    }

    #[test]
    fn protected_pids_includes_own_and_at_least_one_ancestor() {
        // The current process always has at least one parent (the test
        // runner / shell), so the protected set must have ≥ 2 entries on
        // any real OS.
        let set = protected_pids();
        assert!(
            set.contains(&std::process::id()),
            "protected set must include own PID"
        );
        // On a real system the set will contain > 1 entry (own + at least
        // one ancestor). On pathological environments (PID 1 is the parent)
        // it may be exactly 1 — accept both but assert non-empty.
        assert!(!set.is_empty());
    }

    #[test]
    fn protected_pids_cycle_guard_terminates() {
        // Synthetic: even if we call protected_pids() multiple times it
        // should complete in finite time (no infinite loop).
        for _ in 0..3 {
            let set = protected_pids();
            assert!(set.contains(&std::process::id()));
        }
    }

    #[test]
    fn ancestor_max_depth_constant_is_reasonable() {
        // Sanity: a depth of 32 is large enough for any real process tree
        // but bounded to prevent runaway loops.
    }
}