1use 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
28pub const MAX_OUTPUT_SIZE: usize = 100 * 1024;
30
31pub 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#[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#[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#[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#[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#[derive(Debug)]
90pub struct NativeSandbox {
91 workspace: PathBuf,
92 platform: platform::PlatformSandbox,
93}
94
95impl NativeSandbox {
96 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 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 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 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;