use std::collections::HashSet;
use serde::{Deserialize, Serialize};
use sysinfo::{Pid, ProcessRefreshKind, RefreshKind, Signal, System};
pub fn default_patterns() -> Vec<String> {
[
"claude", "cursor", "cline", "aider", "windsurf", "ollama", "mcp-server", ]
.iter()
.map(|s| (*s).to_string())
.collect()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TargetProcess {
pub pid: u32,
pub name: String,
pub cmdline_excerpt: String,
pub matched_pattern: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StopOutcome {
pub rows: Vec<StopRow>,
pub applied: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StopRow {
pub target: TargetProcess,
pub action: StopAction,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum StopAction {
Signalled,
SignalFailed,
WouldSignal,
}
const CMDLINE_EXCERPT_MAX: usize = 120;
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
}
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,
};
}
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}…")
}
}
const ANCESTOR_MAX_DEPTH: usize = 32;
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);
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();
if parent_u32 <= 1 {
break;
}
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() {
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, 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() {
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() {
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() {
let set = protected_pids();
assert!(
set.contains(&std::process::id()),
"protected set must include own PID"
);
assert!(!set.is_empty());
}
#[test]
fn protected_pids_cycle_guard_terminates() {
for _ in 0..3 {
let set = protected_pids();
assert!(set.contains(&std::process::id()));
}
}
#[test]
fn ancestor_max_depth_constant_is_reasonable() {
}
}