Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file.

## Unreleased

### Added

- Add `Landlock.fork` for running a sandboxed Ruby block in a supervised forked child.

## [0.4.1] - 2026-08-20

### Fixed
Expand Down
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,42 @@ Capture options:
- `success_status_codes:` and `failure_message:` — `capture!` failure handling options.
- `allow_all_known:` — when filesystem rules are present, handle all Landlock filesystem rights known to the running ABI so unlisted filesystem access is denied.

## Forking a Ruby block

`Landlock.fork` is a supervised, synchronous fork. It forks the current Ruby process, applies the requested restrictions in the child, runs the block there, waits for it, and returns a `Landlock::CaptureResult`. It is intended for applications that need to reuse initialized Ruby state without executing a new command:

```ruby
result = Landlock.fork(
read: [input_path],
timeout: 5,
rlimits: { cpu_seconds: 5, memory_bytes: 512 * 1024 * 1024 },
seccomp_deny_network: true
) do |stdout, _stderr|
stdout.write(calculate_dominant_color(input_path))
end

color = result.stdout if result.success?
```

The block receives its child-side stdout and stderr streams. Write response data to stdout and diagnostics to stderr, then inspect them through the capture result in the parent. The block's return value is discarded. An exception makes the child exit with status 1 and writes a diagnostic to stderr. `fork` accepts the capture options listed above except `success_status_codes:` and `failure_message:`, which only apply to `capture!`.

By default, `Landlock.fork` requires Linux Landlock support and raises `Landlock::UnsupportedError` before forking when the Landlock ABI is unavailable. A Linux caller that explicitly accepts running without Landlock filesystem, TCP, and scope enforcement can opt in to the fallback:

```ruby
result = Landlock.fork(
on_unsupported: :run_without_landlock,
timeout: 5,
rlimits: { memory_bytes: 512 * 1024 * 1024 },
seccomp_deny_network: true
) { |stdout, _stderr| stdout.write(run_plugin) }
```

This fallback is used only when the Linux kernel has no Landlock ABI. It skips only Landlock policy enforcement; fork supervision, timeout handling, environment changes, descriptor closing, rlimits, output capture, and seccomp remain active. It is never selected implicitly, and non-Linux systems still raise `Landlock::UnsupportedError`. When fallback is active, the call must include `seccomp_deny_network: true` or at least one `rlimits:` entry because Landlock rules are not effective restrictions in that mode. Timeout, environment handling, descriptor closing, and output limits do not satisfy this requirement. `Landlock.fork` requires an actual restriction. By default the child closes inherited Ruby `IO` objects other than stdin, stdout, and stderr, but native extensions may hold descriptors Ruby does not expose as `IO` objects. Pass `close_others: false` only when the child intentionally needs an inherited descriptor. Child setup failures exit 127.

The worker is a process-group leader and reserves Linux real-time signal `SIGRTMIN+2` for parent-death handling while the block runs. If the Ruby thread supervising the synchronous `Landlock.fork` call terminates, a native signal handler sends `SIGKILL` to the worker's process group. This terminates the worker and ordinary descendants that remain in that group. It does not cover descendants that create another process group or session, and the group-wide guarantee can be disabled by code that replaces or blocks the reserved signal, clears the parent-death signal, changes credentials in a way that clears it, or replaces the worker with `exec`. After `exec`, the reserved signal still terminates the worker by default, but the reset handler no longer kills its process group. This is process-lifecycle hardening, not a cgroup, PID namespace, or hostile-process containment boundary.

Fork only from a process whose loaded libraries and runtime state are safe to use after `fork`. `Landlock.fork` cannot make an unsafe parent fork-safe, and the block must not depend on threads that exist only in the parent.

## Restrict current process

This is irreversible for the current thread and its future children. Use `Landlock.exec` or `Landlock.spawn` unless you really mean it.
Expand Down
101 changes: 101 additions & 0 deletions ext/landlock/landlock.c
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,14 @@
#include "landlock_native.h"
#include "seccomp_deny_network.h"

#include <signal.h>
#include <string.h>

#ifdef __linux__
#include <dirent.h>
#include <stdlib.h>
#endif

static VALUE mLandlock;
static VALUE eLandlockError;
static VALUE eSyscallError;
Expand Down Expand Up @@ -121,6 +127,40 @@ static VALUE rb_ll_close_fd(VALUE self, VALUE fd_value) {
return Qnil;
}

static VALUE rb_ll_close_inherited_fds(VALUE self) {
/* The forked child keeps running Ruby, so interpreter-reserved descriptors
* must survive. This rules out close_range across the entire descriptor table. */
#ifdef __linux__
DIR *dir = opendir("/proc/self/fd");
if (dir) {
int dir_fd = dirfd(dir);
struct dirent *entry;
while ((entry = readdir(dir)) != NULL) {
char *end = NULL;
errno = 0;
long fd = strtol(entry->d_name, &end, 10);
if (errno == 0 && end && *end == '\0' && fd >= 3 && fd != dir_fd &&
!rb_reserved_fd_p((int)fd)) {
close((int)fd);
}
}
closedir(dir);
return Qtrue;
}
#endif

long max_fd = sysconf(_SC_OPEN_MAX);
if (max_fd < 0) {
max_fd = 1024;
}
for (long fd = 3; fd < max_fd; fd++) {
if (!rb_reserved_fd_p((int)fd)) {
close((int)fd);
}
}
return Qtrue;
}

static VALUE rb_ll_pidfd_open(VALUE self, VALUE pid_value) {
#ifdef SYS_pidfd_open
int fd = syscall(SYS_pidfd_open, NUM2PIDT(pid_value), 0);
Expand All @@ -135,6 +175,62 @@ static VALUE rb_ll_pidfd_open(VALUE self, VALUE pid_value) {
#endif
}

/* Runs after the worker has become its own process-group leader. */
static void terminate_own_process_group(int signal_number) {
(void)signal_number;
kill(0, SIGKILL);
_exit(0);
}

static VALUE rb_ll_arm_parent_death_process_group(VALUE self, VALUE parent_pid_value) {
#ifdef __linux__
pid_t parent_pid = NUM2PIDT(parent_pid_value);
/* Leave the first two application-visible realtime signals available to callers. */
int parent_death_signal = SIGRTMIN + 2;

struct sigaction action;
memset(&action, 0, sizeof(action));
action.sa_handler = terminate_own_process_group;
sigemptyset(&action.sa_mask);
if (sigaction(parent_death_signal, &action, NULL) != 0) {
raise_syscall_error("sigaction(parent death process group)");
}

sigset_t signals;
sigemptyset(&signals);
sigaddset(&signals, parent_death_signal);
if (sigprocmask(SIG_UNBLOCK, &signals, NULL) != 0) {
raise_syscall_error("sigprocmask(parent death process group)");
}

if (prctl(PR_SET_PDEATHSIG, parent_death_signal) != 0) {
raise_syscall_error("prctl(PR_SET_PDEATHSIG)");
}

if (getppid() != parent_pid) {
terminate_own_process_group(parent_death_signal);
}

return Qtrue;
#else
errno = ENOSYS;
raise_syscall_error("parent death process group");
return Qnil;
#endif
}

static VALUE rb_ll_set_parent_death_signal(VALUE self) {
#ifdef __linux__
if (prctl(PR_SET_PDEATHSIG, SIGKILL) != 0) {
raise_syscall_error("prctl(PR_SET_PDEATHSIG)");
}
return Qtrue;
#else
errno = ENOSYS;
raise_syscall_error("prctl(PR_SET_PDEATHSIG)");
#endif
}

static VALUE rb_ll_seccomp_deny_network(VALUE self) {
const char *error_message = "seccomp(SECCOMP_SET_MODE_FILTER)";
if (rb_landlock_seccomp_deny_network(&error_message) != 0) {
Expand Down Expand Up @@ -164,7 +260,12 @@ void Init_landlock(void) {
rb_define_singleton_method(mLandlock, "_add_net_rule", rb_ll_add_net_rule, 3);
rb_define_singleton_method(mLandlock, "_restrict_self", rb_ll_restrict_self, 1);
rb_define_singleton_method(mLandlock, "_close_fd", rb_ll_close_fd, 1);
rb_define_singleton_method(mLandlock, "_close_inherited_fds", rb_ll_close_inherited_fds, 0);
rb_define_singleton_method(mLandlock, "_pidfd_open", rb_ll_pidfd_open, 1);
rb_define_singleton_method(mLandlock, "_arm_parent_death_process_group",
rb_ll_arm_parent_death_process_group, 1);
rb_define_singleton_method(mLandlock, "_set_parent_death_signal", rb_ll_set_parent_death_signal,
0);
rb_define_singleton_method(mLandlock, "seccomp_deny_network!", rb_ll_seccomp_deny_network, 0);

rb_define_const(mLandlock, "ACCESS_FS_EXECUTE", ULL2NUM(LANDLOCK_ACCESS_FS_EXECUTE));
Expand Down
4 changes: 4 additions & 0 deletions lib/landlock.rb
Original file line number Diff line number Diff line change
Expand Up @@ -39,5 +39,9 @@ def capture(...)
def capture!(...)
Execution.capture!(...)
end

def fork(...)
Execution.fork(...)
end
end
end
108 changes: 94 additions & 14 deletions lib/landlock/execution.rb
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,36 @@ def capture!(argv, **options)
capture_with(argv, raise_on_failure: true, **options)
end

def fork(on_unsupported: :raise, **options, &block)
raise ArgumentError, "fork requires a block" if !block
if !%i[raise run_without_landlock].include?(on_unsupported)
raise ArgumentError, "on_unsupported must be :raise or :run_without_landlock"
end

enforce_landlock = Native.abi_version.positive?
if !enforce_landlock && (on_unsupported == :raise || !RUBY_PLATFORM.include?("linux"))
raise UnsupportedError, "Linux Landlock is unavailable"
end

capture_options = prepare_capture_options(**options, require_landlock: enforce_landlock)
validate_fallback_restriction!(**capture_options) if !enforce_landlock

Runner::Fork.call_block(
**capture_options,
enforce_landlock:,
&block
)
rescue OutputTooLargeError => error
result = error.result
raise CommandError.new(
error.message,
stdout: result&.stdout.to_s,
stderr: result&.stderr.to_s,
status: result&.status,
result:
)
end

def capture_with(
argv,
read: nil,
Expand All @@ -105,31 +135,30 @@ def capture_with(
raise_on_failure:
)
argv = Validation.normalize_argv(argv).map(&:to_s)
ensure_landlock_supported!
max_output_bytes = Validation.validate_output_limit!(max_output_bytes)
timeout = Validation.validate_timeout!(timeout)
normalized_rlimits = Rlimits.normalize(rlimits)
env = Env.normalize(env)
policy =
prepare_policy(read:, write:, execute:, connect_tcp:, bind_tcp:, paths:, scope:, chdir:, allow_all_known:)
validate_capture_restriction!(**policy, seccomp_deny_network:, rlimits: normalized_rlimits)

result =
call_with_runner(
argv,
**policy,
options =
prepare_capture_options(
read:,
write:,
execute:,
connect_tcp:,
bind_tcp:,
paths:,
scope:,
chdir:,
env:,
unsetenv_others:,
close_others:,
allow_all_known:,
timeout:,
stdin:,
rlimits: normalized_rlimits,
rlimits:,
seccomp_deny_network:,
max_output_bytes:,
truncate_output:
)

result = call_with_runner(argv, **options)

if raise_on_failure &&
(result.timed_out? || !result.status.exited? || !success_status_codes.include?(result.status.exitstatus))
message = [argv.join(" "), failure_message, result.stderr].filter { |part| part.to_s != "" }.join("\n")
Expand All @@ -149,6 +178,51 @@ def capture_with(
)
end

def prepare_capture_options(
read: nil,
write: nil,
execute: nil,
connect_tcp: nil,
bind_tcp: nil,
paths: nil,
scope: nil,
chdir: nil,
env: nil,
unsetenv_others: false,
close_others: true,
allow_all_known: false,
timeout: nil,
stdin: nil,
rlimits: {},
seccomp_deny_network: false,
max_output_bytes: nil,
truncate_output: false,
require_landlock: true
)
ensure_landlock_supported! if require_landlock
max_output_bytes = Validation.validate_output_limit!(max_output_bytes)
timeout = Validation.validate_timeout!(timeout)
rlimits = Rlimits.normalize(rlimits)
env = Env.normalize(env)
policy =
prepare_policy(read:, write:, execute:, connect_tcp:, bind_tcp:, paths:, scope:, chdir:, allow_all_known:)
validate_capture_restriction!(**policy, seccomp_deny_network:, rlimits:)

{
**policy,
chdir:,
env:,
unsetenv_others:,
close_others:,
timeout:,
stdin:,
rlimits:,
seccomp_deny_network:,
max_output_bytes:,
truncate_output:
}
end

def spawn_with_runner(argv, **options)
if Runner::Native.available?
begin
Expand Down Expand Up @@ -199,6 +273,12 @@ def validate_landlock_restriction!(
raise ArgumentError, "empty Landlock policy: provide filesystem paths, TCP ports, or scopes"
end

def validate_fallback_restriction!(seccomp_deny_network:, rlimits:, **)
return if seccomp_deny_network || rlimits.any?

raise ArgumentError, "Landlock fallback requires seccomp_deny_network or rlimits"
end

def validate_capture_restriction!(
read:,
write:,
Expand Down
12 changes: 12 additions & 0 deletions lib/landlock/native.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,22 @@ def close_fd(fd)
Landlock.__send__(:_close_fd, fd)
end

def close_inherited_fds!
Landlock.__send__(:_close_inherited_fds)
end

def pidfd_open(pid)
Landlock.__send__(:_pidfd_open, pid)
end

def arm_parent_death_process_group!(parent_pid)
Landlock.__send__(:_arm_parent_death_process_group, parent_pid)
end

def set_parent_death_signal!
Landlock.__send__(:_set_parent_death_signal)
end

def seccomp_deny_network!
Landlock.seccomp_deny_network!
end
Expand Down
12 changes: 10 additions & 2 deletions lib/landlock/runner.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,19 @@ def kernel_exec_args(argv, env, unsetenv_others:, close_others:)
env ? [env, *argv_for_exec(argv), exec_options] : [*argv_for_exec(argv), exec_options]
end

def exit_child!(error)
warn "Landlock child failed before exec: #{error.class}: #{error.message}"
def exit_child!(error, stderr: STDERR)
stderr.puts "Landlock child setup failed: #{error.class}: #{error.message}"
stderr.flush
ensure
exit! 127
end

def exit_forked_block!(error, stderr: STDERR)
stderr.puts "Landlock forked block failed: #{error.class}: #{error.message}"
stderr.flush
ensure
exit! 1
end
end
end

Expand Down
Loading
Loading