heel 0.1.1

Cross-platform native sandboxing library for running untrusted code
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
//! Linux sandbox backend using Landlock + Seccomp

mod landlock_rules;
mod seccomp_filter;

use std::os::unix::process::CommandExt;
use std::process::{Command, Output, Stdio};

use blocking::unblock;

use crate::config::SandboxConfigData;
use crate::error::{Error, Result};
use crate::platform::linux::landlock_rules::LandlockConfig;
use crate::platform::{Backend, Child};

/// Minimum required kernel version for full security (Landlock ABI v4)
const MIN_KERNEL_VERSION: KernelVersion = KernelVersion::new(6, 7, 0);

/// Minimum required Landlock ABI version (v4 adds network restrictions)
const MIN_LANDLOCK_ABI: i32 = 4;

fn pre_exec_write(msg: &[u8]) {
    unsafe {
        libc::write(libc::STDERR_FILENO, msg.as_ptr() as *const _, msg.len());
    }
}

/// Linux sandbox backend using Landlock (filesystem + network) and Seccomp (syscall filtering)
pub struct LinuxBackend {
    _private: (),
}

struct CommandLaunch<'a> {
    program: &'a str,
    args: &'a [String],
    envs: &'a [(String, String)],
    current_dir: Option<&'a std::path::Path>,
    stdin: Stdio,
    stdout: Stdio,
    stderr: Stdio,
}

/// Parsed kernel version
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
struct KernelVersion {
    major: u32,
    minor: u32,
    patch: u32,
}

impl KernelVersion {
    const fn new(major: u32, minor: u32, patch: u32) -> Self {
        Self {
            major,
            minor,
            patch,
        }
    }

    fn parse(release: &str) -> Result<Self> {
        // Parse "6.7.0-generic" or "6.7.0" -> (6, 7, 0)
        let version_part = release.split('-').next().unwrap_or(release);
        let parts: Vec<&str> = version_part.split('.').collect();

        if parts.len() < 2 {
            return Err(Error::InitFailed(format!(
                "Invalid kernel version format: {}",
                release
            )));
        }

        let major: u32 = parts[0]
            .parse()
            .map_err(|_| Error::InitFailed(format!("Invalid major version: {}", parts[0])))?;
        let minor: u32 = parts[1]
            .parse()
            .map_err(|_| Error::InitFailed(format!("Invalid minor version: {}", parts[1])))?;
        let patch: u32 = parts.get(2).and_then(|p| p.parse().ok()).unwrap_or(0);

        Ok(Self {
            major,
            minor,
            patch,
        })
    }
}

impl std::fmt::Display for KernelVersion {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
    }
}

impl LinuxBackend {
    /// Create a new Linux sandbox backend
    ///
    /// Fails if:
    /// - Kernel version < 6.7 (required for Landlock ABI v4)
    /// - Landlock is not available or ABI < v4
    pub fn new() -> Result<Self> {
        // Check kernel version
        let kernel_version = Self::detect_kernel_version()?;
        if kernel_version < MIN_KERNEL_VERSION {
            return Err(Error::UnsupportedPlatformVersion {
                platform: "Linux",
                minimum: "6.7",
                current: kernel_version.to_string(),
            });
        }

        // Check Landlock ABI version
        let landlock_abi = Self::detect_landlock_abi()?;
        if landlock_abi < MIN_LANDLOCK_ABI {
            return Err(Error::UnsupportedPlatformVersion {
                platform: "Linux (Landlock ABI)",
                minimum: "4",
                current: landlock_abi.to_string(),
            });
        }

        tracing::info!(
            kernel = %kernel_version,
            landlock_abi = landlock_abi,
            "Linux sandbox backend initialized"
        );

        Ok(Self { _private: () })
    }

    fn detect_kernel_version() -> Result<KernelVersion> {
        let utsname = nix::sys::utsname::uname()
            .map_err(|e| Error::InitFailed(format!("uname failed: {}", e)))?;
        let release = utsname.release().to_string_lossy();
        KernelVersion::parse(&release)
    }

    fn detect_landlock_abi() -> Result<i32> {
        use landlock::{ABI, Access, RulesetAttr};

        // Try to detect the best available ABI
        // We test by creating a ruleset - restrict_self() is tested in a forked child
        // to avoid restricting the main process
        let abi = ABI::V4; // We require V4

        // Create a minimal ruleset to check if this ABI is supported
        let ruleset =
            match landlock::Ruleset::default().handle_access(landlock::AccessFs::from_all(abi)) {
                Ok(r) => r,
                Err(_) => {
                    // Try to detect what version is actually available
                    return if landlock::Ruleset::default()
                        .handle_access(landlock::AccessFs::from_all(ABI::V3))
                        .is_ok()
                    {
                        Err(Error::UnsupportedPlatformVersion {
                            platform: "Linux (Landlock ABI)",
                            minimum: "4",
                            current: "3".to_string(),
                        })
                    } else if landlock::Ruleset::default()
                        .handle_access(landlock::AccessFs::from_all(ABI::V2))
                        .is_ok()
                    {
                        Err(Error::UnsupportedPlatformVersion {
                            platform: "Linux (Landlock ABI)",
                            minimum: "4",
                            current: "2".to_string(),
                        })
                    } else if landlock::Ruleset::default()
                        .handle_access(landlock::AccessFs::from_all(ABI::V1))
                        .is_ok()
                    {
                        Err(Error::UnsupportedPlatformVersion {
                            platform: "Linux (Landlock ABI)",
                            minimum: "4",
                            current: "1".to_string(),
                        })
                    } else {
                        Err(Error::NotEnforced("Landlock not available in kernel"))
                    };
                }
            };

        // Actually create the ruleset to verify it works
        let _created = ruleset.create().map_err(|e| {
            Error::NotEnforced(Box::leak(
                format!("Landlock ruleset creation failed: {}", e).into_boxed_str(),
            ))
        })?;

        // Test restrict_self() in a forked child process to avoid restricting the main process
        // This is critical because Landlock restrictions are inherited by child processes
        // We must test with actual path rules, not just an empty ruleset
        match unsafe { libc::fork() } {
            -1 => Err(Error::InitFailed(
                "fork failed for Landlock test".to_string(),
            )),
            0 => {
                // Child process - test restrict_self() with real rules and exit with status code
                use landlock::{PathBeneath, PathFd, RulesetCreatedAttr, RulesetStatus};

                let test_ruleset = landlock::Ruleset::default()
                    .handle_access(landlock::AccessFs::from_all(ABI::V4))
                    .and_then(|r| r.create());

                let exit_code = match test_ruleset {
                    Ok(r) => {
                        // Add at least one real path rule to properly test Landlock functionality
                        // An empty ruleset might succeed even when Landlock isn't working
                        let r = if let Ok(path_fd) = PathFd::new("/tmp") {
                            match r.add_rule(PathBeneath::new(
                                path_fd,
                                landlock::AccessFs::from_all(ABI::V4),
                            )) {
                                Ok(r) => r,
                                Err(_) => {
                                    unsafe { libc::_exit(1) };
                                }
                            }
                        } else {
                            r
                        };

                        match r.restrict_self() {
                            Ok(status) => match status.ruleset {
                                RulesetStatus::FullyEnforced => 0,
                                RulesetStatus::PartiallyEnforced => 2,
                                RulesetStatus::NotEnforced => 3,
                            },
                            Err(_) => 1, // restrict_self failed
                        }
                    }
                    Err(_) => 1,
                };
                unsafe { libc::_exit(exit_code) };
            }
            pid => {
                // Parent process - wait for child and check result
                let mut status: libc::c_int = 0;
                unsafe { libc::waitpid(pid, &mut status, 0) };

                if libc::WIFEXITED(status) {
                    match libc::WEXITSTATUS(status) {
                        0 => Ok(4), // FullyEnforced
                        1 => Err(Error::NotEnforced(
                            "Landlock restrict_self failed - kernel may not support Landlock",
                        )),
                        2 => Err(Error::NotEnforced(
                            "Landlock only partially enforced - refusing to run with reduced security",
                        )),
                        3 => Err(Error::NotEnforced("Landlock not enforced by kernel")),
                        _ => Err(Error::InitFailed(
                            "Landlock test child exited with unexpected status".to_string(),
                        )),
                    }
                } else {
                    Err(Error::InitFailed(
                        "Landlock test child terminated abnormally".to_string(),
                    ))
                }
            }
        }
    }

    fn build_command(
        &self,
        config: &SandboxConfigData,
        proxy_port: u16,
        launch: CommandLaunch<'_>,
    ) -> Result<Command> {
        // Build Landlock ruleset (validated at creation time)
        let landlock_config = LandlockConfig::from_config(config);
        let landlock_ruleset = landlock_rules::build_ruleset(&landlock_config, proxy_port)?;

        // Build Seccomp BPF filter
        let security = config.security().clone();
        let seccomp_filter = seccomp_filter::build_filter(
            &security,
            config.network_deny_all(),
            config.ipc_port().is_some(),
        )?;

        let mut cmd = Command::new(launch.program);
        cmd.args(launch.args);

        // Set working directory
        let work_dir = launch.current_dir.unwrap_or(config.working_dir());
        cmd.current_dir(work_dir);

        // Clear environment and set allowed vars
        cmd.env_clear();
        for var in config.env_passthrough() {
            if let Ok(val) = std::env::var(var) {
                cmd.env(var, val);
            }
        }

        // Add custom environment variables (includes proxy vars from Command)
        for (key, val) in launch.envs {
            cmd.env(key, val);
        }

        // Set stdio
        cmd.stdin(launch.stdin);
        cmd.stdout(launch.stdout);
        cmd.stderr(launch.stderr);

        // CRITICAL: Apply sandbox restrictions after fork, before exec
        // Keep pre_exec minimal and async-signal-safe: only apply pre-built rulesets/filters.
        let mut landlock_ruleset = Some(landlock_ruleset);
        let mut seccomp_filter = Some(seccomp_filter);

        unsafe {
            cmd.pre_exec(move || {
                #[cfg(debug_assertions)]
                pre_exec_write(b"heel: pre_exec start\n");

                let ruleset = landlock_ruleset
                    .take()
                    .ok_or_else(|| std::io::Error::other("Landlock ruleset already used"))?;

                if let Err(err) = ruleset.restrict_self() {
                    pre_exec_write(b"heel: landlock restrict_self failed\n");
                    let errno = err
                        .raw_os_error()
                        .map(|code| format!(" (errno {code})"))
                        .unwrap_or_default();
                    return Err(std::io::Error::new(
                        err.kind(),
                        format!("landlock restrict_self failed: {err}{errno}"),
                    ));
                }

                #[cfg(debug_assertions)]
                pre_exec_write(b"heel: landlock applied\n");

                let filter = seccomp_filter
                    .take()
                    .ok_or_else(|| std::io::Error::other("Seccomp filter already used"))?;

                if let Err(err) = filter.apply() {
                    pre_exec_write(b"heel: seccomp apply failed\n");
                    let errno = err
                        .raw_os_error()
                        .map(|code| format!(" (errno {code})"))
                        .unwrap_or_default();
                    return Err(std::io::Error::new(
                        err.kind(),
                        format!("seccomp apply failed: {err}{errno}"),
                    ));
                }

                #[cfg(debug_assertions)]
                pre_exec_write(b"heel: seccomp applied\n");

                Ok(())
            });
        }

        Ok(cmd)
    }
}

impl Backend for LinuxBackend {
    async fn execute(
        &self,
        config: &SandboxConfigData,
        proxy_port: u16,
        program: &str,
        args: &[String],
        envs: &[(String, String)],
        current_dir: Option<&std::path::Path>,
        stdin: Stdio,
        stdout: Stdio,
        stderr: Stdio,
    ) -> Result<Output> {
        tracing::debug!(program = %program, args = ?args, "sandbox: executing command");

        let mut cmd = self.build_command(
            config,
            proxy_port,
            CommandLaunch {
                program,
                args,
                envs,
                current_dir,
                stdin,
                stdout,
                stderr,
            },
        )?;

        // DEBUG: Print command details before spawn
        tracing::info!(
            program = %program,
            args = ?args,
            working_dir = ?current_dir.map(|p| p.display()),
            config_working_dir = %config.working_dir().display(),
            "About to spawn command"
        );

        let output = unblock(move || cmd.output()).await?;

        tracing::debug!(
            program = %program,
            exit_code = ?output.status.code(),
            success = output.status.success(),
            "sandbox: command completed"
        );

        Ok(output)
    }

    async fn spawn(
        &self,
        config: &SandboxConfigData,
        proxy_port: u16,
        program: &str,
        args: &[String],
        envs: &[(String, String)],
        current_dir: Option<&std::path::Path>,
        stdin: Stdio,
        stdout: Stdio,
        stderr: Stdio,
    ) -> Result<Child> {
        tracing::debug!(program = %program, args = ?args, "sandbox: spawning command");

        let mut cmd = self.build_command(
            config,
            proxy_port,
            CommandLaunch {
                program,
                args,
                envs,
                current_dir,
                stdin,
                stdout,
                stderr,
            },
        )?;

        let child = cmd.spawn()?;

        tracing::debug!(program = %program, pid = child.id(), "sandbox: command spawned");

        Ok(Child::new(child))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_kernel_version_parsing() {
        assert_eq!(
            KernelVersion::parse("6.7.0").unwrap(),
            KernelVersion::new(6, 7, 0)
        );
        assert_eq!(
            KernelVersion::parse("6.8.1-generic").unwrap(),
            KernelVersion::new(6, 8, 1)
        );
        assert_eq!(
            KernelVersion::parse("5.15.0-91-generic").unwrap(),
            KernelVersion::new(5, 15, 0)
        );
    }

    #[test]
    fn test_kernel_version_comparison() {
        assert!(KernelVersion::new(6, 7, 0) >= KernelVersion::new(6, 7, 0));
        assert!(KernelVersion::new(6, 8, 0) > KernelVersion::new(6, 7, 0));
        assert!(KernelVersion::new(5, 15, 0) < KernelVersion::new(6, 7, 0));
    }
}