Skip to main content

a3s_sandbox/
lib.rs

1//! Cross-platform native command isolation for A3S.
2//!
3//! Platform backends are implemented with Seatbelt on macOS, namespaces and
4//! seccomp on Linux, and AppContainer plus Job Objects on Windows. Unsupported
5//! targets fail closed. The crate does not depend on A3S Code or any product
6//! host, so policy and lifecycle semantics remain reusable.
7
8use anyhow::{bail, Context, Result};
9use async_trait::async_trait;
10use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12use std::sync::Arc;
13
14mod platform;
15mod policy;
16mod process;
17
18pub use policy::{
19    hard_link_count, hard_link_count_for_open_file, is_protected_workspace_path, sensitive_paths,
20    should_skip_workspace_scan_directory, workspace_hardlink_paths, workspace_sensitive_paths,
21    PROTECTED_WORKSPACE_DIRECTORIES, PROTECTED_WORKSPACE_FILES,
22};
23
24const DEFAULT_TIMEOUT_MS: u64 = 120_000;
25const PROBE_TIMEOUT_MS: u64 = 30_000;
26const PROBE_MARKER: &str = "a3s-native-sandbox-ready";
27
28/// Maximum stdout and stderr bytes retained for a command.
29pub const MAX_OUTPUT_SIZE: usize = 100 * 1024;
30
31/// Native backend selected for the current target.
32pub const NATIVE_SANDBOX_BACKEND: &str = if cfg!(target_os = "macos") {
33    "macos-seatbelt"
34} else if cfg!(target_os = "linux") {
35    "linux-namespace-seccomp"
36} else if cfg!(windows) {
37    "windows-appcontainer"
38} else {
39    "unsupported"
40};
41
42/// Final accounting for bounded command output.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub struct OutputSummary {
45    pub total_bytes: usize,
46    pub captured_bytes: usize,
47    pub truncated: bool,
48    pub timed_out: bool,
49}
50
51/// Observer for live command output and final capture accounting.
52#[async_trait]
53pub trait OutputObserver: Send + Sync {
54    async fn on_output_delta(&self, delta: &str);
55
56    async fn on_output_complete(&self, _summary: &OutputSummary) {}
57}
58
59/// Complete command execution request.
60#[derive(Clone)]
61pub struct CommandRequest {
62    pub command: String,
63    pub timeout_ms: u64,
64    pub output_observer: Option<Arc<dyn OutputObserver>>,
65    pub env: Option<Arc<HashMap<String, String>>>,
66}
67
68impl std::fmt::Debug for CommandRequest {
69    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70        f.debug_struct("CommandRequest")
71            .field("command", &self.command)
72            .field("timeout_ms", &self.timeout_ms)
73            .field("output_observer", &self.output_observer.is_some())
74            .field("env", &self.env.as_ref().map(|env| env.len()))
75            .finish()
76    }
77}
78
79/// Result of a command executed inside the native boundary.
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct CommandOutput {
82    pub stdout: String,
83    pub stderr: String,
84    pub exit_code: i32,
85    pub timed_out: bool,
86}
87
88/// A fail-closed native sandbox bound to one canonical workspace.
89#[derive(Debug)]
90pub struct NativeSandbox {
91    workspace: PathBuf,
92    platform: platform::PlatformSandbox,
93}
94
95impl NativeSandbox {
96    /// Resolve a workspace and initialize the current platform boundary.
97    pub fn new(workspace: impl Into<PathBuf>) -> Result<Self> {
98        let workspace = workspace
99            .into()
100            .canonicalize()
101            .context("failed to canonicalize the native sandbox workspace")?;
102        if !workspace.is_dir() {
103            bail!(
104                "native sandbox workspace is not a directory: {}",
105                workspace.display()
106            );
107        }
108        let platform = platform::PlatformSandbox::new(&workspace)?;
109        Ok(Self {
110            workspace,
111            platform,
112        })
113    }
114
115    pub fn workspace(&self) -> &Path {
116        &self.workspace
117    }
118
119    pub fn backend(&self) -> &'static str {
120        NATIVE_SANDBOX_BACKEND
121    }
122
123    /// Prove that the selected operating-system boundary can start a command.
124    pub async fn probe(&self) -> Result<()> {
125        #[cfg(windows)]
126        let command = format!("[Console]::Out.Write('{PROBE_MARKER}')");
127        #[cfg(not(windows))]
128        let command = format!("printf %s {PROBE_MARKER}");
129
130        let output = self
131            .execute(CommandRequest {
132                command,
133                timeout_ms: PROBE_TIMEOUT_MS,
134                output_observer: None,
135                env: None,
136            })
137            .await
138            .context("native sandbox capability probe failed")?;
139        if output.timed_out {
140            bail!("native sandbox capability probe timed out");
141        }
142        if output.exit_code != 0 || output.stdout != PROBE_MARKER {
143            bail!(
144                "native sandbox capability probe returned exit code {} with stdout {:?} and stderr {:?}",
145                output.exit_code,
146                output.stdout,
147                output.stderr
148            );
149        }
150        Ok(())
151    }
152
153    /// Execute a command with a default two-minute deadline.
154    pub async fn exec_command(&self, command: impl Into<String>) -> Result<CommandOutput> {
155        self.execute(CommandRequest {
156            command: command.into(),
157            timeout_ms: DEFAULT_TIMEOUT_MS,
158            output_observer: None,
159            env: None,
160        })
161        .await
162    }
163
164    /// Execute a command inside the configured native boundary.
165    pub async fn execute(&self, request: CommandRequest) -> Result<CommandOutput> {
166        if request.timeout_ms == 0 {
167            bail!("native sandbox command timeout must be greater than zero");
168        }
169        if request.command.contains('\0') {
170            bail!("native sandbox command contains a NUL byte");
171        }
172        let scratch = tempfile::Builder::new()
173            .prefix("a3s-sandbox-")
174            .tempdir()
175            .context("failed to create native sandbox scratch directory")?;
176        let policy = policy::SandboxPolicy::for_execution(&self.workspace, scratch.path())?;
177        self.platform.execute(&policy, request).await
178    }
179}
180
181#[cfg(test)]
182mod tests;