Skip to main content

heimdall_core/
executor.rs

1use std::ffi::OsString;
2use std::io::{Read, Write};
3use std::process::{Command, Stdio};
4use std::thread;
5
6use heimdall_sandbox_policy::AgentPolicy;
7
8use crate::child::ChildGuard;
9use crate::environment::{build_child_environment, strip_dangerous_environment};
10use crate::outcome::child_outcome;
11use crate::request::{EnvPolicy, ExecRequest, StdioPolicy};
12use crate::{Error, Result};
13
14#[cfg(target_os = "linux")]
15use heimdall_linux_sandbox::BubblewrapRequest;
16#[cfg(target_os = "macos")]
17use heimdall_macos_sandbox::SeatbeltRequest;
18
19/// Executes sandbox requests.
20///
21/// A zero-sized marker type that namespaces sandbox execution methods.
22/// Using a type rather than free functions allows future extension (e.g.
23/// injecting custom signal handlers or loggers) without changing call sites.
24#[derive(Debug, Default)]
25pub struct Executor;
26
27impl Executor {
28    /// Execute a sandbox request and return the child process exit code.
29    ///
30    /// # Errors
31    ///
32    /// Returns an error when validation, hardening, spawning, signal setup, or wait fails.
33    pub fn execute(&self, request: &ExecRequest) -> Result<i32> {
34        self.execute_with_hardener(request, heimdall_process_hardening::apply_child_hardening)
35    }
36
37    fn execute_with_hardener(
38        &self,
39        request: &ExecRequest,
40        hardener: impl FnOnce() -> std::io::Result<()> + Send + Sync + 'static,
41    ) -> Result<i32> {
42        if request.needs_isolation() {
43            #[cfg(target_os = "linux")]
44            {
45                return self.execute_with_bubblewrap(request);
46            }
47            #[cfg(target_os = "macos")]
48            {
49                return self.execute_with_seatbelt(request);
50            }
51            #[cfg(not(any(target_os = "linux", target_os = "macos")))]
52            {
53                return Err(Error::sandbox_misconfiguration(
54                    "filesystem/network isolation is not supported on this platform",
55                ));
56            }
57        }
58
59        self.execute_direct_with_hardener(request, hardener)
60    }
61
62    fn execute_direct_with_hardener(
63        &self,
64        request: &ExecRequest,
65        hardener: impl FnOnce() -> std::io::Result<()> + Send + Sync + 'static,
66    ) -> Result<i32> {
67        let child_environment = self.child_environment(request);
68
69        let mut command = Command::new(&request.argv()[0]);
70        command
71            .args(&request.argv()[1..])
72            .current_dir(request.cwd())
73            .env_clear()
74            .envs(child_environment);
75        Self::configure_stdio(&mut command, request.stdio_policy());
76
77        #[cfg(unix)]
78        install_child_setup(&mut command, hardener);
79
80        #[cfg(not(unix))]
81        hardener().map_err(Error::Hardening)?;
82
83        self.execute_command(command, request.stdio_policy())
84    }
85
86    fn child_environment(
87        &self,
88        request: &ExecRequest,
89    ) -> Vec<(std::ffi::OsString, std::ffi::OsString)> {
90        let parent_environment = std::env::vars_os().collect::<Vec<_>>();
91        let child_environment = build_child_environment(
92            parent_environment,
93            request.allowed_env(),
94            request.denied_env(),
95            request.env_policy() == EnvPolicy::Blocklist,
96        );
97        let mut child_environment = strip_dangerous_environment(child_environment);
98        append_agent_environment(&mut child_environment, request.agent_policy());
99        child_environment
100    }
101
102    fn execute_command(&self, command: Command, stdio_policy: StdioPolicy) -> Result<i32> {
103        self.execute_command_with_signal_target(command, stdio_policy, false)
104    }
105
106    fn execute_command_with_signal_target(
107        &self,
108        mut command: Command,
109        stdio_policy: StdioPolicy,
110        target_process_group: bool,
111    ) -> Result<i32> {
112        #[cfg(unix)]
113        let forwarding = if target_process_group {
114            crate::signal::SignalForwarding::install_for_process_group()?
115        } else {
116            crate::signal::SignalForwarding::install()?
117        };
118
119        let mut child = command.spawn().map_err(Error::Spawn)?;
120        let output_forwarding = OutputForwarding::start(&mut child, stdio_policy);
121        let mut child = ChildGuard::new(child);
122
123        #[cfg(unix)]
124        forwarding.set_child(child.id())?;
125
126        let status = child.wait()?;
127        output_forwarding.join();
128
129        #[cfg(unix)]
130        drop(forwarding);
131
132        Ok(child_outcome(status).exit_code())
133    }
134
135    #[cfg(target_os = "linux")]
136    fn execute_with_bubblewrap(&self, request: &ExecRequest) -> Result<i32> {
137        let plan = BubblewrapRequest {
138            cwd: request.cwd(),
139            argv: request.argv(),
140            network_mode: request.network_mode(),
141            stdio_policy: match request.stdio_policy() {
142                StdioPolicy::Inherit => "inherit",
143                StdioPolicy::Piped => "piped",
144            },
145            filesystem_policy: &request.effective_filesystem_policy(),
146            proc_mode: request.proc_mode(),
147            agent_policy: request.agent_policy(),
148        }
149        .into_plan()?;
150        let mut command = plan.command();
151        command
152            .current_dir(request.cwd())
153            .env_clear()
154            .envs(self.child_environment(request));
155        Self::configure_stdio(&mut command, request.stdio_policy());
156        install_bubblewrap_child_setup(&mut command);
157        let result = self.execute_bubblewrap_command(command, request.stdio_policy());
158        let cleanup_result = plan.cleanup_missing_deny_guards();
159        match (result, cleanup_result) {
160            (Ok(exit_code), Ok(())) => Ok(exit_code),
161            (Err(error), Ok(())) | (Err(error), Err(_)) => Err(error),
162            (Ok(_), Err(error)) => Err(error.into()),
163        }
164    }
165
166    #[cfg(target_os = "linux")]
167    fn execute_bubblewrap_command(
168        &self,
169        mut command: Command,
170        stdio_policy: StdioPolicy,
171    ) -> Result<i32> {
172        let forwarding = crate::signal::SignalForwarding::install_for_bubblewrap_payload()?;
173
174        let mut child = command.spawn().map_err(Error::Spawn)?;
175        let output_forwarding = OutputForwarding::start(&mut child, stdio_policy);
176        let mut child = ChildGuard::new(child);
177
178        forwarding.set_child(child.id())?;
179
180        let status = child.wait()?;
181        output_forwarding.join();
182        drop(forwarding);
183
184        Ok(child_outcome(status).exit_code())
185    }
186
187    #[cfg(target_os = "macos")]
188    fn execute_with_seatbelt(&self, request: &ExecRequest) -> Result<i32> {
189        let plan = SeatbeltRequest {
190            cwd: request.cwd(),
191            argv: request.argv(),
192            network_mode: request.network_mode(),
193            filesystem_policy: &request.effective_filesystem_policy(),
194            proc_mode: request.proc_mode(),
195            agent_policy: request.agent_policy(),
196        }
197        .into_plan()?;
198        let mut command = plan.command();
199        command
200            .current_dir(request.cwd())
201            .env_clear()
202            .envs(self.child_environment(request));
203        Self::configure_stdio(&mut command, request.stdio_policy());
204        install_seatbelt_child_setup(&mut command);
205        self.execute_command_with_signal_target(command, request.stdio_policy(), true)
206    }
207
208    fn configure_stdio(command: &mut Command, stdio_policy: StdioPolicy) {
209        match stdio_policy {
210            StdioPolicy::Inherit => {
211                command
212                    .stdin(Stdio::inherit())
213                    .stdout(Stdio::inherit())
214                    .stderr(Stdio::inherit());
215            }
216            StdioPolicy::Piped => {
217                command
218                    .stdin(Stdio::null())
219                    .stdout(Stdio::piped())
220                    .stderr(Stdio::piped());
221            }
222        }
223    }
224}
225
226fn append_agent_environment(
227    environment: &mut Vec<(OsString, OsString)>,
228    agent_policy: AgentPolicy,
229) {
230    if agent_policy.ssh_agent() {
231        append_parent_environment(environment, "SSH_AUTH_SOCK");
232    }
233    if agent_policy.gpg_agent() {
234        append_parent_environment(environment, "GPG_AGENT_INFO");
235    }
236    if agent_policy.age_agent() {
237        append_parent_environment(environment, "AGE_AUTH_SOCK");
238        append_parent_environment(environment, "GOPASS_AGE_AGENT_SOCK");
239    }
240}
241
242fn append_parent_environment(environment: &mut Vec<(OsString, OsString)>, key: &str) {
243    let Some(value) = std::env::var_os(key) else {
244        return;
245    };
246    let key = OsString::from(key);
247    if let Some((_, existing_value)) = environment
248        .iter_mut()
249        .find(|(existing_key, _)| existing_key == &key)
250    {
251        *existing_value = value;
252    } else {
253        environment.push((key, value));
254    }
255}
256
257struct OutputForwarding {
258    stdout: Option<thread::JoinHandle<std::io::Result<()>>>,
259    stderr: Option<thread::JoinHandle<std::io::Result<()>>>,
260}
261
262impl OutputForwarding {
263    fn start(child: &mut std::process::Child, stdio_policy: StdioPolicy) -> Self {
264        if stdio_policy != StdioPolicy::Piped {
265            return Self {
266                stdout: None,
267                stderr: None,
268            };
269        }
270
271        let stdout = child.stdout.take().map(|mut stream| {
272            thread::spawn(move || {
273                let mut output = std::io::stdout().lock();
274                copy_stream(&mut stream, &mut output)
275            })
276        });
277        let stderr = child.stderr.take().map(|mut stream| {
278            thread::spawn(move || {
279                let mut output = std::io::stderr().lock();
280                copy_stream(&mut stream, &mut output)
281            })
282        });
283
284        Self { stdout, stderr }
285    }
286
287    fn join(self) {
288        if let Some(stdout) = self.stdout
289            && let Err(panic) = stdout.join()
290        {
291            std::panic::resume_unwind(panic);
292        }
293        if let Some(stderr) = self.stderr
294            && let Err(panic) = stderr.join()
295        {
296            std::panic::resume_unwind(panic);
297        }
298    }
299}
300
301fn copy_stream(reader: &mut impl Read, writer: &mut impl Write) -> std::io::Result<()> {
302    std::io::copy(reader, writer)?;
303    writer.flush()
304}
305
306#[cfg(unix)]
307fn install_child_setup(
308    command: &mut Command,
309    hardener: impl FnOnce() -> std::io::Result<()> + Send + Sync + 'static,
310) {
311    use std::os::unix::process::CommandExt;
312
313    #[cfg(target_os = "linux")]
314    // SAFETY: `getpid` has no preconditions.
315    let parent_pid = unsafe { libc::getpid() };
316    let mut hardener = Some(hardener);
317
318    // SAFETY: the closure only calls libc/setup routines intended to run after fork and before
319    // exec, and propagates `std::io::Error` values directly to `Command::spawn`.
320    unsafe {
321        command.pre_exec(move || {
322            #[cfg(target_os = "linux")]
323            heimdall_process_hardening::terminate_with_parent(parent_pid)?;
324
325            let hardener = hardener.take().ok_or_else(|| {
326                std::io::Error::other("child hardening callback was already consumed")
327            })?;
328            hardener()
329        });
330    }
331}
332
333#[cfg(target_os = "macos")]
334fn install_seatbelt_child_setup(command: &mut Command) {
335    use std::os::unix::process::CommandExt;
336
337    // SAFETY: the closure only calls libc/setup routines intended to run after fork and before
338    // exec, and propagates `std::io::Error` values directly to `Command::spawn`.
339    unsafe {
340        command.pre_exec(move || {
341            // SAFETY: `setpgid(0, 0)` places the child in a fresh process group before exec.
342            if libc::setpgid(0, 0) != 0 {
343                return Err(std::io::Error::last_os_error());
344            }
345            heimdall_process_hardening::apply_child_hardening()
346        });
347    }
348}
349
350#[cfg(target_os = "linux")]
351fn install_bubblewrap_child_setup(command: &mut Command) {
352    use std::os::unix::process::CommandExt;
353
354    #[cfg(target_os = "linux")]
355    // SAFETY: `getpid` has no preconditions.
356    let parent_pid = unsafe { libc::getpid() };
357
358    // SAFETY: the closure only calls libc/setup routines intended to run after fork and before
359    // exec, and propagates `std::io::Error` values directly to `Command::spawn`.
360    unsafe {
361        command.pre_exec(move || {
362            // SAFETY: `setpgid(0, 0)` places the child in a fresh process group before exec.
363            if libc::setpgid(0, 0) != 0 {
364                return Err(std::io::Error::last_os_error());
365            }
366            heimdall_process_hardening::terminate_with_parent(parent_pid)
367        });
368    }
369}
370
371#[cfg(test)]
372mod tests {
373    use std::io;
374    use std::time::{SystemTime, UNIX_EPOCH};
375
376    use super::*;
377
378    #[test]
379    fn hardening_failure_maps_to_misconfiguration_exit_code() {
380        let request = ExecRequest::new(
381            std::env::current_dir().expect("current dir exists"),
382            vec!["true".to_string()],
383            Vec::new(),
384        )
385        .expect("request is valid");
386
387        let error = Executor
388            .execute_with_hardener(&request, || Err(io::Error::other("hardening failed")))
389            .expect_err("hardening failure is fatal");
390
391        assert_eq!(error.exit_code(), crate::SANDBOX_MISCONFIGURATION_EXIT_CODE);
392    }
393
394    #[test]
395    fn hardening_failure_does_not_execute_child_command() {
396        let stamp = SystemTime::now()
397            .duration_since(UNIX_EPOCH)
398            .expect("system time is after Unix epoch")
399            .as_nanos();
400        let marker = std::env::temp_dir().join(format!("heimdall-hardening-{stamp}"));
401        let script = format!("touch {}", marker.display());
402        let request = ExecRequest::new(
403            std::env::current_dir().expect("current dir exists"),
404            vec!["sh".to_string(), "-c".to_string(), script],
405            Vec::new(),
406        )
407        .expect("request is valid");
408
409        let error = Executor
410            .execute_with_hardener(&request, || Err(io::Error::other("hardening failed")))
411            .expect_err("hardening failure is fatal");
412
413        assert_eq!(error.exit_code(), crate::SANDBOX_MISCONFIGURATION_EXIT_CODE);
414        assert!(!marker.exists());
415    }
416}