From 35fe89b0a091f8f5bc09e81299ad19c6f0e4f8bc Mon Sep 17 00:00:00 2001 From: liwenkai <2020583117@qq.com> Date: Fri, 26 Jun 2026 18:01:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(agent):=20Windows=20=E6=B2=99=E7=AE=B1=20J?= =?UTF-8?q?ob=20Object(=E8=BF=9B=E7=A8=8B=E6=A0=91=20kill-on-close=20+=20?= =?UTF-8?q?=E8=BF=9B=E7=A8=8B=E6=95=B0=E4=B8=8A=E9=99=90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 22 ++++ Cargo.lock | 1 + crates/deep-code-agent/Cargo.toml | 7 + crates/deep-code-agent/src/lib.rs | 2 +- crates/deep-code-agent/src/sandbox/mod.rs | 33 ++++- crates/deep-code-agent/src/sandbox/windows.rs | 123 +++++++++++++++++- crates/deep-code-agent/src/shell_tools.rs | 8 ++ .../deep-code-agent/src/shell_tools/jobs.rs | 5 + 8 files changed, 196 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aaaf281..84055ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,3 +39,25 @@ jobs: - name: Test run: cargo test -p deep-code-agent -p deep-code-tui -p deep-code-runtime + + # Compiles & tests the Windows-only sandbox (Job Object) code, which the + # Linux job's `cfg(windows)` gating skips. + windows: + name: windows · clippy · test + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + # Pinned to match rust-toolchain.toml; bump both together. + uses: dtolnay/rust-toolchain@1.96.0 + with: + components: clippy + + - uses: Swatinem/rust-cache@v2 + + - name: Clippy (deny warnings) + run: cargo clippy -p deep-code-agent -p deep-code-tui -p deep-code-runtime --all-targets -- -D warnings + + - name: Test + run: cargo test -p deep-code-agent -p deep-code-tui -p deep-code-runtime diff --git a/Cargo.lock b/Cargo.lock index eb6d19d..bb47649 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -895,6 +895,7 @@ dependencies = [ "toml_edit 0.20.2", "uuid", "walkdir", + "windows-sys 0.59.0", ] [[package]] diff --git a/crates/deep-code-agent/Cargo.toml b/crates/deep-code-agent/Cargo.toml index 3cc4872..d868257 100644 --- a/crates/deep-code-agent/Cargo.toml +++ b/crates/deep-code-agent/Cargo.toml @@ -33,6 +33,13 @@ landlock = "0.4" seccompiler = "0.5" libc = "0.2" +# Windows sandbox backend (Job Object: process-tree kill + resource limits). +[target.'cfg(target_os = "windows")'.dependencies] +windows-sys = { version = "0.59", features = [ + "Win32_Foundation", + "Win32_System_JobObjects", +] } + [dev-dependencies] anyhow = "1" tempfile = "3" diff --git a/crates/deep-code-agent/src/lib.rs b/crates/deep-code-agent/src/lib.rs index df3df05..8e9b042 100644 --- a/crates/deep-code-agent/src/lib.rs +++ b/crates/deep-code-agent/src/lib.rs @@ -108,7 +108,7 @@ pub use runtime_launch::{ runtime_system_prompt, }; pub use sandbox::{ - SandboxBackend, SandboxCapabilities, SandboxManager, SandboxPolicy, capabilities, + SandboxBackend, SandboxCapabilities, SandboxGuard, SandboxManager, SandboxPolicy, capabilities, detect_capabilities, }; pub use session::Session; diff --git a/crates/deep-code-agent/src/sandbox/mod.rs b/crates/deep-code-agent/src/sandbox/mod.rs index 87194bb..6af8beb 100644 --- a/crates/deep-code-agent/src/sandbox/mod.rs +++ b/crates/deep-code-agent/src/sandbox/mod.rs @@ -12,7 +12,7 @@ mod windows; pub use policy::SandboxPolicy; use std::path::Path; -use std::process::Command; +use std::process::{Child, Command}; use std::sync::OnceLock; /// Detected sandbox backend for the current platform. @@ -90,6 +90,15 @@ pub fn detect_capabilities() -> SandboxCapabilities { } } +/// Keeps an OS sandbox alive for a spawned child's lifetime. On Windows it owns +/// the Job Object handle (dropping it kills the process tree); on macOS/Linux +/// it is empty, since those confine before spawn via [`SandboxManager::wrap_shell_command`]. +#[derive(Debug)] +pub struct SandboxGuard { + #[cfg(target_os = "windows")] + _job: windows::JobGuard, +} + /// Prepares subprocess commands, optionally wrapping them in an OS sandbox. #[derive(Debug, Clone, Default)] pub struct SandboxManager { @@ -146,6 +155,28 @@ impl SandboxManager { bare_shell_command(command, cwd) } } + + /// Confine an already-spawned child where the OS sandbox must be applied + /// post-spawn (Windows Job Object). Returns a guard to retain for the + /// child's lifetime, or `None` when no post-spawn step is needed (macOS and + /// Linux confine via [`Self::wrap_shell_command`]) or sandboxing is off. + #[must_use] + pub fn confine_spawned(&self, child: &Child, policy: &SandboxPolicy) -> Option { + if !self.should_sandbox(policy) { + return None; + } + #[cfg(target_os = "windows")] + { + Some(SandboxGuard { + _job: windows::confine(child)?, + }) + } + #[cfg(not(target_os = "windows"))] + { + let _ = child; + None + } + } } fn bare_shell_command(command: &str, cwd: &Path) -> Command { diff --git a/crates/deep-code-agent/src/sandbox/windows.rs b/crates/deep-code-agent/src/sandbox/windows.rs index da79db4..e85913a 100644 --- a/crates/deep-code-agent/src/sandbox/windows.rs +++ b/crates/deep-code-agent/src/sandbox/windows.rs @@ -1,11 +1,128 @@ +//! Windows sandbox: Job Object process containment. +//! +//! Unlike macOS/Linux, Windows has no clean "confine writes to a directory" +//! primitive (that needs AppContainer, which is out of scope and breaks many +//! tools). What a Job Object *does* give — and what we take here — is +//! process-tree containment: a child assigned to a `KILL_ON_JOB_CLOSE` job and +//! all its descendants are terminated when we drop the job handle, plus an +//! active-process cap to blunt fork bombs. Data-safety on Windows still rests +//! on the cross-platform approval gate. +//! +//! Job assignment must happen *after* spawn (`AssignProcessToJobObject` needs a +//! process handle), so this is wired at the shell spawn site via +//! [`super::SandboxManager::confine_spawned`], not the pre-spawn +//! `wrap_shell_command` path used elsewhere. + +use std::os::windows::io::AsRawHandle; +use std::process::Child; +use std::{mem, ptr}; + +use windows_sys::Win32::Foundation::{CloseHandle, HANDLE, INVALID_HANDLE_VALUE}; +use windows_sys::Win32::System::JobObjects::{ + AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_ACTIVE_PROCESS, + JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, JOBOBJECT_EXTENDED_LIMIT_INFORMATION, + JobObjectExtendedLimitInformation, SetInformationJobObject, +}; + use super::{SandboxBackend, SandboxCapabilities}; -/// Windows Job Object placeholder — detection only for now. +/// Cap on concurrent processes in a job — generous, just a fork-bomb backstop. +const ACTIVE_PROCESS_LIMIT: u32 = 512; + #[must_use] pub fn capabilities() -> SandboxCapabilities { + // Job Objects are available on every supported Windows release. SandboxCapabilities { backend: SandboxBackend::WindowsJobObject, - available: false, - detail: "Windows Job Object sandbox is not implemented yet (placeholder)".to_string(), + available: true, + detail: "Windows Job Object (process-tree kill + limits)".to_string(), + } +} + +/// Owns a Job Object handle. Dropping it (`CloseHandle`) terminates the +/// assigned process tree via `KILL_ON_JOB_CLOSE`, so keep it alive for as long +/// as the child should run. +#[derive(Debug)] +pub struct JobGuard { + handle: HANDLE, +} + +// `HANDLE` is a raw pointer the guard solely owns; it is only used to +// `CloseHandle` on drop, which is sound to move/share across threads. +unsafe impl Send for JobGuard {} +unsafe impl Sync for JobGuard {} + +impl Drop for JobGuard { + fn drop(&mut self) { + // SAFETY: `handle` is a valid job handle created in `confine` and not + // closed elsewhere. + unsafe { + CloseHandle(self.handle); + } + } +} + +/// Assign an already-spawned child to a fresh kill-on-close job with a process +/// cap. Returns the guard to retain, or `None` on any failure (best-effort: the +/// child then runs unconfined, matching the fail-open semantics on other OSes). +#[must_use] +pub fn confine(child: &Child) -> Option { + // SAFETY: standard Win32 Job Object sequence; every handle is checked and + // closed on the failure paths. + unsafe { + let job = CreateJobObjectW(ptr::null(), ptr::null()); + if job.is_null() || job == INVALID_HANDLE_VALUE { + return None; + } + + let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = mem::zeroed(); + info.BasicLimitInformation.LimitFlags = + JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_ACTIVE_PROCESS; + info.BasicLimitInformation.ActiveProcessLimit = ACTIVE_PROCESS_LIMIT; + + let set = SetInformationJobObject( + job, + JobObjectExtendedLimitInformation, + (&raw const info).cast(), + mem::size_of_val(&info) as u32, + ); + if set == 0 { + CloseHandle(job); + return None; + } + + let process: HANDLE = child.as_raw_handle(); + if AssignProcessToJobObject(job, process) == 0 { + CloseHandle(job); + return None; + } + + Some(JobGuard { handle: job }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::process::{Command, Stdio}; + + #[test] + fn confine_assigns_running_child_to_job() { + // A child that stays alive briefly so the assignment targets a live + // process; the guard drop then kills it via KILL_ON_JOB_CLOSE. + let mut child = Command::new("cmd") + .args(["/C", "ping", "-n", "5", "127.0.0.1"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn test child"); + + let guard = confine(&child); + assert!(guard.is_some(), "Job Object confinement should succeed"); + + drop(guard); // KILL_ON_JOB_CLOSE terminates the process tree. + let _ = child.kill(); + let _ = child.wait(); } } diff --git a/crates/deep-code-agent/src/shell_tools.rs b/crates/deep-code-agent/src/shell_tools.rs index 310fa5c..efad0f2 100644 --- a/crates/deep-code-agent/src/shell_tools.rs +++ b/crates/deep-code-agent/src/shell_tools.rs @@ -149,6 +149,9 @@ impl Tool for ShellRunTool { message: format!("failed to start command: {error}"), })?; + let job_guard = self + .sandbox + .confine_spawned(&child, ¤t_sandbox_policy()); let stdout = SharedBuffer::default(); let stderr = SharedBuffer::default(); if let Some(pipe) = child.stdout.take() { @@ -168,6 +171,7 @@ impl Tool for ShellRunTool { stdout: stdout.clone(), stderr: stderr.clone(), child: Some(child), + job_guard, }); let foreground_wait_deadline = Instant::now() + Duration::from_millis(SHELL_RUN_STARTUP_WAIT_MS.min(timeout_ms)); @@ -253,6 +257,9 @@ impl Tool for JobStartTool { name: Self::NAME.to_string(), message: format!("failed to start background command: {error}"), })?; + let job_guard = self + .sandbox + .confine_spawned(&child, ¤t_sandbox_policy()); let stdout = SharedBuffer::default(); let stderr = SharedBuffer::default(); if let Some(pipe) = child.stdout.take() { @@ -273,6 +280,7 @@ impl Tool for JobStartTool { stdout, stderr, child: Some(child), + job_guard, }); Ok(ToolResult::success( diff --git a/crates/deep-code-agent/src/shell_tools/jobs.rs b/crates/deep-code-agent/src/shell_tools/jobs.rs index ac68848..8d3bc65 100644 --- a/crates/deep-code-agent/src/shell_tools/jobs.rs +++ b/crates/deep-code-agent/src/shell_tools/jobs.rs @@ -85,6 +85,11 @@ pub(super) struct JobState { pub(super) stdout: SharedBuffer, pub(super) stderr: SharedBuffer, pub(super) child: Option, + /// OS sandbox guard tied to the child (Windows Job Object); dropping it with + /// the job kills the process tree. `None` on macOS/Linux (confined pre-spawn). + /// Held purely for its `Drop` — never read. + #[allow(dead_code)] + pub(super) job_guard: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]