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