1use crate::capability::{CapabilityError, Context, Output, TypedCapability};
36use crate::processes::ProcessSnapshot;
37use serde::{Deserialize, Serialize};
38use serde_json::Value;
39use std::time::Duration;
40
41#[cfg(test)]
42use std::process::Command;
43
44#[allow(clippy::arithmetic_side_effects)]
50fn get_process_start_time(pid: u32) -> Option<u64> {
51 let stat_path = format!("/proc/{}/stat", pid);
52 let content = std::fs::read_to_string(&stat_path).ok()?;
53 let last_paren = content.rfind(')')?;
54 let fields: Vec<&str> = content[last_paren + 2..].split_whitespace().collect();
55 fields.get(19)?.parse::<u64>().ok()
56}
57fn get_process_start_time_retry(pid: u32) -> Option<u64> {
58 #[allow(clippy::arithmetic_side_effects)] for attempt in 0..3 {
60 if attempt > 0 {
61 std::thread::sleep(std::time::Duration::from_millis(10 * (1 << attempt)));
62 }
63 if let Some(start_time) = get_process_start_time(pid) {
64 return Some(start_time);
65 }
66 }
67 None
68}
69
70fn get_process_cgroup(pid: u32) -> Option<String> {
74 std::fs::read_to_string(format!("/proc/{}/cgroup", pid)).ok()
75}
76
77fn is_systemd_service(cgroup: &str) -> bool {
79 cgroup.contains("/system.slice/")
80 || cgroup.contains("/init.scope")
81 || cgroup.contains("systemd")
82}
83
84fn protected_pids() -> Vec<u32> {
88 let mut pids = vec![1, 2];
89 let self_pid = std::process::id();
90 pids.push(self_pid);
91
92 if let Ok(status) = std::fs::read_to_string(format!("/proc/{}/status", self_pid)) {
94 if let Some(ppid_str) = status
95 .lines()
96 .find(|l| l.starts_with("PPid:"))
97 .and_then(|l| l.split_whitespace().nth(1))
98 {
99 if let Ok(ppid) = ppid_str.parse::<u32>() {
100 pids.push(ppid);
101 }
102 }
103 }
104
105 if let Ok(status) = std::fs::read_to_string(format!("/proc/{}/status", self_pid)) {
107 if let Some(sid_str) = status
108 .lines()
109 .find(|l| l.starts_with("Sid:"))
110 .and_then(|l| l.split_whitespace().nth(1))
111 {
112 if let Ok(sid) = sid_str.parse::<u32>() {
113 if sid != 0 {
114 pids.push(sid);
115 }
116 }
117 }
118 }
119
120 if let Ok(status) = std::fs::read_to_string(format!("/proc/{}/status", self_pid)) {
122 if let Some(pgid_str) = status
123 .lines()
124 .find(|l| l.starts_with("NSpgid:"))
125 .and_then(|l| l.split_whitespace().nth(1))
126 {
127 if let Ok(pgid) = pgid_str.parse::<u32>() {
128 if pgid != 0 {
129 pids.push(pgid);
130 }
131 }
132 }
133 }
134
135 if let Ok(entries) = std::fs::read_dir("/proc") {
137 for entry in entries.flatten() {
138 if let Ok(name) = entry.file_name().into_string() {
139 if let Ok(pid) = name.parse::<u32>() {
140 if let Some(cgroup) = get_process_cgroup(pid) {
141 if is_systemd_service(&cgroup) {
142 pids.push(pid);
143 }
144 }
145 }
146 }
147 }
148 }
149
150 pids.sort_unstable();
151 pids.dedup();
152 pids
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize)]
157#[allow(clippy::exhaustive_structs)] pub struct KillArgs {
159 pub pid: u32,
161 pub signal: Option<i32>,
163}
164
165#[allow(clippy::exhaustive_structs)]
170pub struct Kill;
171
172impl TypedCapability for Kill {
173 type Args = KillArgs;
174
175 fn name(&self) -> &'static str {
176 "Kill"
177 }
178
179 fn description(&self) -> &'static str {
180 "terminate process by PID with PID reuse protection. protected: init (1), kthreadd (2), self, parent, session/group leaders, systemd services. signals: 1-31, 64 (SIGRTMIN)."
181 }
182
183 fn schema(&self) -> Value {
188 serde_json::json!({
189 "type": "object",
190 "properties": {
191 "pid": { "type": "integer", "minimum": 1 },
192 "signal": {
193 "type": "integer",
194 "anyOf": [
195 { "minimum": 1, "maximum": 31 },
196 { "enum": [64] }
197 ]
198 }
199 },
200 "required": ["pid"]
201 })
202 }
203
204 fn execute(
205 &self,
206 args: KillArgs,
207 ctx: &Context,
208 ) -> std::result::Result<Output, CapabilityError> {
209 if let Some(signal) = args.signal {
211 if !(1..=31).contains(&signal) && signal != 64 {
212 return Err(CapabilityError::InvalidArgs(format!(
213 "Invalid signal {}: must be 1-31 or 64 (POSIX signals)",
214 signal
215 )));
216 }
217 }
218
219 let protected = protected_pids();
221 if protected.contains(&args.pid) {
222 return Err(CapabilityError::PermissionDenied(format!(
223 "PID {} is a protected system process",
224 args.pid
225 )));
226 }
227
228 if ctx.dry_run {
230 let mut out = Output::ok(format!("DRY RUN: would kill PID {}", args.pid));
232 out.data = Some(serde_json::json!({
233 "pid": args.pid,
234 "killed": false,
235 "dry_run": true,
236 "signal": args.signal.unwrap_or(15),
237 }));
238 return Ok(out);
239 }
240
241 let process_before = ProcessSnapshot::capture();
243 let process_exists = process_before.processes.iter().any(|p| p.pid == args.pid);
244
245 if !process_exists {
246 let mut out = Output::error(
247 format!("Process {} not found", args.pid),
248 "Process not found".into(),
249 );
250 out.data = Some(serde_json::json!({
251 "pid": args.pid,
252 "killed": false,
253 "reason": "Process not found"
254 }));
255 return Ok(out);
256 }
257
258 let start_time_before = get_process_start_time_retry(args.pid);
260
261 let start_time_before_confirm = get_process_start_time_retry(args.pid);
264 if start_time_before != start_time_before_confirm {
265 let mut out = Output::error(
266 format!(
267 "PID {} was reused by a different process (start time changed before kill)",
268 args.pid
269 ),
270 "PID reused between safety checks".into(),
271 );
272 out.data = Some(serde_json::json!({
273 "pid": args.pid,
274 "killed": false,
275 "reason": "PID reused between safety checks",
276 "pid_reused": true,
277 }));
278 return Ok(out);
279 }
280
281 let signal = args.signal.unwrap_or(15);
283
284 #[allow(clippy::cast_possible_wrap)]
288 let kill_result = unsafe { libc::kill(args.pid as libc::pid_t, signal) };
289 let success = kill_result == 0;
290 let stderr_str = if success {
291 String::new()
292 } else {
293 std::io::Error::last_os_error().to_string()
294 };
295
296 std::thread::sleep(Duration::from_millis(500));
298
299 ProcessSnapshot::clear_cache();
301
302 let process_after = ProcessSnapshot::capture();
304
305 let process_still_exists = process_after
307 .processes
308 .iter()
309 .any(|p| p.pid == args.pid && !p.stat.starts_with('Z'));
310 let pid_reused = match (start_time_before, get_process_start_time_retry(args.pid)) {
312 (Some(before_time), Some(after_time)) => before_time != after_time,
313 (None, _) => false,
314 (Some(_), None) => true,
315 };
316
317 let killed_success = success && !process_still_exists && !pid_reused;
318
319 let message = if killed_success {
320 format!("Killed process {} (signal {})", args.pid, signal)
321 } else if pid_reused {
322 format!(
323 "PID {} was reused by a different process (start time changed)",
324 args.pid
325 )
326 } else if !success {
327 format!("Failed to kill process {}: {}", args.pid, stderr_str)
328 } else {
329 format!("Process {} still exists after signal {}", args.pid, signal)
330 };
331
332 let mut out = if killed_success {
333 Output::ok(message)
334 } else {
335 Output::error(
336 message,
337 if success {
338 String::new()
339 } else {
340 stderr_str.clone()
341 },
342 )
343 };
344 out.data = Some(serde_json::json!({
345 "pid": args.pid,
346 "killed": killed_success,
347 "signal": signal,
348 "stderr": if success { String::new() } else { stderr_str },
349 "pid_reused": pid_reused,
350 "process_before": {
351 "count": process_before.summary.total_processes,
352 "zombies": process_before.summary.zombie_count
353 },
354 "process_after": {
355 "count": process_after.summary.total_processes,
356 "zombies": process_after.summary.zombie_count
357 }
358 }));
359 Ok(out)
360 }
361}
362
363#[cfg(test)]
364#[allow(clippy::unnecessary_map_or)]
365mod tests {
366 use super::*;
367 use crate::capability::Capability;
368 use std::thread;
369 use std::time::Duration;
370
371 #[test]
372 fn test_kill_schema() {
373 let cap = Kill;
374 let _schema = Capability::schema(&cap);
375 let mut child = Command::new("sleep").arg("60").spawn().unwrap();
378 let pid = child.id();
379
380 let result = get_process_start_time_retry(pid);
381 assert!(
382 result.is_some(),
383 "Should read start time for running process"
384 );
385
386 child.kill().ok();
387 let _ = child.wait();
388
389 let result = get_process_start_time_retry(999999);
391 assert!(result.is_none(), "Non-existent PID should return None");
392 }
393
394 #[test]
395 fn test_kill_protected_pid() {
396 let cap = Kill;
397 let result = Capability::execute(
399 &cap,
400 &serde_json::json!({ "pid": 1 }),
401 &Context {
402 dry_run: false,
403 job_id: "test".into(),
404 working_dir: std::env::current_dir().unwrap(),
405 },
406 );
407
408 assert!(result.is_err());
410 assert!(result
411 .unwrap_err()
412 .to_string()
413 .contains("protected system process"));
414 }
415
416 #[test]
417 fn test_kill_self_protected() {
418 let cap = Kill;
419 let self_pid = std::process::id();
420 let result = Capability::execute(
421 &cap,
422 &serde_json::json!({ "pid": self_pid }),
423 &Context {
424 dry_run: false,
425 job_id: "test".into(),
426 working_dir: std::env::current_dir().unwrap(),
427 },
428 );
429
430 assert!(result.is_err());
431 assert!(result.unwrap_err().to_string().contains("protected"));
432 }
433
434 #[test]
435 fn test_kill_nonexistent() {
436 let cap = Kill;
437 let result = Capability::execute(
439 &cap,
440 &serde_json::json!({ "pid": 999999 }),
441 &Context {
442 dry_run: false,
443 job_id: "test".into(),
444 working_dir: std::env::current_dir().unwrap(),
445 },
446 )
447 .unwrap();
448
449 assert_eq!(result.status, "error");
450 assert!(result.data.as_ref().unwrap()["killed"].as_bool() == Some(false));
451 }
452
453 #[test]
454 fn test_kill_dry_run() {
455 let cap = Kill;
456 let result = Capability::execute(
460 &cap,
461 &serde_json::json!({ "pid": 999998 }),
462 &Context {
463 dry_run: true,
464 job_id: "test".into(),
465 working_dir: std::env::current_dir().unwrap(),
466 },
467 )
468 .unwrap();
469
470 assert_eq!(result.status, "ok");
471 assert!(result.data.as_ref().unwrap()["dry_run"].as_bool() == Some(true));
472 assert!(result.data.as_ref().unwrap()["killed"].as_bool() == Some(false));
473 }
474
475 #[test]
476 fn test_kill_actual_process() {
477 let mut child = Command::new("sleep").arg("60").spawn().unwrap();
479 let pid = child.id();
480
481 thread::sleep(Duration::from_millis(100));
483
484 let pre_check = Command::new("kill").arg("-0").arg(pid.to_string()).output();
486 assert!(
487 pre_check.unwrap().status.success(),
488 "Process should exist before kill"
489 );
490
491 let protected = protected_pids();
496 if protected.contains(&pid) {
497 let _ = child.kill();
498 let _ = child.wait();
499 eprintln!(
500 "SKIP: spawned child PID {pid} is in protected_pids set \
501 ({protected:?}); kill blocked by safety guard. \
502 This is expected in CI containers."
503 );
504 return;
505 }
506
507 ProcessSnapshot::clear_cache();
509
510 let cap = Kill;
512 let result = Capability::execute(
513 &cap,
514 &serde_json::json!({ "pid": pid, "signal": 9 }),
515 &Context {
516 dry_run: false,
517 job_id: "test".into(),
518 working_dir: std::env::current_dir().unwrap(),
519 },
520 )
521 .unwrap();
522
523 assert!(
525 result.data.as_ref().unwrap()["killed"].as_bool() == Some(true),
526 "Kill failed: {:?}",
527 result.data
528 );
529 assert!(
530 result.data.as_ref().unwrap()["signal"].as_i64() == Some(9),
531 "Should use SIGKILL"
532 );
533
534 let _ = child.wait();
536
537 let post_check = Command::new("kill").arg("-0").arg(pid.to_string()).output();
539 let still_alive = post_check.map_or(false, |o| o.status.success());
540 assert!(
541 !still_alive,
542 "Process {} should be dead after kill and reap",
543 pid
544 );
545 }
546
547 #[test]
548 fn test_get_process_start_time() {
549 let mut child = Command::new("sleep").arg("60").spawn().unwrap();
551 let pid = child.id();
552
553 let start_time = get_process_start_time(pid);
554 assert!(
555 start_time.is_some(),
556 "Should be able to read start time for running process"
557 );
558
559 let start_time2 = get_process_start_time(pid);
561 assert_eq!(start_time, start_time2, "Start time should be stable");
562
563 child.kill().ok();
564 let _ = child.wait();
565 }
566
567 #[test]
568 fn test_get_process_start_time_nonexistent() {
569 let result = get_process_start_time(999999);
570 assert!(result.is_none(), "Non-existent PID should return None");
571 }
572
573 #[test]
574 fn test_signal_validation_rejects_negative() {
575 let cap = Kill;
577 let result = Capability::execute(
578 &cap,
579 &serde_json::json!({ "pid": 999998, "signal": -1 }),
580 &Context {
581 dry_run: false,
582 job_id: "test".into(),
583 working_dir: std::env::current_dir().unwrap(),
584 },
585 );
586 assert!(result.is_err());
587 assert!(result.unwrap_err().to_string().contains("Invalid signal"));
588 }
589
590 #[test]
591 fn test_signal_validation_rejects_zero() {
592 let cap = Kill;
594 let result = Capability::execute(
595 &cap,
596 &serde_json::json!({ "pid": 999998, "signal": 0 }),
597 &Context {
598 dry_run: false,
599 job_id: "test".into(),
600 working_dir: std::env::current_dir().unwrap(),
601 },
602 );
603 assert!(result.is_err());
604 assert!(result.unwrap_err().to_string().contains("Invalid signal"));
605 }
606
607 #[test]
608 fn test_signal_validation_rejects_out_of_range() {
609 let cap = Kill;
611 let result = Capability::execute(
612 &cap,
613 &serde_json::json!({ "pid": 999998, "signal": 32 }),
614 &Context {
615 dry_run: false,
616 job_id: "test".into(),
617 working_dir: std::env::current_dir().unwrap(),
618 },
619 );
620 assert!(result.is_err());
621 }
622
623 #[test]
624 fn test_signal_validation_accepts_valid_signals() {
625 let cap = Kill;
626 for sig in [1, 9, 15, 31, 64] {
627 let result = Capability::execute(
628 &cap,
629 &serde_json::json!({ "pid": 999998, "signal": sig }),
630 &Context {
631 dry_run: false,
632 job_id: "test".into(),
633 working_dir: std::env::current_dir().unwrap(),
634 },
635 );
636 if let Err(e) = &result {
639 assert!(
640 !e.to_string().contains("Invalid signal"),
641 "Signal {} should be valid, got: {}",
642 sig,
643 e
644 );
645 }
646 }
647 }
648
649 #[test]
650 fn test_dry_run_hides_process_info() {
651 let cap = Kill;
653 let result = Capability::execute(
654 &cap,
655 &serde_json::json!({ "pid": 999998 }),
656 &Context {
657 dry_run: true,
658 job_id: "test".into(),
659 working_dir: std::env::current_dir().unwrap(),
660 },
661 )
662 .unwrap();
663
664 assert_eq!(result.status, "ok");
665 assert!(result.data.as_ref().unwrap()["dry_run"].as_bool() == Some(true));
666 assert!(
667 result.data.as_ref().unwrap().get("command").is_none(),
668 "dry-run must not expose command"
669 );
670 assert!(
671 result.data.as_ref().unwrap().get("user").is_none(),
672 "dry-run must not expose user"
673 );
674 assert!(
675 result
676 .data
677 .as_ref()
678 .unwrap()
679 .get("process_exists")
680 .is_none(),
681 "dry-run must not expose process_exists"
682 );
683 }
684
685 #[test]
686 fn test_protected_pids_includes_self_and_parent() {
687 let protected = protected_pids();
688 let self_pid = std::process::id();
689 assert!(protected.contains(&1), "PID 1 should be protected");
690 assert!(protected.contains(&2), "PID 2 should be protected");
691 assert!(
692 protected.contains(&self_pid),
693 "self PID should be protected"
694 );
695 }
696
697 #[test]
698 fn test_get_process_start_time_retry() {
699 let mut child = Command::new("sleep").arg("60").spawn().unwrap();
701 let pid = child.id();
702
703 let result = get_process_start_time_retry(pid);
704 assert!(
705 result.is_some(),
706 "Should read start time for running process"
707 );
708
709 child.kill().ok();
710 let _ = child.wait();
711
712 let result = get_process_start_time_retry(999999);
714 assert!(result.is_none(), "Non-existent PID should return None");
715 }
716}