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#[derive(Debug, Default)]
25pub struct Executor;
26
27impl Executor {
28 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 let parent_pid = unsafe { libc::getpid() };
316 let mut hardener = Some(hardener);
317
318 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 unsafe {
340 command.pre_exec(move || {
341 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 let parent_pid = unsafe { libc::getpid() };
357
358 unsafe {
361 command.pre_exec(move || {
362 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}