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