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, 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
44/// Maximum stdout and stderr bytes retained for a command.
45pub const MAX_OUTPUT_SIZE: usize = 100 * 1024;
46
47/// Prefix of the redacted placeholder a child observes for a host-held secret
48/// environment entry. Gate 8 slice 1: real secret bytes never enter the child
49/// environment; the host re-injects them only at a mediation point in a later
50/// slice.
51pub const SECRET_ENV_SENTINEL_PREFIX: &str = "a3s:secret:";
52
53/// Windows host commands use the same PowerShell 7 executable as the
54/// AppContainer backend. `powershell.exe` is a different binary and is not
55/// part of this contract.
56#[cfg(windows)]
57pub fn windows_host_powershell(workspace: &Path) -> Result<PathBuf> {
58    platform::resolve_powershell(workspace)
59}
60
61/// Native backend selected for the current target.
62pub 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/// Final accounting for bounded command output.
73#[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/// Observer for live command output and final capture accounting.
82#[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/// Complete command execution request.
90#[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/// Result of a command executed inside the native boundary.
110#[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/// A fail-closed native sandbox bound to one canonical workspace.
119///
120/// The policy sits behind a lock so an authorized grant can widen it while
121/// the sandbox handle is shared (`Arc<dyn BashSandbox>` hosts). Execution
122/// snapshots the policy at start, so a concurrent replacement never affects
123/// a running command.
124#[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/// Structured capability probe for Gate 6 host negotiation.
141///
142/// Unavailable surfaces are listed explicitly so nested/container hosts never
143/// assume silent degradation. Callers must either drop those policy features or
144/// refuse startup.
145#[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    /// Resolve a workspace and initialize the current platform boundary with
155    /// the A3S Bash baseline policy.
156    pub fn new(workspace: impl Into<PathBuf>) -> Result<Self> {
157        Self::with_policy(workspace, SandboxPolicy::a3s_bash_baseline())
158    }
159
160    /// Resolve a workspace with an explicit typed policy. Unsupported or
161    /// unenforceable rules fail closed before any command runs.
162    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    /// A snapshot of the active policy document.
191    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    /// Backend capabilities for the compiled target. Policy must not request more.
212    pub fn capabilities(&self) -> BackendCapabilities {
213        BackendCapabilities::native_gate2()
214    }
215
216    /// Reject a caller policy that this backend cannot enforce.
217    pub fn ensure_policy_enforceable(&self, policy: &SandboxPolicy) -> Result<()> {
218        policy.validate_for_backend(self.capabilities())
219    }
220
221    /// Report advertised capabilities and surfaces this host cannot provide.
222    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    /// Replace the session policy. Broadening fails closed unless opted in.
252    ///
253    /// Safe to call while the handle is shared: each execute snapshots the
254    /// policy document at start, so a concurrent replacement never affects a
255    /// running command.
256    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    /// Apply a host-approved network grant: the only sanctioned broadening
279    /// path (optimization-roadmap Gate 10).
280    ///
281    /// `expected_base_digest` must match the current policy digest, pinning
282    /// the grant to the exact policy the user approved against. On a deny-all
283    /// baseline the grant is also the sanctioned activation of
284    /// `mediated_network`, scoped to exactly one origin. Returns the new
285    /// policy digest; every application (and every stale-digest refusal) is
286    /// auditable.
287    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(&current, &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    /// Prove that the selected operating-system boundary can start a command.
340    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    /// Execute a command with a default two-minute deadline.
370    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    /// Execute a command inside the configured native boundary.
381    pub async fn execute(&self, request: CommandRequest) -> Result<CommandOutput> {
382        self.execute_inner(request, new_command_id(), None).await
383    }
384
385    /// Execute a command with host-held secret environment entries.
386    ///
387    /// Secret values never reach the child: each entry is delivered as a
388    /// [`SECRET_ENV_SENTINEL_PREFIX`] placeholder and the host re-injects the
389    /// real value only at a mediation point. Because a sentinel nothing
390    /// re-injects would silently strand the secret, entries refuse to run
391    /// unless the policy actually enables the mediated-network boundary.
392    /// Malformed names, values, reserved environment names, and collisions
393    /// with explicit entries all fail closed before spawn.
394    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        // Gate 8 slice 2: every policy injection must resolve against this
439        // request's secrets, and its value must be header-safe. Otherwise the
440        // mediator would either strand the rule or split headers upstream.
441        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        // Snapshot so a concurrent replace_policy cannot race a running
495        // command.
496        let policy_doc = read_policy(&self.policy);
497
498        let mut http_mediator = None;
499        // Platform cfg arms assign different subsets of these fields.
500        #[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                // AppContainer guests cannot name-open host pipes (Access Denied).
534                // Create a connected pair and inherit the client handle into the guest.
535                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        // Platform cfg arms assign different subsets of these fields.
564        #[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;