Skip to main content

vtcode_bash_runner/
process_owner.rs

1//! Native command lifetime support for a binary-owned task cleanup endpoint.
2//!
3//! This is cleanup, not command admission: callers must validate the original
4//! command and select its sandbox launcher before applying this helper.
5
6use std::ffi::{OsStr, OsString};
7use std::io::{self, Read};
8use std::os::fd::AsRawFd;
9use std::os::unix::net::UnixStream;
10use std::os::unix::process::CommandExt;
11use std::path::{Path, PathBuf};
12use std::time::Duration;
13
14/// Private task endpoint inherited by VT Code's managed command launchers.
15pub const OWNER_SOCKET_ENV: &str = "VTCODE_COMPANION_OWNER_SOCKET";
16/// Private native entry point shared with the binary-owned task launcher.
17pub const HELPER_MODE: &str = "--vtcode-owned-exec";
18/// Prefix of the private directory that contains the task owner endpoint.
19pub const OWNER_DIRECTORY_PREFIX: &str = "vtc-";
20/// Basename of the task owner endpoint inside its private directory.
21pub const OWNER_SOCKET_FILE: &str = "owner";
22const READY: u8 = 1;
23
24/// Exact argv for the shipped native helper. Original argv is never interpreted
25/// as shell syntax; `arg0` is restored when replacing the helper with the target.
26pub struct OwnedCommand {
27    pub executable: PathBuf,
28    pub arguments: Vec<OsString>,
29    pub owner_socket: PathBuf,
30}
31
32impl OwnedCommand {
33    pub fn new(
34        executable: &Path,
35        owner_socket: &Path,
36        program: &OsStr,
37        args: impl IntoIterator<Item = impl AsRef<OsStr>>,
38        arg0: Option<&OsStr>,
39    ) -> Self {
40        let mut arguments = vec![
41            OsString::from(HELPER_MODE),
42            arg0.unwrap_or(program).to_owned(),
43            program.to_owned(),
44        ];
45        arguments.extend(args.into_iter().map(|arg| arg.as_ref().to_owned()));
46        Self {
47            executable: executable.to_owned(),
48            arguments,
49            owner_socket: owner_socket.to_owned(),
50        }
51    }
52
53    /// Wrap `program` when this process is itself a managed command whose
54    /// private task endpoint is still present.
55    ///
56    /// [`OWNER_SOCKET_ENV`] is inherited from the process environment, so a
57    /// spurious, stale, or forged value must not be honored: wrapping an
58    /// endpoint this process does not own fails closed inside the helper and
59    /// would disable every managed command. A value that is not a live
60    /// [`OWNER_SOCKET_ENV`] endpoint created by this user's task supervisor is
61    /// therefore ignored, and the command spawns normally.
62    pub fn from_environment(
63        program: &OsStr,
64        args: impl IntoIterator<Item = impl AsRef<OsStr>>,
65        arg0: Option<&OsStr>,
66    ) -> io::Result<Option<Self>> {
67        let Some(socket) = std::env::var_os(OWNER_SOCKET_ENV) else {
68            return Ok(None);
69        };
70        let socket = PathBuf::from(socket);
71        if !is_owned_endpoint(&socket) {
72            return Ok(None);
73        }
74        Ok(Some(Self::new(&std::env::current_exe()?, &socket, program, args, arg0)))
75    }
76}
77
78/// Whether `socket` belongs to a live task owner created by this user.
79///
80/// Provenance is structural, matching how the supervisor creates the endpoint:
81/// a Unix socket named [`OWNER_SOCKET_FILE`] directly inside a directory that
82/// is prefixed [`OWNER_DIRECTORY_PREFIX`], owned by the current user, granted
83/// owner-only permissions, and located under the system temp root. Every part
84/// must hold, so a path the supervisor could not have produced is rejected.
85///
86/// Exposed so the binary-owned supervisor can assert that the endpoint it
87/// publishes satisfies the launcher's provenance contract.
88pub fn is_owned_endpoint(socket: &Path) -> bool {
89    is_owned_endpoint_under(socket, &std::env::temp_dir())
90}
91
92/// [`is_owned_endpoint`] with an explicit temp root, so the containment gate is
93/// testable without mutating the process environment.
94fn is_owned_endpoint_under(socket: &Path, temp_root: &Path) -> bool {
95    use std::os::unix::fs::{FileTypeExt, MetadataExt, PermissionsExt};
96
97    if !socket.is_absolute() || socket.file_name() != Some(OsStr::new(OWNER_SOCKET_FILE)) {
98        return false;
99    }
100    // `symlink_metadata` keeps a symlink from standing in for a real endpoint.
101    let Ok(endpoint) = std::fs::symlink_metadata(socket) else {
102        return false;
103    };
104    if !endpoint.file_type().is_socket() {
105        return false;
106    }
107    let Some(directory) = socket.parent() else {
108        return false;
109    };
110    let Ok(directory_metadata) = std::fs::metadata(directory) else {
111        return false;
112    };
113    let owner_only = directory_metadata.permissions().mode() & 0o077 == 0;
114    let owned_by_self = directory_metadata.is_dir() && directory_metadata.uid() == nix::unistd::geteuid().as_raw();
115    if !owner_only || !owned_by_self {
116        return false;
117    }
118    if !directory
119        .file_name()
120        .and_then(OsStr::to_str)
121        .is_some_and(|name| name.starts_with(OWNER_DIRECTORY_PREFIX))
122    {
123        return false;
124    }
125    let Ok(directory) = vtcode_commons::canonicalize(directory) else {
126        return false;
127    };
128    let temp_root = vtcode_commons::canonicalize(temp_root).unwrap_or_else(|_| temp_root.to_path_buf());
129    directory.starts_with(temp_root)
130}
131
132/// Dispatch before agent environment hardening, worker threads, tracing, or the async runtime.
133/// A successful helper replaces itself with the admitted program and does not
134/// return. A missing/closed endpoint fails before the target can execute.
135pub fn maybe_run_owned_command() -> io::Result<bool> {
136    let args: Vec<_> = std::env::args_os().skip(1).collect();
137    if args.first().is_none_or(|arg| arg != HELPER_MODE) {
138        return Ok(false);
139    }
140    let arg0 = args
141        .get(1)
142        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "missing target argv[0]"))?;
143    let program = args
144        .get(2)
145        .filter(|p| !p.is_empty())
146        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "missing target program"))?;
147    if nix::unistd::getpgrp() != nix::unistd::getpid() {
148        return Err(io::Error::new(io::ErrorKind::InvalidInput, "owned command requires an isolated process group"));
149    }
150    let socket = std::env::var_os(OWNER_SOCKET_ENV)
151        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "missing task owner endpoint"))?;
152    let mut owner = UnixStream::connect(socket)?;
153    owner.set_read_timeout(Some(Duration::from_secs(2)))?;
154    let mut acknowledgement = [0];
155    owner.read_exact(&mut acknowledgement)?;
156    if acknowledgement != [READY] {
157        return Err(io::Error::new(io::ErrorKind::InvalidData, "invalid task owner acknowledgement"));
158    }
159    // The monitor has no readiness timeout: it waits for EOF throughout the
160    // command's lifetime. The two-second timeout only bounds startup above.
161    owner.set_read_timeout(None)?;
162    spawn_watchdog(&owner)?;
163    drop(owner);
164    let error = std::process::Command::new(program).arg0(arg0).args(args.iter().skip(3)).exec();
165    Err(error)
166}
167
168#[expect(
169    unsafe_code,
170    reason = "Fork children execute only async-signal-safe syscalls and exit without Rust destructors"
171)]
172fn spawn_watchdog(owner: &UnixStream) -> io::Result<()> {
173    let (mut ready_reader, ready_writer) = UnixStream::pair()?;
174    ready_reader.set_read_timeout(Some(Duration::from_secs(2)))?;
175    let owner_fd = owner.as_raw_fd();
176    let ready_fd = ready_writer.as_raw_fd();
177    if owner_fd <= 2 || ready_fd <= 2 || ready_reader.as_raw_fd() <= 2 {
178        return Err(io::Error::new(io::ErrorKind::InvalidInput, "owned command requires open standard streams"));
179    }
180    let group = nix::unistd::getpgrp().as_raw();
181    // SAFETY: The child branches use only fork, close, sigaction, read, write,
182    // kill, and _exit. They never allocate, lock, unwind, or run Rust drops.
183    let intermediate = unsafe { libc::fork() };
184    if intermediate == -1 {
185        return Err(io::Error::last_os_error());
186    }
187    if intermediate == 0 {
188        // SAFETY: The child branches below use only async-signal-safe calls
189        // and never return to Rust. Double-forking keeps shell wait independent.
190        let monitor = unsafe { libc::fork() };
191        if monitor == -1 {
192            // SAFETY: Exit immediately without running fork-inherited drops.
193            unsafe { libc::_exit(1) };
194        }
195        if monitor != 0 {
196            // SAFETY: The intermediate child has no further work or cleanup.
197            unsafe { libc::_exit(0) };
198        }
199        // SAFETY: Close only this child's copy of the validated socket fd.
200        unsafe { libc::close(ready_reader.as_raw_fd()) };
201        // SAFETY: Valid inherited fds and the isolated positive group ID were
202        // established before fork. The monitor cannot return or run drops.
203        unsafe { run_watchdog(owner_fd, ready_fd, group) };
204    }
205    drop(ready_writer);
206    let mut status = 0;
207    loop {
208        // SAFETY: `status` is writable and this waits only for our intermediate
209        // child. The monitor is reparented and never belongs to the command's
210        // child-wait relationship.
211        let waited = unsafe { libc::waitpid(intermediate, &mut status, 0) };
212        if waited == intermediate {
213            break;
214        }
215        let error = io::Error::last_os_error();
216        if error.kind() != io::ErrorKind::Interrupted {
217            return Err(error);
218        }
219    }
220    if !libc::WIFEXITED(status) || libc::WEXITSTATUS(status) != 0 {
221        return Err(io::Error::other("failed to start command lifetime watchdog"));
222    }
223    let mut ready = [0];
224    ready_reader.read_exact(&mut ready)?;
225    if ready != [READY] {
226        return Err(io::Error::other("command lifetime watchdog did not initialize"));
227    }
228    Ok(())
229}
230
231/// # Safety
232/// Called only in the double-forked child with valid borrowed socket fds above
233/// stdio and a positive, isolated group ID. Never returns or drops Rust values.
234#[expect(
235    unsafe_code,
236    reason = "Minimal fork-child watchdog uses only async-signal-safe POSIX calls"
237)]
238unsafe fn run_watchdog(owner_fd: libc::c_int, ready_fd: libc::c_int, group: libc::pid_t) -> ! {
239    // SAFETY: POSIX sigaction has a valid all-zero representation before its
240    // handler and mask are initialized below.
241    let mut ignore: libc::sigaction = unsafe { std::mem::zeroed() };
242    ignore.sa_sigaction = libc::SIG_IGN;
243    // SAFETY: The pointer addresses a writable stack-owned signal mask.
244    unsafe { libc::sigemptyset(&mut ignore.sa_mask) };
245    for signal in [libc::SIGINT, libc::SIGTERM, libc::SIGHUP, libc::SIGPIPE] {
246        // SAFETY: Supported signal numbers and a fully initialized action;
247        // ignoring cancellation pins group identity until SIGKILL or owner EOF.
248        if unsafe { libc::sigaction(signal, &ignore, std::ptr::null_mut()) } == -1 {
249            // SAFETY: Failed monitor initialization must not run Rust drops.
250            unsafe { libc::_exit(1) };
251        }
252    }
253    for fd in 0..=2 {
254        // SAFETY: Close only this child's standard descriptors, never the
255        // validated lifetime socket fds above stdio.
256        unsafe { libc::close(fd) };
257    }
258    let ready = READY;
259    // SAFETY: The borrowed socket fd is valid and the pointer addresses one
260    // initialized byte on this child's stack.
261    if unsafe { libc::write(ready_fd, std::ptr::addr_of!(ready).cast(), 1) } != 1 {
262        // SAFETY: Failed readiness must exit without Rust drops.
263        unsafe { libc::_exit(1) };
264    }
265    // SAFETY: Only this child's readiness descriptor copy is closed.
266    unsafe { libc::close(ready_fd) };
267    let mut byte = 0u8;
268    loop {
269        // SAFETY: The owner fd remains valid; the pointer is a writable byte.
270        let read = unsafe { libc::read(owner_fd, std::ptr::addr_of_mut!(byte).cast(), 1) };
271        if read == -1 && nix::errno::Errno::last() == nix::errno::Errno::EINTR {
272            continue;
273        }
274        // EOF, invalid protocol data, and I/O errors all fail closed.
275        // SAFETY: This monitor remains in the positive isolated group it pins;
276        // negating that group targets only its own managed command group.
277        unsafe { libc::kill(-group, libc::SIGKILL) };
278        // SAFETY: Even if kill fails, never return into fork-inherited Rust.
279        unsafe { libc::_exit(1) };
280    }
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286    use assert_fs::TempDir;
287    use std::os::unix::fs::{PermissionsExt, symlink};
288    use std::os::unix::net::UnixListener;
289
290    fn private_directory(root: &Path, name: &str) -> PathBuf {
291        let path = root.join(name);
292        std::fs::create_dir(&path).expect("create private directory");
293        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).expect("owner-only directory");
294        path
295    }
296
297    fn bind_endpoint(directory: &Path, name: &str) -> PathBuf {
298        let socket = directory.join(name);
299        // The bound path stays a socket file after the listener is dropped.
300        drop(UnixListener::bind(&socket).expect("bind endpoint"));
301        socket
302    }
303
304    #[test]
305    fn accepts_a_supervisor_shaped_endpoint() {
306        let root = TempDir::new().expect("temp root");
307        let directory = private_directory(root.path(), "vtc-accepted");
308        let socket = bind_endpoint(&directory, OWNER_SOCKET_FILE);
309
310        assert!(is_owned_endpoint(&socket));
311        assert!(is_owned_endpoint_under(&socket, &std::env::temp_dir()));
312    }
313
314    #[test]
315    fn ignores_a_stale_or_invalid_endpoint() {
316        let root = TempDir::new().expect("temp root");
317        let missing = root.path().join("vtc-stale").join(OWNER_SOCKET_FILE);
318        // A stale value points at an endpoint the supervisor has removed.
319        assert!(!is_owned_endpoint(&missing));
320
321        let directory = private_directory(root.path(), "vtc-stale");
322        std::fs::write(&missing, b"not a socket").expect("regular file");
323        assert!(!is_owned_endpoint(&missing));
324
325        let _foreign = bind_endpoint(&directory, "endpoint");
326        assert!(!is_owned_endpoint(&directory.join("endpoint")));
327        assert!(!is_owned_endpoint(Path::new(OWNER_SOCKET_FILE)));
328    }
329
330    #[test]
331    fn ignores_an_endpoint_outside_a_private_directory() {
332        let root = TempDir::new().expect("temp root");
333        let directory = root.path().join("vtc-public");
334        std::fs::create_dir(&directory).expect("public directory");
335        std::fs::set_permissions(&directory, std::fs::Permissions::from_mode(0o755)).expect("world-accessible");
336        let socket = bind_endpoint(&directory, OWNER_SOCKET_FILE);
337
338        assert!(!is_owned_endpoint(&socket));
339    }
340
341    #[test]
342    fn ignores_a_directory_the_supervisor_did_not_name() {
343        let root = TempDir::new().expect("temp root");
344        let directory = private_directory(root.path(), "other-owner");
345        let socket = bind_endpoint(&directory, OWNER_SOCKET_FILE);
346
347        assert!(!is_owned_endpoint(&socket));
348    }
349
350    #[test]
351    fn ignores_a_symlinked_endpoint() {
352        let root = TempDir::new().expect("temp root");
353        let target = bind_endpoint(root.path(), "target.sock");
354        let directory = private_directory(root.path(), "vtc-symlinked");
355        let socket = directory.join(OWNER_SOCKET_FILE);
356        symlink(&target, &socket).expect("symlink endpoint");
357
358        assert!(!is_owned_endpoint(&socket));
359    }
360
361    #[test]
362    fn ignores_an_endpoint_outside_the_temp_root() {
363        let root = TempDir::new().expect("temp root");
364        let other_root = TempDir::new().expect("other root");
365        let directory = private_directory(root.path(), "vtc-containment");
366        let socket = bind_endpoint(&directory, OWNER_SOCKET_FILE);
367
368        assert!(is_owned_endpoint(&socket));
369        assert!(!is_owned_endpoint_under(&socket, other_root.path()));
370    }
371}