1use anyhow::{bail, Context, Result};
9use async_trait::async_trait;
10use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12use std::sync::{Arc, RwLock};
13
14mod network;
15mod observability;
16mod platform;
17mod policy;
18mod process;
19
20pub use network::{
21 default_guest_relay_addr, posix_shell_single_quote, resolve_relay_executable,
22 stage_relay_into_scratch, wrap_command_with_guest_relay, wrap_command_with_guest_relays,
23 ConnectMediator, ConnectMediatorHandle, Socks5Mediator, Socks5MediatorHandle, TcpUnixRelay,
24 TcpUnixRelayHandle, GUEST_HTTP_CONNECT_RELAY_PORT, GUEST_SOCKS_CONNECT_RELAY_PORT,
25};
26pub use observability::{AuditEvent, AuditEventParts, AuditLog, AuditSurface, ReasonCode};
27pub use policy::{
28 decide_mediated_connect, decide_mediated_http, decide_mediated_socks, decide_network,
29 decide_read, decide_write, ensure_policy_not_broader, hard_link_count,
30 hard_link_count_for_open_file, is_protected_workspace_path, matching_secret_injections,
31 normalize_policy_path, policy_digest, sensitive_paths, should_skip_workspace_scan_directory,
32 workspace_credential_hardlink_aliases, workspace_hardlink_paths, workspace_sensitive_paths,
33 AccessDecision, BackendCapabilities, FeatureFlags, FilesystemMount, FilesystemRules,
34 MediatedHttpRequest, MountMode, NetworkAllowRule, NetworkDefault, NetworkGrant, NetworkRules,
35 NormalizedPath, PathRule, PolicyUpdateOptions, ResolvedResourceBudget, ResourceLimits,
36 SandboxPolicy, SecretHeaderInjection, SessionWriteMode, SocketRules, POLICY_VERSION,
37 PROTECTED_WORKSPACE_DIRECTORIES, PROTECTED_WORKSPACE_FILES,
38};
39
40const DEFAULT_TIMEOUT_MS: u64 = 120_000;
41const PROBE_TIMEOUT_MS: u64 = 30_000;
42const PROBE_MARKER: &str = "a3s-native-sandbox-ready";
43
44pub const MAX_OUTPUT_SIZE: usize = 100 * 1024;
46
47pub const SECRET_ENV_SENTINEL_PREFIX: &str = "a3s:secret:";
52
53#[cfg(windows)]
57pub fn windows_host_powershell(workspace: &Path) -> Result<PathBuf> {
58 platform::resolve_powershell(workspace)
59}
60
61pub const NATIVE_SANDBOX_BACKEND: &str = if cfg!(target_os = "macos") {
63 "macos-seatbelt"
64} else if cfg!(target_os = "linux") {
65 "linux-namespace-seccomp"
66} else if cfg!(windows) {
67 "windows-appcontainer"
68} else {
69 "unsupported"
70};
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub struct OutputSummary {
75 pub total_bytes: usize,
76 pub captured_bytes: usize,
77 pub truncated: bool,
78 pub timed_out: bool,
79}
80
81#[async_trait]
83pub trait OutputObserver: Send + Sync {
84 async fn on_output_delta(&self, delta: &str);
85
86 async fn on_output_complete(&self, _summary: &OutputSummary) {}
87}
88
89#[derive(Clone)]
91pub struct CommandRequest {
92 pub command: String,
93 pub timeout_ms: u64,
94 pub output_observer: Option<Arc<dyn OutputObserver>>,
95 pub env: Option<Arc<HashMap<String, String>>>,
96}
97
98impl std::fmt::Debug for CommandRequest {
99 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100 f.debug_struct("CommandRequest")
101 .field("command", &self.command)
102 .field("timeout_ms", &self.timeout_ms)
103 .field("output_observer", &self.output_observer.is_some())
104 .field("env", &self.env.as_ref().map(|env| env.len()))
105 .finish()
106 }
107}
108
109#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct CommandOutput {
112 pub stdout: String,
113 pub stderr: String,
114 pub exit_code: i32,
115 pub timed_out: bool,
116}
117
118#[derive(Debug)]
125pub struct NativeSandbox {
126 workspace: PathBuf,
127 policy: RwLock<SandboxPolicy>,
128 platform: platform::PlatformSandbox,
129 audit: AuditLog,
130 session_id: String,
131}
132
133fn read_policy(policy: &RwLock<SandboxPolicy>) -> SandboxPolicy {
134 policy
135 .read()
136 .unwrap_or_else(|poisoned| poisoned.into_inner())
137 .clone()
138}
139
140#[derive(Debug, Clone, PartialEq, Eq)]
146pub struct CapabilityReport {
147 pub backend: &'static str,
148 pub capabilities: BackendCapabilities,
149 pub policy_digest: String,
150 pub unavailable: Vec<&'static str>,
151}
152
153impl NativeSandbox {
154 pub fn new(workspace: impl Into<PathBuf>) -> Result<Self> {
157 Self::with_policy(workspace, SandboxPolicy::a3s_bash_baseline())
158 }
159
160 pub fn with_policy(workspace: impl Into<PathBuf>, policy: SandboxPolicy) -> Result<Self> {
163 let workspace = workspace
164 .into()
165 .canonicalize()
166 .context("failed to canonicalize the native sandbox workspace")?;
167 if !workspace.is_dir() {
168 bail!(
169 "native sandbox workspace is not a directory: {}",
170 workspace.display()
171 );
172 }
173 policy
174 .validate_for_backend(BackendCapabilities::native_gate2())
175 .context("sandbox policy is incompatible with this backend")?;
176 let platform = platform::PlatformSandbox::new(&workspace)?;
177 Ok(Self {
178 workspace,
179 policy: RwLock::new(policy),
180 platform,
181 audit: AuditLog::with_capacity(1_024),
182 session_id: new_session_id(),
183 })
184 }
185
186 pub fn workspace(&self) -> &Path {
187 &self.workspace
188 }
189
190 pub fn policy(&self) -> SandboxPolicy {
192 read_policy(&self.policy)
193 }
194
195 pub fn policy_digest(&self) -> String {
196 policy_digest(&read_policy(&self.policy))
197 }
198
199 pub fn session_id(&self) -> &str {
200 &self.session_id
201 }
202
203 pub fn audit_log(&self) -> &AuditLog {
204 &self.audit
205 }
206
207 pub fn backend(&self) -> &'static str {
208 NATIVE_SANDBOX_BACKEND
209 }
210
211 pub fn capabilities(&self) -> BackendCapabilities {
213 BackendCapabilities::native_gate2()
214 }
215
216 pub fn ensure_policy_enforceable(&self, policy: &SandboxPolicy) -> Result<()> {
218 policy.validate_for_backend(self.capabilities())
219 }
220
221 pub fn capability_report(&self) -> CapabilityReport {
223 let capabilities = self.capabilities();
224 let mut unavailable = Vec::new();
225 if !capabilities.mediated_http {
226 unavailable.push("mediated_http");
227 }
228 if !capabilities.mediated_socks {
229 unavailable.push("mediated_socks");
230 }
231 if !capabilities.unix_socket_allowlist {
232 unavailable.push("unix_socket_allowlist");
233 }
234 if !capabilities.filesystem_ephemeral_writes {
235 unavailable.push("filesystem_ephemeral_writes");
236 }
237 if !capabilities.resource_memory_limit {
238 unavailable.push("resource_memory_limit");
239 }
240 if !capabilities.resource_process_limit {
241 unavailable.push("resource_process_limit");
242 }
243 CapabilityReport {
244 backend: self.backend(),
245 capabilities,
246 policy_digest: self.policy_digest(),
247 unavailable,
248 }
249 }
250
251 pub fn replace_policy(
257 &self,
258 policy: SandboxPolicy,
259 options: PolicyUpdateOptions,
260 ) -> Result<()> {
261 policy
262 .validate_for_backend(self.capabilities())
263 .context("replacement sandbox policy is incompatible with this backend")?;
264 {
265 let mut guard = self
266 .policy
267 .write()
268 .unwrap_or_else(|poisoned| poisoned.into_inner());
269 if !options.allow_broadening {
270 ensure_policy_not_broader(&guard, &policy)
271 .context("refusing silent policy broadening")?;
272 }
273 *guard = policy;
274 }
275 Ok(())
276 }
277
278 pub fn apply_network_grant(
288 &self,
289 grant: policy::NetworkGrant,
290 expected_base_digest: &str,
291 ) -> Result<String> {
292 let base_digest = self.policy_digest();
293 let subject = match grant.port {
294 Some(port) => format!("{}:{port}", grant.host),
295 None => grant.host.clone(),
296 };
297 if base_digest != expected_base_digest {
298 self.audit.record(AuditEvent::from_parts(AuditEventParts {
299 session_id: self.session_id.clone(),
300 command_id: format!("grant-{}", unix_millis()),
301 policy_digest: base_digest.clone(),
302 backend: self.backend().into(),
303 surface: AuditSurface::PolicyCompile,
304 decision: AccessDecision::Deny,
305 reason_code: ReasonCode::PolicyDeny,
306 target_redacted: format!("<network-grant:{subject};stale-digest>"),
307 }));
308 bail!(
309 "network grant for {subject} was approved against digest \
310 {expected_base_digest}, but the session policy is now {base_digest}; \
311 re-approve against the current policy"
312 );
313 }
314 let current = read_policy(&self.policy);
315 let (widened, changed) = policy::apply_network_grant_to_policy(¤t, &grant)?;
316 self.replace_policy(
317 widened,
318 PolicyUpdateOptions {
319 allow_broadening: true,
320 },
321 )
322 .context("granted policy must be enforceable on this backend")?;
323 let new_digest = self.policy_digest();
324 if changed {
325 self.audit.record(AuditEvent::from_parts(AuditEventParts {
326 session_id: self.session_id.clone(),
327 command_id: format!("grant-{}", unix_millis()),
328 policy_digest: new_digest.clone(),
329 backend: self.backend().into(),
330 surface: AuditSurface::Network,
331 decision: AccessDecision::Allow,
332 reason_code: ReasonCode::GrantApplied,
333 target_redacted: format!("<network-grant:{subject}>"),
334 }));
335 }
336 Ok(new_digest)
337 }
338
339 pub async fn probe(&self) -> Result<()> {
341 #[cfg(windows)]
342 let command = format!("[Console]::Out.Write('{PROBE_MARKER}')");
343 #[cfg(not(windows))]
344 let command = format!("printf %s {PROBE_MARKER}");
345
346 let output = self
347 .execute(CommandRequest {
348 command,
349 timeout_ms: PROBE_TIMEOUT_MS,
350 output_observer: None,
351 env: None,
352 })
353 .await
354 .context("native sandbox capability probe failed")?;
355 if output.timed_out {
356 bail!("native sandbox capability probe timed out");
357 }
358 if output.exit_code != 0 || output.stdout != PROBE_MARKER {
359 bail!(
360 "native sandbox capability probe returned exit code {} with stdout {:?} and stderr {:?}",
361 output.exit_code,
362 output.stdout,
363 output.stderr
364 );
365 }
366 Ok(())
367 }
368
369 pub async fn exec_command(&self, command: impl Into<String>) -> Result<CommandOutput> {
371 self.execute(CommandRequest {
372 command: command.into(),
373 timeout_ms: DEFAULT_TIMEOUT_MS,
374 output_observer: None,
375 env: None,
376 })
377 .await
378 }
379
380 pub async fn execute(&self, request: CommandRequest) -> Result<CommandOutput> {
382 self.execute_inner(request, new_command_id(), None).await
383 }
384
385 pub async fn execute_with_secrets(
395 &self,
396 mut request: CommandRequest,
397 secrets: Option<Arc<HashMap<String, String>>>,
398 ) -> Result<CommandOutput> {
399 let command_id = new_command_id();
400 let Some(secrets) = secrets.filter(|map| !map.is_empty()) else {
401 return self.execute_inner(request, command_id, None).await;
402 };
403 if !read_policy(&self.policy).features.mediated_network {
404 self.audit.record(AuditEvent::from_parts(AuditEventParts {
405 session_id: self.session_id.clone(),
406 command_id: command_id.clone(),
407 policy_digest: self.policy_digest(),
408 backend: self.backend().into(),
409 surface: AuditSurface::Environment,
410 decision: AccessDecision::Deny,
411 reason_code: ReasonCode::SecretRequiresMediation,
412 target_redacted: "<secret-env>".into(),
413 }));
414 bail!(
415 "secret environment entries require mediated_network; refusing to run \
416 secrets without a mediation boundary"
417 );
418 }
419 for (name, value) in secrets.iter() {
420 if name.is_empty() || name.contains('=') || name.contains('\0') || value.contains('\0')
421 {
422 bail!("invalid secret environment entry: {name:?}");
423 }
424 if policy::secret_env_name_is_reserved(name) {
425 bail!("secret environment entry uses a reserved environment name: {name}");
426 }
427 if request
428 .env
429 .as_ref()
430 .is_some_and(|env| env.contains_key(name))
431 {
432 bail!(
433 "secret environment entry {name} collides with an explicit \
434 command environment entry"
435 );
436 }
437 }
438 for injection in &read_policy(&self.policy).secret_injections {
442 let Some(value) = secrets.get(&injection.secret_env) else {
443 bail!(
444 "policy secret_injections reference secret {} which was not \
445 provided for this command; refusing to run with a stranded rule",
446 injection.secret_env
447 );
448 };
449 if value.bytes().any(|byte| matches!(byte, b'\r' | b'\n' | 0)) {
450 bail!(
451 "invalid secret value for {}: control characters would break \
452 the injected header",
453 injection.secret_env
454 );
455 }
456 }
457 let mut merged = request.env.as_deref().cloned().unwrap_or_default();
458 for name in secrets.keys() {
459 merged.insert(name.clone(), format!("{SECRET_ENV_SENTINEL_PREFIX}{name}"));
460 }
461 request.env = Some(Arc::new(merged));
462 let mut names: Vec<&str> = secrets.keys().map(String::as_str).collect();
463 names.sort_unstable();
464 self.audit.record(AuditEvent::from_parts(AuditEventParts {
465 session_id: self.session_id.clone(),
466 command_id: command_id.clone(),
467 policy_digest: self.policy_digest(),
468 backend: self.backend().into(),
469 surface: AuditSurface::Environment,
470 decision: AccessDecision::Allow,
471 reason_code: ReasonCode::PolicyAllow,
472 target_redacted: format!("<secret-env:{}>", names.join(",")),
473 }));
474 self.execute_inner(request, command_id, Some(secrets)).await
475 }
476
477 async fn execute_inner(
478 &self,
479 request: CommandRequest,
480 command_id: String,
481 secrets: Option<Arc<HashMap<String, String>>>,
482 ) -> Result<CommandOutput> {
483 if request.timeout_ms == 0 {
484 bail!("native sandbox command timeout must be greater than zero");
485 }
486 if request.command.contains('\0') {
487 bail!("native sandbox command contains a NUL byte");
488 }
489 let scratch = tempfile::Builder::new()
490 .prefix("a3s-sandbox-")
491 .tempdir()
492 .context("failed to create native sandbox scratch directory")?;
493 let digest = self.policy_digest();
494 let policy_doc = read_policy(&self.policy);
497
498 let mut http_mediator = None;
499 #[allow(unused_mut)]
501 let mut mediator_unix_path = None;
502 #[allow(unused_mut)]
503 let mut mediator_port = None;
504 #[allow(unused_mut)]
505 let mut mediator_pipe_name = None;
506 #[cfg(windows)]
507 let mut mediator_pipe_client: Option<std::os::windows::io::OwnedHandle> = None;
508 if policy_doc.features.mediated_network {
509 #[cfg(target_os = "linux")]
510 {
511 let sock = scratch.path().join("mediator.sock");
512 http_mediator = Some(
513 crate::ConnectMediator::bind_unix(policy_doc.clone(), &sock, secrets.clone())
514 .await
515 .context("failed to start host Unix CONNECT mediator")?,
516 );
517 mediator_unix_path = Some(sock);
518 mediator_port = Some(crate::GUEST_HTTP_CONNECT_RELAY_PORT);
519 }
520 #[cfg(target_os = "macos")]
521 {
522 http_mediator = Some(
523 crate::ConnectMediator::bind(policy_doc.clone(), secrets.clone())
524 .await
525 .context("failed to start host CONNECT mediator")?,
526 );
527 mediator_port = http_mediator
528 .as_ref()
529 .map(|handle| handle.listen_addr().port());
530 }
531 #[cfg(windows)]
532 {
533 let pipe_name = format!(
536 r"\\.\pipe\a3s-sandbox-{}-{}",
537 std::process::id(),
538 command_id
539 );
540 let (server, client) = self
541 .platform
542 .create_mediation_pipe(&pipe_name)
543 .context("failed to create AppContainer mediation pipe pair")?;
544 http_mediator = Some(
545 crate::ConnectMediator::bind_named_pipe_connected(
546 policy_doc.clone(),
547 pipe_name.clone(),
548 server,
549 secrets.clone(),
550 )
551 .await
552 .context("failed to start connected AppContainer CONNECT mediator")?,
553 );
554 mediator_pipe_name = Some(pipe_name);
555 mediator_pipe_client = Some(client);
556 }
557 #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
558 {
559 bail!("mediated_network is unavailable on this platform");
560 }
561 }
562 let mut socks_mediator = None;
563 #[allow(unused_mut)]
565 let mut socks_mediator_unix_path = None;
566 let socks_mediator_port = if policy_doc.features.mediated_socks {
567 #[cfg(target_os = "linux")]
568 {
569 let sock = scratch.path().join("socks-mediator.sock");
570 socks_mediator = Some(
571 crate::Socks5Mediator::bind_unix(policy_doc.clone(), &sock)
572 .await
573 .context("failed to start host Unix SOCKS5 mediator")?,
574 );
575 socks_mediator_unix_path = Some(sock);
576 Some(crate::GUEST_SOCKS_CONNECT_RELAY_PORT)
577 }
578 #[cfg(not(target_os = "linux"))]
579 {
580 socks_mediator = Some(
581 crate::Socks5Mediator::bind(policy_doc.clone())
582 .await
583 .context("failed to start host SOCKS5 mediator")?,
584 );
585 socks_mediator
586 .as_ref()
587 .map(|handle| handle.listen_addr().port())
588 }
589 } else {
590 None
591 };
592
593 let policy = match policy::EnforcedPolicy::compile(
594 &policy_doc,
595 &self.workspace,
596 scratch.path(),
597 self.capabilities(),
598 ) {
599 Ok(mut policy) => {
600 policy.mediator_port = mediator_port;
601 policy.mediator_unix_path = mediator_unix_path;
602 policy.mediator_pipe_name = mediator_pipe_name;
603 policy.socks_mediator_port = socks_mediator_port;
604 policy.socks_mediator_unix_path = socks_mediator_unix_path;
605 self.audit.record(AuditEvent::from_parts(AuditEventParts {
606 session_id: self.session_id.clone(),
607 command_id: command_id.clone(),
608 policy_digest: digest.clone(),
609 backend: self.backend().into(),
610 surface: AuditSurface::PolicyCompile,
611 decision: AccessDecision::Allow,
612 reason_code: ReasonCode::PolicyAllow,
613 target_redacted: "enforced-policy".into(),
614 }));
615 self.audit.record(AuditEvent::from_parts(AuditEventParts {
616 session_id: self.session_id.clone(),
617 command_id: command_id.clone(),
618 policy_digest: digest.clone(),
619 backend: self.backend().into(),
620 surface: AuditSurface::Network,
621 decision: AccessDecision::Deny,
622 reason_code: ReasonCode::NetworkDenyAll,
623 target_redacted: if mediator_port.is_some()
624 || socks_mediator_port.is_some()
625 || policy.mediator_pipe_name.is_some()
626 {
627 "<network-except-mediator>".into()
628 } else {
629 "<network>".into()
630 },
631 }));
632 policy
633 }
634 Err(error) => {
635 self.audit.record(AuditEvent::from_parts(AuditEventParts {
636 session_id: self.session_id.clone(),
637 command_id: command_id.clone(),
638 policy_digest: digest.clone(),
639 backend: self.backend().into(),
640 surface: AuditSurface::PolicyCompile,
641 decision: AccessDecision::Deny,
642 reason_code: ReasonCode::CompileOverlayRejected,
643 target_redacted: "compile-failed".into(),
644 }));
645 if let Some(handle) = http_mediator {
646 handle.shutdown().await;
647 }
648 if let Some(handle) = socks_mediator {
649 handle.shutdown().await;
650 }
651 return Err(error);
652 }
653 };
654 let output = {
655 #[cfg(windows)]
656 {
657 if let Some(client) = mediator_pipe_client {
658 self.platform
659 .execute_with_mediator_client(&policy, request, client)
660 .await?
661 } else {
662 self.platform.execute(&policy, request).await?
663 }
664 }
665 #[cfg(not(windows))]
666 {
667 self.platform.execute(&policy, request).await?
668 }
669 };
670 if let Some(handle) = http_mediator {
671 handle.shutdown().await;
672 }
673 if let Some(handle) = socks_mediator {
674 handle.shutdown().await;
675 }
676 if output.timed_out {
677 self.audit.record(AuditEvent::from_parts(AuditEventParts {
678 session_id: self.session_id.clone(),
679 command_id,
680 policy_digest: digest,
681 backend: self.backend().into(),
682 surface: AuditSurface::Process,
683 decision: AccessDecision::Deny,
684 reason_code: ReasonCode::Timeout,
685 target_redacted: "<process-tree>".into(),
686 }));
687 }
688 Ok(output)
689 }
690}
691
692fn new_session_id() -> String {
693 format!("session-{}", unix_millis())
694}
695
696fn new_command_id() -> String {
697 format!("command-{}", unix_millis())
698}
699
700fn unix_millis() -> u64 {
701 std::time::SystemTime::now()
702 .duration_since(std::time::UNIX_EPOCH)
703 .map(|d| d.as_millis() as u64)
704 .unwrap_or(0)
705}
706
707#[cfg(test)]
708mod tests;