Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions crates/deep-code-agent/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion crates/deep-code-agent/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
33 changes: 32 additions & 1 deletion crates/deep-code-agent/src/sandbox/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<SandboxGuard> {
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 {
Expand Down
123 changes: 120 additions & 3 deletions crates/deep-code-agent/src/sandbox/windows.rs
Original file line number Diff line number Diff line change
@@ -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<JobGuard> {
// 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();
}
}
8 changes: 8 additions & 0 deletions crates/deep-code-agent/src/shell_tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,9 @@ impl Tool for ShellRunTool {
message: format!("failed to start command: {error}"),
})?;

let job_guard = self
.sandbox
.confine_spawned(&child, &current_sandbox_policy());
let stdout = SharedBuffer::default();
let stderr = SharedBuffer::default();
if let Some(pipe) = child.stdout.take() {
Expand All @@ -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));
Expand Down Expand Up @@ -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, &current_sandbox_policy());
let stdout = SharedBuffer::default();
let stderr = SharedBuffer::default();
if let Some(pipe) = child.stdout.take() {
Expand All @@ -273,6 +280,7 @@ impl Tool for JobStartTool {
stdout,
stderr,
child: Some(child),
job_guard,
});

Ok(ToolResult::success(
Expand Down
5 changes: 5 additions & 0 deletions crates/deep-code-agent/src/shell_tools/jobs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,11 @@ pub(super) struct JobState {
pub(super) stdout: SharedBuffer,
pub(super) stderr: SharedBuffer,
pub(super) child: Option<Child>,
/// 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<crate::sandbox::SandboxGuard>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
Expand Down
Loading