Skip to main content

a3s_code_core/sandbox/
process_host.rs

1//! Process-host Bash sandbox for environments that already provide an outer
2//! isolation boundary (Harbor task containers, CI job VMs).
3//!
4//! Unlike [`super::native::NativeBashSandbox`], this backend does not require
5//! bubblewrap / Seatbelt. It still owns process-group termination and bounded
6//! output capture so tool contracts stay intact. It is never the silent
7//! default: hosts must opt in via
8//! [`crate::SessionOptions::with_allow_process_host_sandbox`] or
9//! `A3S_CODE_ALLOW_PROCESS_HOST_SANDBOX=1` when the native backend cannot
10//! initialize.
11
12use super::{BashSandbox, SandboxCommandRequest, SandboxExecutionOutput, SandboxOutput};
13use crate::tools::MAX_OUTPUT_SIZE;
14use anyhow::{Context, Result};
15use async_trait::async_trait;
16use std::collections::VecDeque;
17use std::path::{Path, PathBuf};
18use std::process::Stdio;
19use std::time::{Duration, Instant};
20use tokio::io::AsyncReadExt;
21use tokio::process::{Child, Command};
22
23const MAX_CAPTURE_BYTES: usize = MAX_OUTPUT_SIZE;
24const OUTPUT_HEAD_BYTES: usize = 64 * 1024;
25const READ_CHUNK_BYTES: usize = 8 * 1024;
26const PROCESS_SETTLEMENT_MS: u64 = 500;
27
28/// Run Bash on the host process with process-group and output bounds.
29///
30/// Use only when an outer container / VM already isolates the workspace from
31/// the developer machine. This is the Harbor Terminal-Bench boundary.
32#[derive(Debug)]
33pub struct ProcessHostBashSandbox {
34    workspace: PathBuf,
35    deadline: Option<Instant>,
36}
37
38impl ProcessHostBashSandbox {
39    /// Create a process-host sandbox rooted at `workspace`.
40    ///
41    /// `deadline`, when set, caps every command against a shared run budget
42    /// (Terminal-Bench outer timeout).
43    pub fn new(workspace: impl Into<PathBuf>, deadline: Option<Instant>) -> Self {
44        Self {
45            workspace: workspace.into(),
46            deadline,
47        }
48    }
49
50    pub fn workspace(&self) -> &Path {
51        &self.workspace
52    }
53
54    fn remaining_timeout_ms(&self, requested_ms: u64) -> u64 {
55        let Some(deadline) = self.deadline else {
56            return requested_ms;
57        };
58        let remaining = deadline
59            .saturating_duration_since(Instant::now())
60            .as_millis()
61            .min(u128::from(u64::MAX)) as u64;
62        requested_ms.min(remaining)
63    }
64}
65
66/// Bash invocation for one process-host command.
67///
68/// The command text is not inspected. An absolute path outside `workspace`
69/// stays in the argv. The outer container or VM is the path boundary.
70fn process_host_command(workspace: &Path, command: &str) -> (PathBuf, Vec<String>) {
71    (
72        workspace.to_path_buf(),
73        vec!["-c".to_string(), command.to_string()],
74    )
75}
76
77#[async_trait]
78impl BashSandbox for ProcessHostBashSandbox {
79    async fn exec_command(&self, command: &str, guest_workspace: &str) -> Result<SandboxOutput> {
80        let output = self
81            .exec(SandboxCommandRequest {
82                command: command.to_string(),
83                guest_workspace: guest_workspace.to_string(),
84                timeout_ms: 120_000,
85                output_observer: None,
86                env: None,
87            })
88            .await?;
89        Ok(SandboxOutput {
90            stdout: output.stdout,
91            stderr: output.stderr,
92            exit_code: output.exit_code,
93        })
94    }
95
96    async fn exec(&self, request: SandboxCommandRequest) -> Result<SandboxExecutionOutput> {
97        let timeout_ms = self.remaining_timeout_ms(request.timeout_ms);
98        if timeout_ms == 0 {
99            let output = SandboxExecutionOutput {
100                stdout: String::new(),
101                stderr: "command skipped because the run deadline expired\n".to_string(),
102                exit_code: 124,
103                timed_out: true,
104            };
105            if let Some(observer) = request.output_observer {
106                observer.on_output_delta(&output.stderr).await;
107                observer
108                    .on_output_complete(&crate::workspace::CommandOutputSummary {
109                        total_bytes: output.stderr.len(),
110                        captured_bytes: output.stderr.len(),
111                        truncated: false,
112                        timed_out: true,
113                    })
114                    .await;
115            }
116            return Ok(output);
117        }
118
119        // Non-login `-c`: login shells (`-lc`) source profile scripts that often
120        // exit non-zero on Windows Git Bash / CI images and poison tool exit codes.
121        let (current_dir, args) = process_host_command(&self.workspace, &request.command);
122        let mut shell = Command::new("bash");
123        shell
124            .args(&args)
125            .current_dir(current_dir)
126            .stdin(Stdio::null())
127            .stdout(Stdio::piped())
128            .stderr(Stdio::piped())
129            .kill_on_drop(true);
130        #[cfg(unix)]
131        {
132            // The shell is the process-group leader. A timeout therefore
133            // terminates descendants spawned by scripts, package managers, or
134            // test runners instead of leaving them behind in the outer job.
135            shell.process_group(0);
136        }
137        if let Some(env) = request.env.as_deref() {
138            shell.envs(env);
139        }
140        let mut child = shell.spawn().context("spawn process-host bash")?;
141        let captured =
142            capture_process_output(&mut child, timeout_ms, request.output_observer.as_deref())
143                .await
144                .context("capture process-host bash output")?;
145        let exit_code = if captured.timed_out {
146            124
147        } else {
148            captured
149                .status
150                .and_then(|status| status.code())
151                .unwrap_or(1)
152        };
153        Ok(SandboxExecutionOutput {
154            stdout: captured.stdout,
155            stderr: captured.stderr,
156            exit_code,
157            timed_out: captured.timed_out,
158        })
159    }
160
161    async fn shutdown(&self) {}
162}
163
164#[derive(Clone, Copy)]
165enum OutputStream {
166    Stdout,
167    Stderr,
168}
169
170#[derive(Clone, Copy)]
171struct CapturedByte {
172    stream: OutputStream,
173    byte: u8,
174}
175
176struct BoundedCapture {
177    head: Vec<CapturedByte>,
178    tail: VecDeque<CapturedByte>,
179    total_bytes: usize,
180    stdout_bytes: usize,
181    stderr_bytes: usize,
182}
183
184impl BoundedCapture {
185    fn new() -> Self {
186        Self {
187            head: Vec::with_capacity(OUTPUT_HEAD_BYTES),
188            tail: VecDeque::with_capacity(MAX_CAPTURE_BYTES - OUTPUT_HEAD_BYTES),
189            total_bytes: 0,
190            stdout_bytes: 0,
191            stderr_bytes: 0,
192        }
193    }
194
195    fn push(&mut self, stream: OutputStream, bytes: &[u8]) {
196        self.total_bytes = self.total_bytes.saturating_add(bytes.len());
197        match stream {
198            OutputStream::Stdout => {
199                self.stdout_bytes = self.stdout_bytes.saturating_add(bytes.len())
200            }
201            OutputStream::Stderr => {
202                self.stderr_bytes = self.stderr_bytes.saturating_add(bytes.len())
203            }
204        }
205
206        let head_remaining = OUTPUT_HEAD_BYTES.saturating_sub(self.head.len());
207        let head_bytes = head_remaining.min(bytes.len());
208        self.head
209            .extend(bytes[..head_bytes].iter().map(|byte| CapturedByte {
210                stream,
211                byte: *byte,
212            }));
213        self.tail
214            .extend(bytes[head_bytes..].iter().map(|byte| CapturedByte {
215                stream,
216                byte: *byte,
217            }));
218        while self.tail.len() > MAX_CAPTURE_BYTES - OUTPUT_HEAD_BYTES {
219            self.tail.pop_front();
220        }
221    }
222
223    fn summary(&self, timed_out: bool) -> crate::workspace::CommandOutputSummary {
224        crate::workspace::CommandOutputSummary {
225            total_bytes: self.total_bytes,
226            captured_bytes: self.head.len() + self.tail.len(),
227            truncated: self.total_bytes > MAX_CAPTURE_BYTES,
228            timed_out,
229        }
230    }
231
232    #[cfg(test)]
233    fn render_combined(&self) -> String {
234        let mut rendered = String::new();
235        append_captured_bytes(&mut rendered, self.head.iter().copied());
236        if self.total_bytes > MAX_CAPTURE_BYTES {
237            rendered.push_str(&format!(
238                "\n\n[command output truncated: retained the first {} and last {} of {} bytes]\n\n",
239                self.head.len(),
240                self.tail.len(),
241                self.total_bytes
242            ));
243        }
244        append_captured_bytes(&mut rendered, self.tail.iter().copied());
245        rendered
246    }
247
248    fn render_stream(&self, stream: OutputStream) -> String {
249        let head = self
250            .head
251            .iter()
252            .copied()
253            .filter(|captured| matches_stream(captured.stream, stream))
254            .collect::<Vec<_>>();
255        let tail = self
256            .tail
257            .iter()
258            .copied()
259            .filter(|captured| matches_stream(captured.stream, stream))
260            .collect::<Vec<_>>();
261        let total_bytes = match stream {
262            OutputStream::Stdout => self.stdout_bytes,
263            OutputStream::Stderr => self.stderr_bytes,
264        };
265        let mut rendered = String::new();
266        append_captured_bytes(&mut rendered, head.iter().copied());
267        if total_bytes > head.len() + tail.len() {
268            let label = match stream {
269                OutputStream::Stdout => "stdout",
270                OutputStream::Stderr => "stderr",
271            };
272            rendered.push_str(&format!(
273                "\n\n[command {label} truncated by the global output limit: retained the first {} and last {} of {} bytes]\n\n",
274                head.len(),
275                tail.len(),
276                total_bytes
277            ));
278        }
279        append_captured_bytes(&mut rendered, tail.iter().copied());
280        rendered
281    }
282}
283
284fn matches_stream(left: OutputStream, right: OutputStream) -> bool {
285    matches!(
286        (left, right),
287        (OutputStream::Stdout, OutputStream::Stdout) | (OutputStream::Stderr, OutputStream::Stderr)
288    )
289}
290
291fn append_captured_bytes(rendered: &mut String, captured: impl IntoIterator<Item = CapturedByte>) {
292    let bytes = captured
293        .into_iter()
294        .map(|captured| captured.byte)
295        .collect::<Vec<_>>();
296    rendered.push_str(&String::from_utf8_lossy(&bytes));
297}
298
299struct CapturedProcessOutput {
300    stdout: String,
301    stderr: String,
302    status: Option<std::process::ExitStatus>,
303    timed_out: bool,
304}
305
306async fn capture_process_output(
307    child: &mut Child,
308    timeout_ms: u64,
309    observer: Option<&dyn crate::workspace::CommandOutputObserver>,
310) -> std::io::Result<CapturedProcessOutput> {
311    let mut stdout = child
312        .stdout
313        .take()
314        .ok_or_else(|| std::io::Error::other("child stdout was not piped"))?;
315    let mut stderr = child
316        .stderr
317        .take()
318        .ok_or_else(|| std::io::Error::other("child stderr was not piped"))?;
319    let mut process_group = ProcessGroupGuard::for_child(child);
320    let mut capture = BoundedCapture::new();
321    let mut stdout_done = false;
322    let mut stderr_done = false;
323    let mut stdout_buffer = vec![0_u8; READ_CHUNK_BYTES];
324    let mut stderr_buffer = vec![0_u8; READ_CHUNK_BYTES];
325
326    let execution = tokio::time::timeout(Duration::from_millis(timeout_ms.max(1)), async {
327        while !stdout_done || !stderr_done {
328            tokio::select! {
329                read = stdout.read(&mut stdout_buffer), if !stdout_done => {
330                    match read {
331                        Ok(0) => stdout_done = true,
332                        Ok(count) => {
333                            let bytes = &stdout_buffer[..count];
334                            capture.push(OutputStream::Stdout, bytes);
335                            if let Some(observer) = observer {
336                                observer.on_output_delta(&String::from_utf8_lossy(bytes)).await;
337                            }
338                        }
339                        Err(error) => {
340                            let message = format!("\n[failed to read command stdout: {error}]\n");
341                            capture.push(OutputStream::Stderr, message.as_bytes());
342                            stdout_done = true;
343                        }
344                    }
345                }
346                read = stderr.read(&mut stderr_buffer), if !stderr_done => {
347                    match read {
348                        Ok(0) => stderr_done = true,
349                        Ok(count) => {
350                            let bytes = &stderr_buffer[..count];
351                            capture.push(OutputStream::Stderr, bytes);
352                            if let Some(observer) = observer {
353                                observer.on_output_delta(&String::from_utf8_lossy(bytes)).await;
354                            }
355                        }
356                        Err(error) => {
357                            let message = format!("\n[failed to read command stderr: {error}]\n");
358                            capture.push(OutputStream::Stderr, message.as_bytes());
359                            stderr_done = true;
360                        }
361                    }
362                }
363            }
364        }
365        child.wait().await
366    })
367    .await;
368
369    let (status, timed_out) = match execution {
370        Ok(status) => {
371            process_group.disarm();
372            (Some(status?), false)
373        }
374        Err(_) => {
375            process_group.kill();
376            child.start_kill().ok();
377            let status = match tokio::time::timeout(
378                Duration::from_millis(PROCESS_SETTLEMENT_MS),
379                child.wait(),
380            )
381            .await
382            {
383                Ok(Ok(status)) => Some(status),
384                Ok(Err(_)) | Err(_) => None,
385            };
386            (status, true)
387        }
388    };
389
390    let summary = capture.summary(timed_out);
391    if let Some(observer) = observer {
392        observer.on_output_complete(&summary).await;
393    }
394    Ok(CapturedProcessOutput {
395        stdout: capture.render_stream(OutputStream::Stdout),
396        stderr: capture.render_stream(OutputStream::Stderr),
397        status,
398        timed_out,
399    })
400}
401
402struct ProcessGroupGuard {
403    #[cfg(unix)]
404    process_group: Option<i32>,
405}
406
407impl ProcessGroupGuard {
408    fn for_child(child: &Child) -> Self {
409        #[cfg(unix)]
410        {
411            Self {
412                process_group: child.id().and_then(|id| i32::try_from(id).ok()),
413            }
414        }
415        #[cfg(not(unix))]
416        {
417            let _ = child;
418            Self {}
419        }
420    }
421
422    fn kill(&mut self) {
423        #[cfg(unix)]
424        if let Some(process_group) = self.process_group.take() {
425            let _ = unsafe { libc::kill(-process_group, libc::SIGKILL) };
426        }
427    }
428
429    fn disarm(&mut self) {
430        #[cfg(unix)]
431        {
432            self.process_group = None;
433        }
434    }
435}
436
437impl Drop for ProcessGroupGuard {
438    fn drop(&mut self) {
439        self.kill();
440    }
441}
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446    use std::sync::Arc;
447    use tokio::sync::Mutex;
448
449    struct RecordingObserver {
450        deltas: Mutex<Vec<String>>,
451        completed: Mutex<Option<crate::workspace::CommandOutputSummary>>,
452    }
453
454    #[async_trait]
455    impl crate::workspace::CommandOutputObserver for RecordingObserver {
456        async fn on_output_delta(&self, delta: &str) {
457            self.deltas.lock().await.push(delta.to_string());
458        }
459
460        async fn on_output_complete(&self, summary: &crate::workspace::CommandOutputSummary) {
461            *self.completed.lock().await = Some(summary.clone());
462        }
463    }
464
465    #[test]
466    fn bounded_capture_keeps_global_memory_with_head_and_tail() {
467        let mut capture = BoundedCapture::new();
468        let input = vec![b'x'; MAX_CAPTURE_BYTES + 1];
469        capture.push(OutputStream::Stdout, &input);
470        assert_eq!(capture.head.len() + capture.tail.len(), MAX_CAPTURE_BYTES);
471        assert_eq!(capture.total_bytes, MAX_CAPTURE_BYTES + 1);
472        assert!(capture.summary(false).truncated);
473        assert!(capture
474            .render_combined()
475            .contains("command output truncated"));
476    }
477
478    #[test]
479    fn bounded_capture_labels_stderr_truncation() {
480        let mut capture = BoundedCapture::new();
481        let input = vec![b'y'; MAX_CAPTURE_BYTES + 1];
482        capture.push(OutputStream::Stderr, &input);
483        let rendered = capture.render_stream(OutputStream::Stderr);
484        assert!(
485            rendered.contains("command stderr truncated"),
486            "stderr truncation marker missing: {rendered}"
487        );
488    }
489
490    #[test]
491    fn workspace_accessor_returns_configured_root() {
492        let directory = tempfile::tempdir().expect("temporary directory");
493        let sandbox = ProcessHostBashSandbox::new(directory.path().to_path_buf(), None);
494        assert_eq!(sandbox.workspace(), directory.path());
495    }
496
497    #[test]
498    fn process_host_does_not_filter_commands_that_name_paths_outside_the_workspace() {
499        let workspace = Path::new(r"C:\workspace");
500        let command = "printf OUTSIDE-91 > /tmp/outside-91.txt";
501        let (current_dir, args) = process_host_command(workspace, command);
502        assert_eq!(current_dir, workspace);
503        assert_eq!(args, vec!["-c".to_string(), command.to_string()]);
504        assert!(
505            args[1].contains("/tmp/outside-91.txt"),
506            "outside path must stay in the command; the outer isolator owns the deny"
507        );
508    }
509
510    #[tokio::test]
511    async fn shutdown_is_a_noop() {
512        let directory = tempfile::tempdir().expect("temporary directory");
513        let sandbox = ProcessHostBashSandbox::new(directory.path().to_path_buf(), None);
514        sandbox.shutdown().await;
515    }
516
517    #[tokio::test]
518    async fn expired_deadline_skips_spawn_and_notifies_observer() {
519        let directory = tempfile::tempdir().expect("temporary directory");
520        let sandbox = ProcessHostBashSandbox::new(
521            directory.path().to_path_buf(),
522            Some(Instant::now() - Duration::from_secs(1)),
523        );
524        let observer = Arc::new(RecordingObserver {
525            deltas: Mutex::new(Vec::new()),
526            completed: Mutex::new(None),
527        });
528        let output = sandbox
529            .exec(SandboxCommandRequest {
530                command: "printf should-not-run".into(),
531                guest_workspace: directory.path().to_string_lossy().into_owned(),
532                timeout_ms: 5_000,
533                output_observer: Some(observer.clone()),
534                env: None,
535            })
536            .await
537            .expect("deadline skip must succeed");
538        assert!(output.timed_out);
539        assert_eq!(output.exit_code, 124);
540        assert!(output.stderr.contains("run deadline expired"));
541        assert!(!observer.deltas.lock().await.is_empty());
542        assert!(observer.completed.lock().await.is_some());
543    }
544
545    // Real bash pipelines are Harbor/Unix. BoundedCapture itself is covered
546    // hermetically above; Windows product bash is PowerShell.
547    #[cfg(unix)]
548    #[tokio::test]
549    async fn high_volume_output_is_bounded_before_returning_to_the_tool() {
550        let directory = tempfile::tempdir().expect("temporary directory");
551        let sandbox = ProcessHostBashSandbox::new(
552            directory.path().to_path_buf(),
553            Some(Instant::now() + Duration::from_secs(5)),
554        );
555        let output = sandbox
556            .exec(SandboxCommandRequest {
557                command: "yes x | head -c 200000".to_string(),
558                guest_workspace: directory.path().to_string_lossy().into_owned(),
559                timeout_ms: 5_000,
560                output_observer: None,
561                env: None,
562            })
563            .await
564            .expect("sandbox execution");
565        assert!(
566            !output.timed_out,
567            "high-volume capture timed out: exit={} stderr={}",
568            output.exit_code, output.stderr
569        );
570        assert!(
571            output.stdout.contains("command stdout truncated"),
572            "expected truncation marker; exit={} stdout_len={} stderr={}",
573            output.exit_code,
574            output.stdout.len(),
575            output.stderr
576        );
577        assert!(output.stdout.len() <= MAX_CAPTURE_BYTES + 256);
578    }
579
580    #[cfg(unix)]
581    #[tokio::test]
582    async fn timeout_kills_process_group_after_streams_close() {
583        let directory = tempfile::tempdir().expect("temporary directory");
584        let sandbox = ProcessHostBashSandbox::new(
585            directory.path().to_path_buf(),
586            Some(Instant::now() + Duration::from_secs(5)),
587        );
588        let output = sandbox
589            .exec(SandboxCommandRequest {
590                command: "exec 1>&- 2>&-; (sleep 0.3; touch leaked) & wait".to_string(),
591                guest_workspace: directory.path().to_string_lossy().into_owned(),
592                timeout_ms: 20,
593                output_observer: None,
594                env: None,
595            })
596            .await
597            .expect("sandbox execution");
598        assert!(output.timed_out);
599        tokio::time::sleep(Duration::from_millis(400)).await;
600        assert!(!directory.path().join("leaked").exists());
601    }
602
603    #[cfg(unix)]
604    #[tokio::test]
605    async fn exec_command_and_observer_cover_stream_capture() {
606        let directory = tempfile::tempdir().expect("temporary directory");
607        let sandbox = ProcessHostBashSandbox::new(directory.path().to_path_buf(), None);
608        let observer = Arc::new(RecordingObserver {
609            deltas: Mutex::new(Vec::new()),
610            completed: Mutex::new(None),
611        });
612        let output = sandbox
613            .exec(SandboxCommandRequest {
614                command: "printf 'out\\n'; printf 'err\\n' >&2".into(),
615                guest_workspace: directory.path().to_string_lossy().into_owned(),
616                timeout_ms: 5_000,
617                output_observer: Some(observer.clone()),
618                env: Some(Arc::new(std::collections::HashMap::from([(
619                    "PROCESS_HOST_COV".into(),
620                    "1".into(),
621                )]))),
622            })
623            .await
624            .expect("process-host exec");
625        assert_eq!(output.exit_code, 0, "stderr={}", output.stderr);
626        assert!(output.stdout.contains("out"));
627        assert!(output.stderr.contains("err"));
628        assert!(!observer.deltas.lock().await.is_empty());
629        assert!(observer.completed.lock().await.is_some());
630
631        let via_exec_command = sandbox
632            .exec_command("printf 'via-exec-command\\n'", "/workspace")
633            .await
634            .expect("exec_command");
635        assert_eq!(via_exec_command.exit_code, 0);
636        assert!(via_exec_command.stdout.contains("via-exec-command"));
637    }
638}