Skip to content

Commit 0c76ade

Browse files
authored
Merge pull request #34 from kevinnft/fix/run-command-process-group
fix(security): kill descendant processes when run_command times out
2 parents 0872734 + 130006b commit 0c76ade

2 files changed

Lines changed: 148 additions & 41 deletions

File tree

src-tauri/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ globset = "0.4"
3636
regex = "1"
3737
urlencoding = "2"
3838

39+
[target.'cfg(unix)'.dependencies]
40+
libc = "0.2"
41+
3942
[profile.release]
4043
opt-level = 3
4144
lto = true

src-tauri/src/tools/executor.rs

Lines changed: 145 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -430,51 +430,120 @@ impl ToolExecutor {
430430
.stderr(Stdio::piped())
431431
.kill_on_drop(true);
432432

433-
let child = command.spawn().map_err(AppError::from)?;
433+
// On Unix, place the child in its own process group so we can kill any
434+
// descendants the shell backgrounds. Without this, a command like
435+
// `sh -c "sleep 60 &"` orphans the sleep when sh exits or is killed —
436+
// it survives the timeout and continues to run with the agent's privileges.
437+
#[cfg(unix)]
438+
command.process_group(0);
434439

435440
const MAX_OUTPUT_BYTES: usize = 256 * 1024; // 256KB limit
436441

437-
match tokio::time::timeout(self.command_timeout, child.wait_with_output()).await {
438-
Ok(Ok(output)) => {
439-
let stdout_raw = &output.stdout;
440-
let stderr_raw = &output.stderr;
441-
let exit_code = output.status.code().unwrap_or(-1);
442-
443-
let stdout = if stdout_raw.len() > MAX_OUTPUT_BYTES {
444-
let truncated = String::from_utf8_lossy(&stdout_raw[..MAX_OUTPUT_BYTES]);
445-
format!(
446-
"{}\n\n... [truncated: {} total bytes, showing first {}]",
447-
truncated,
448-
stdout_raw.len(),
449-
MAX_OUTPUT_BYTES
450-
)
451-
} else {
452-
String::from_utf8_lossy(stdout_raw).to_string()
453-
};
454-
455-
let stderr = if stderr_raw.len() > MAX_OUTPUT_BYTES {
456-
let truncated = String::from_utf8_lossy(&stderr_raw[..MAX_OUTPUT_BYTES]);
457-
format!(
458-
"{}\n\n... [truncated: {} total bytes, showing first {}]",
459-
truncated,
460-
stderr_raw.len(),
461-
MAX_OUTPUT_BYTES
462-
)
463-
} else {
464-
String::from_utf8_lossy(stderr_raw).to_string()
465-
};
466-
467-
Ok(format!(
468-
"exit_code: {}\nstdout:\n{}\nstderr:\n{}",
469-
exit_code, stdout, stderr
470-
))
442+
let mut child = command.spawn().map_err(AppError::from)?;
443+
444+
// Capture the leader pid before waiting. This is the process group ID
445+
// since we requested process_group(0).
446+
#[cfg(unix)]
447+
let pgid = child.id().map(|id| id as i32);
448+
449+
let stdout_pipe = child.stdout.take();
450+
let stderr_pipe = child.stderr.take();
451+
452+
let stdout_task = tokio::spawn(async move {
453+
let mut buf = Vec::new();
454+
let mut total = 0usize;
455+
if let Some(mut out) = stdout_pipe {
456+
let mut chunk = [0u8; 8192];
457+
loop {
458+
let n = tokio::io::AsyncReadExt::read(&mut out, &mut chunk).await?;
459+
if n == 0 {
460+
break;
461+
}
462+
total += n;
463+
if buf.len() < MAX_OUTPUT_BYTES {
464+
let remaining = MAX_OUTPUT_BYTES - buf.len();
465+
buf.extend_from_slice(&chunk[..n.min(remaining)]);
466+
}
467+
}
471468
}
472-
Ok(Err(error)) => Err(AppError::from(error)),
473-
Err(_) => Err(AppError::Internal(format!(
474-
"Command timed out after {}s",
475-
self.command_timeout.as_secs()
476-
))),
477-
}
469+
Ok::<_, std::io::Error>((buf, total))
470+
});
471+
472+
let stderr_task = tokio::spawn(async move {
473+
let mut buf = Vec::new();
474+
let mut total = 0usize;
475+
if let Some(mut err) = stderr_pipe {
476+
let mut chunk = [0u8; 8192];
477+
loop {
478+
let n = tokio::io::AsyncReadExt::read(&mut err, &mut chunk).await?;
479+
if n == 0 {
480+
break;
481+
}
482+
total += n;
483+
if buf.len() < MAX_OUTPUT_BYTES {
484+
let remaining = MAX_OUTPUT_BYTES - buf.len();
485+
buf.extend_from_slice(&chunk[..n.min(remaining)]);
486+
}
487+
}
488+
}
489+
Ok::<_, std::io::Error>((buf, total))
490+
});
491+
492+
let status = match tokio::time::timeout(self.command_timeout, child.wait()).await {
493+
Ok(Ok(status)) => status,
494+
Ok(Err(error)) => return Err(AppError::from(error)),
495+
Err(_) => {
496+
#[cfg(unix)]
497+
if let Some(pgid) = pgid {
498+
// Kill the entire process group so backgrounded descendants don't survive.
499+
// SAFETY: killpg with a valid pgid we just spawned is a safe syscall.
500+
unsafe {
501+
libc::killpg(pgid, libc::SIGKILL);
502+
}
503+
}
504+
#[cfg(not(unix))]
505+
{
506+
let _ = child.kill().await;
507+
}
508+
let _ = child.wait().await;
509+
let _ = stdout_task.await;
510+
let _ = stderr_task.await;
511+
return Err(AppError::Internal(format!(
512+
"Command timed out after {}s",
513+
self.command_timeout.as_secs()
514+
)));
515+
}
516+
};
517+
518+
let (stdout_raw, stdout_total) = stdout_task
519+
.await
520+
.map_err(|e| AppError::Internal(format!("stdout task failed: {e}")))?
521+
.map_err(AppError::from)?;
522+
let (stderr_raw, stderr_total) = stderr_task
523+
.await
524+
.map_err(|e| AppError::Internal(format!("stderr task failed: {e}")))?
525+
.map_err(AppError::from)?;
526+
527+
let format_stream = |buf: &[u8], total: usize| {
528+
if total > MAX_OUTPUT_BYTES {
529+
let truncated = String::from_utf8_lossy(buf);
530+
format!(
531+
"{}\n\n... [truncated: {} total bytes, showing first {}]",
532+
truncated, total, MAX_OUTPUT_BYTES
533+
)
534+
} else {
535+
String::from_utf8_lossy(buf).to_string()
536+
}
537+
};
538+
539+
let stdout = format_stream(&stdout_raw, stdout_total);
540+
let stderr = format_stream(&stderr_raw, stderr_total);
541+
let exit_code = status.code().unwrap_or(-1);
542+
543+
Ok(format!(
544+
"exit_code: {}\nstdout:\n{}\nstderr:\n{}",
545+
exit_code, stdout, stderr
546+
))
478547
}
479548

480549
async fn web_search(&self, input: &serde_json::Value) -> AppResult<String> {
@@ -1086,6 +1155,41 @@ mod tests {
10861155
cleanup("run_cmd_timeout");
10871156
}
10881157

1158+
#[tokio::test]
1159+
#[cfg(unix)]
1160+
async fn test_run_command_timeout_kills_backgrounded_children() {
1161+
// Regression: with only kill_on_drop on the parent shell, a command like
1162+
// `sh -c "sleep 60 &"` orphans the sleep when sh exits or is killed —
1163+
// the descendant survives the timeout and continues running with the
1164+
// agent's privileges. The fix puts the child in its own process group
1165+
// and killpg's the whole group on timeout.
1166+
let sandbox_path = with_sandbox("run_cmd_orphan");
1167+
let proof = sandbox_path.join("orphan_proof.txt");
1168+
let proof_str = proof.to_string_lossy().to_string();
1169+
1170+
let mut executor = ToolExecutor::new(sandbox_path);
1171+
executor.command_timeout = Duration::from_millis(300);
1172+
1173+
let call = ToolCall {
1174+
tool: ToolName::RunCommand,
1175+
input: serde_json::json!({
1176+
"command": format!("(sleep 3 && echo orphan > '{}') & sleep 60", proof_str),
1177+
}),
1178+
};
1179+
let result = executor.execute(call).await;
1180+
assert!(result.is_error, "timeout should trigger error");
1181+
1182+
// Wait long enough that the orphan WOULD have written its file if it survived.
1183+
tokio::time::sleep(Duration::from_secs(5)).await;
1184+
assert!(
1185+
!proof.exists(),
1186+
"backgrounded descendant must be killed with the process group, but it wrote: {}",
1187+
proof.display()
1188+
);
1189+
1190+
cleanup("run_cmd_orphan");
1191+
}
1192+
10891193
// ── validate_path edge cases ─────────────────────────────────────────────
10901194

10911195
#[tokio::test]

0 commit comments

Comments
 (0)