From 79d5e943e0a9a86ff5ec66534780f600185effd9 Mon Sep 17 00:00:00 2001 From: Alan Guo Xiang Tan Date: Thu, 27 Aug 2026 08:33:04 +0800 Subject: [PATCH 01/14] FEATURE: Add Landlock.capture_fork Long-lived Ruby processes cannot currently run a block in a sandboxed child without execing a fresh runtime. This prevents callers from reusing safely initialized native libraries while retaining capture policies and limits. This commit adds Landlock.capture_fork. It applies validated Landlock, seccomp, and resource limits to a forked child, captures stdout and stderr, and uses the existing timeout, output-limit, and process-group cleanup. It documents fork-safety and file-descriptor caveats and adds Linux coverage for inherited Ruby state, policies, process options, failures, timeouts, and output limits. --- CHANGELOG.md | 4 + README.md | 26 ++++++- lib/landlock.rb | 4 + lib/landlock/execution.rb | 86 ++++++++++++++++++---- lib/landlock/runner.rb | 6 ++ lib/landlock/runner/fork.rb | 113 +++++++++++++++++++++-------- test/landlock_capture_fork_test.rb | 107 +++++++++++++++++++++++++++ 7 files changed, 298 insertions(+), 48 deletions(-) create mode 100644 test/landlock_capture_fork_test.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f5b38b..0e94977 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. ## Unreleased +### Added + +- Add `Landlock.capture_fork` for capturing a sandboxed Ruby block in a forked child while reusing the existing timeout, output-limit, resource-limit, and process-group cleanup behavior. + ## [0.4.1] - 2026-08-20 ### Fixed diff --git a/README.md b/README.md index 0c05a7b..48be8de 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,27 @@ stdout, stderr, status = Landlock.capture( `Landlock.capture!` has the same return shape for successful commands, but raises `Landlock::CommandError` for unsuccessful statuses. The error also exposes `stdout`, `stderr`, `status`, and `result`. -`Landlock.capture` requires an actual restriction: provide Landlock rules, `seccomp_deny_network: true`, or `rlimits:`. This avoids accidentally running a command completely unsandboxed when a dynamically built policy is empty. It also requires Linux Landlock support and raises `Landlock::UnsupportedError` when unavailable; it does not fall back to running the command unsandboxed. +### Capturing a forked Ruby block + +`Landlock.capture_fork` forks the current Ruby process, applies the requested restrictions in the child, runs the block there, 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.capture_fork( + read: [input_path, "/usr", "/lib", "/lib64"].select { |path| File.exist?(path) }, + write: [File.dirname(output_path)], + timeout: 5, + rlimits: { cpu_seconds: 5, memory_bytes: 512 * 1024 * 1024 }, + seccomp_deny_network: true +) do + process_image(input_path, output_path) +end +``` + +The block runs in a separate process. Its return value is discarded; write response data to stdout and inspect the capture result. An exception makes the child exit with status 1 and writes a diagnostic to stderr. `capture_fork` accepts the capture options listed below except `success_status_codes:` and `failure_message:`, which only apply to `capture!`. By default the child closes inherited Ruby `IO` objects other than stdin, stdout, and stderr. Pass `close_others: false` only when the block intentionally needs an inherited descriptor. + +Fork only from a process whose loaded libraries and runtime state are safe to use after `fork`. `capture_fork` cannot make an unsafe parent fork-safe, and the block must not depend on threads that exist only in the parent. + +`Landlock.capture` and `Landlock.capture_fork` require an actual restriction: provide Landlock rules, `seccomp_deny_network: true`, or `rlimits:`. This avoids accidentally running work completely unsandboxed when a dynamically built policy is empty. They also require Linux Landlock support and raise `Landlock::UnsupportedError` when unavailable; they do not fall back to running work unsandboxed. Pass `stdin:` when a tool should read from standard input instead of a file: @@ -201,11 +221,11 @@ Treat small positive or negative deltas as noise and benchmark on the kernel, fi Landlock is not a complete container. It restricts selected kernel-mediated actions for the current thread and its future descendants, but it does not create namespaces, hide process IDs, virtualize the filesystem, or isolate the process from every kernel interface. For serious untrusted execution, combine Landlock with a controlled environment, resource limits, seccomp, and process isolation appropriate to your threat model. -`Landlock.restrict!` only installs a Landlock ruleset. It does not close already-open file descriptors, impose resource limits, clean the environment, or kill subprocess trees. The subprocess helpers add practical hardening around this: `exec`/`spawn` add controlled environments and `close_others`, while `capture` also adds optional `rlimits:`, optional `seccomp_deny_network:`, output limits, timeout handling, and process-group termination. This is still not a VM/container boundary. By default, subprocess helpers close inherited file descriptors numbered 3 and higher before installing the sandbox; pass `close_others: false` only when the child intentionally needs inherited descriptors. Direct `landlock-safe-exec` use also closes inherited descriptors by default. +`Landlock.restrict!` only installs a Landlock ruleset. It does not close already-open file descriptors, impose resource limits, clean the environment, or kill subprocess trees. The subprocess helpers add practical hardening around this: `exec`/`spawn` add controlled environments and `close_others`, while `capture` and `capture_fork` also add optional `rlimits:`, optional `seccomp_deny_network:`, output limits, timeout handling, and process-group termination. This is still not a VM/container boundary. By default, `exec`, `spawn`, and `capture` close inherited file descriptors numbered 3 and higher before installing the sandbox. `capture_fork` closes inherited Ruby `IO` objects, but native extensions may hold descriptors Ruby does not expose as `IO` objects. Pass `close_others: false` only when the child intentionally needs inherited descriptors. Direct `landlock-safe-exec` use also closes inherited descriptors by default. When the native helper is used, sandbox policy details such as allowed paths, TCP ports, scopes, rights, and rlimits are passed as helper argv. They may be visible to same-user processes through tools such as `ps` or `/proc//cmdline` until the helper execs the target command. Environment values passed with `env:` are not encoded in helper argv, but do not put secrets in policy path names or other policy arguments. -If `Landlock.exec`, `Landlock.spawn`, or `Landlock.capture` child setup fails before `exec`, the child prints a diagnostic and exits 127. `landlock-safe-exec` setup/argument failures exit 126. These codes can collide with commands that legitimately exit with the same status, so inspect stderr when debugging failures. +If `Landlock.exec`, `Landlock.spawn`, `Landlock.capture`, or `Landlock.capture_fork` child setup fails, the child prints a diagnostic and exits 127. A `capture_fork` block exception exits 1. `landlock-safe-exec` setup/argument failures exit 126. These codes can collide with commands that legitimately exit with the same status, so inspect stderr when debugging failures. Path rules follow the kernel's normal path resolution when the rule is installed. Because paths are opened without `O_NOFOLLOW`, a symlink rule applies to the symlink target's inode, not to the symlink path itself. Capture APIs validate explicit `read:`, `write:`, and `execute:` paths before launching so typos fail closed instead of silently weakening a policy. diff --git a/lib/landlock.rb b/lib/landlock.rb index 7be6240..9e079da 100644 --- a/lib/landlock.rb +++ b/lib/landlock.rb @@ -39,5 +39,9 @@ def capture(...) def capture!(...) Execution.capture!(...) end + + def capture_fork(...) + Execution.capture_fork(...) + end end end diff --git a/lib/landlock/execution.rb b/lib/landlock/execution.rb index 2109bf2..5ac3f11 100644 --- a/lib/landlock/execution.rb +++ b/lib/landlock/execution.rb @@ -80,6 +80,21 @@ def capture!(argv, **options) capture_with(argv, raise_on_failure: true, **options) end + def capture_fork(**options, &block) + raise ArgumentError, "capture_fork requires a block" if !block + + Runner::Fork.call_block(**prepare_capture_options(**options), &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, @@ -105,31 +120,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") @@ -149,6 +163,50 @@ 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 + ) + ensure_landlock_supported! + 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 diff --git a/lib/landlock/runner.rb b/lib/landlock/runner.rb index 73c012a..025af25 100644 --- a/lib/landlock/runner.rb +++ b/lib/landlock/runner.rb @@ -21,6 +21,12 @@ def exit_child!(error) ensure exit! 127 end + + def exit_forked_block!(error) + warn "Landlock forked block failed: #{error.class}: #{error.message}" + ensure + exit! 1 + end end end diff --git a/lib/landlock/runner/fork.rb b/lib/landlock/runner/fork.rb index 3200b2f..53de008 100644 --- a/lib/landlock/runner/fork.rb +++ b/lib/landlock/runner/fork.rb @@ -72,44 +72,65 @@ def call( max_output_bytes:, truncate_output: ) + capture_pipes(timeout:, stdin:, max_output_bytes:, truncate_output:) do + setup_child!( + argv, + read:, + write:, + execute:, + connect_tcp:, + bind_tcp:, + paths:, + scope:, + chdir:, + env:, + unsetenv_others:, + close_others:, + allow_all_known:, + rlimits:, + seccomp_deny_network: + ) + rescue Exception => error + Runner.exit_child!(error) + end + end + + def call_block(timeout:, stdin:, max_output_bytes:, truncate_output:, **options, &block) + capture_pipes(timeout:, stdin:, max_output_bytes:, truncate_output:) do + begin + prepare_forked_block!(**options) + rescue Exception => error + Runner.exit_child!(error) + end + + block.call + exit! 0 + rescue Exception => error + Runner.exit_forked_block!(error) + end + end + + def capture_pipes(timeout:, stdin:, max_output_bytes:, truncate_output:) stdout_reader, stdout_writer = IO.pipe stderr_reader, stderr_writer = IO.pipe stdin_reader, stdin_writer = IO.pipe pid = fork do - begin - stdout_reader.close - stderr_reader.close - stdin_writer.close - ::Process.setpgrp - STDIN.reopen(stdin_reader) - STDOUT.reopen(stdout_writer) - STDERR.reopen(stderr_writer) - stdin_reader.close - stdout_writer.close - stderr_writer.close + stdout_reader.close + stderr_reader.close + stdin_writer.close + ::Process.setpgrp + STDIN.reopen(stdin_reader) + STDOUT.reopen(stdout_writer) + STDERR.reopen(stderr_writer) + STDOUT.sync = true + STDERR.sync = true + stdin_reader.close + stdout_writer.close + stderr_writer.close - setup_child!( - argv, - read:, - write:, - execute:, - connect_tcp:, - bind_tcp:, - paths:, - scope:, - chdir:, - env:, - unsetenv_others:, - close_others:, - allow_all_known:, - rlimits:, - seccomp_deny_network: - ) - rescue Exception => error - Runner.exit_child!(error) - end + yield end stdin_reader.close @@ -166,6 +187,36 @@ def setup_child!( Rlimits.apply!(rlimits) Kernel.exec(*Runner.kernel_exec_args(argv, env, unsetenv_others:, close_others:)) end + + def prepare_forked_block!( + chdir:, + env:, + unsetenv_others:, + close_others:, + rlimits:, + seccomp_deny_network:, + **policy + ) + close_inherited_ios if close_others + Dir.public_send(:chdir, chdir) if chdir + ENV.clear if unsetenv_others + env&.each { |key, value| value.nil? ? ENV.delete(key) : ENV[key] = value } + Landlock.restrict!(**policy) if Policy.requested?(**policy) + Landlock::Native.seccomp_deny_network! if seccomp_deny_network + Rlimits.apply!(rlimits) + end + + def close_inherited_ios + ObjectSpace + .each_object(IO) + .to_a + .each do |io| + next if io.closed? || io.fileno <= 2 + + io.close + rescue IOError + end + end end end end diff --git a/test/landlock_capture_fork_test.rb b/test/landlock_capture_fork_test.rb new file mode 100644 index 0000000..38b5f05 --- /dev/null +++ b/test/landlock_capture_fork_test.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require_relative "test_helper" + +class LandlockCaptureForkTest < LandlockTestCase + def test_capture_fork_captures_an_inherited_ruby_block + skip "Landlock unsupported" unless Landlock.supported? + + inherited = "ready" + result = + Landlock.capture_fork(rlimits: { open_files: 64 }) do + print inherited + warn "warning" + end + + assert_equal "ready", result.stdout + assert_equal "warning\n", result.stderr + assert_predicate result, :success? + end + + def test_capture_fork_returns_block_errors + skip "Landlock unsupported" unless Landlock.supported? + + result = Landlock.capture_fork(rlimits: { open_files: 64 }) { raise "failed" } + + assert_equal 1, result.status.exitstatus + assert_match(/RuntimeError: failed/, result.stderr) + refute_predicate result, :success? + end + + def test_capture_fork_enforces_timeout + skip "Landlock unsupported" unless Landlock.supported? + + result = Landlock.capture_fork(timeout: 0.01, rlimits: { open_files: 64 }) { sleep 30 } + + assert_predicate result, :timed_out? + refute_predicate result, :success? + end + + def test_capture_fork_applies_the_filesystem_policy + skip "Landlock unsupported" unless Landlock.supported? + + Dir.mktmpdir do |directory| + path = File.join(directory, "secret") + File.write(path, "secret") + + result = Landlock.capture_fork(read: [], write: []) { File.read(path) } + + assert_equal 1, result.status.exitstatus + assert_match(/Errno::EACCES/, result.stderr) + end + end + + def test_capture_fork_applies_process_options + skip "Landlock unsupported" unless Landlock.supported? + + Dir.mktmpdir do |directory| + result = + Landlock.capture_fork( + chdir: directory, + env: { + LANDLOCK_CAPTURE_FORK: "child" + }, + unsetenv_others: true, + stdin: "input", + rlimits: { + open_files: 32 + } + ) do + print [Dir.pwd, ENV.fetch("LANDLOCK_CAPTURE_FORK"), STDIN.read, Process.getrlimit(:NOFILE).first].join(":") + end + + assert_equal "#{directory}:child:input:32", result.stdout + assert_predicate result, :success? + end + end + + def test_capture_fork_closes_inherited_io + skip "Landlock unsupported" unless Landlock.supported? + + reader, writer = IO.pipe + result = Landlock.capture_fork(rlimits: { open_files: 64 }) { print writer.closed? } + + assert_equal "true", result.stdout + ensure + reader&.close + writer&.close + end + + def test_capture_fork_enforces_output_limit + skip "Landlock unsupported" unless Landlock.supported? + + error = + assert_raises(Landlock::CommandError) do + Landlock.capture_fork(rlimits: { open_files: 64 }, max_output_bytes: 4) { print "output" } + end + + assert_equal "outp", error.stdout + assert_predicate error.result, :output_truncated? + end + + def test_capture_fork_requires_a_block + error = assert_raises(ArgumentError) { Landlock.capture_fork(rlimits: { open_files: 64 }) } + + assert_equal "capture_fork requires a block", error.message + end +end From e3e0307f1c1fd409d3443eb8762bfe3306b78ef3 Mon Sep 17 00:00:00 2001 From: Alan Guo Xiang Tan Date: Thu, 27 Aug 2026 09:01:24 +0800 Subject: [PATCH 02/14] DEV: Simplify `capture_fork` changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e94977..b9158b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to this project will be documented in this file. ### Added -- Add `Landlock.capture_fork` for capturing a sandboxed Ruby block in a forked child while reusing the existing timeout, output-limit, resource-limit, and process-group cleanup behavior. +- Add `Landlock.capture_fork` for capturing a sandboxed Ruby block in a forked child. ## [0.4.1] - 2026-08-20 From 25e30848ed036d53493a03925b64fa87164ee139 Mon Sep 17 00:00:00 2001 From: Alan Guo Xiang Tan Date: Thu, 27 Aug 2026 09:01:50 +0800 Subject: [PATCH 03/14] DEV: Simplify `capture_fork` example policy --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 48be8de..07cb8bf 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ stdout, stderr, status = Landlock.capture( ```ruby result = Landlock.capture_fork( - read: [input_path, "/usr", "/lib", "/lib64"].select { |path| File.exist?(path) }, + read: [input_path], write: [File.dirname(output_path)], timeout: 5, rlimits: { cpu_seconds: 5, memory_bytes: 512 * 1024 * 1024 }, From 332c1455a5767b52b052145f2936e4c61ada4854 Mon Sep 17 00:00:00 2001 From: Alan Guo Xiang Tan Date: Thu, 27 Aug 2026 09:05:07 +0800 Subject: [PATCH 04/14] DEV: Separate `capture_fork` documentation --- README.md | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 07cb8bf..85c38d5 100644 --- a/README.md +++ b/README.md @@ -104,26 +104,6 @@ stdout, stderr, status = Landlock.capture( `Landlock.capture!` has the same return shape for successful commands, but raises `Landlock::CommandError` for unsuccessful statuses. The error also exposes `stdout`, `stderr`, `status`, and `result`. -### Capturing a forked Ruby block - -`Landlock.capture_fork` forks the current Ruby process, applies the requested restrictions in the child, runs the block there, 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.capture_fork( - read: [input_path], - write: [File.dirname(output_path)], - timeout: 5, - rlimits: { cpu_seconds: 5, memory_bytes: 512 * 1024 * 1024 }, - seccomp_deny_network: true -) do - process_image(input_path, output_path) -end -``` - -The block runs in a separate process. Its return value is discarded; write response data to stdout and inspect the capture result. An exception makes the child exit with status 1 and writes a diagnostic to stderr. `capture_fork` accepts the capture options listed below except `success_status_codes:` and `failure_message:`, which only apply to `capture!`. By default the child closes inherited Ruby `IO` objects other than stdin, stdout, and stderr. Pass `close_others: false` only when the block intentionally needs an inherited descriptor. - -Fork only from a process whose loaded libraries and runtime state are safe to use after `fork`. `capture_fork` cannot make an unsafe parent fork-safe, and the block must not depend on threads that exist only in the parent. - `Landlock.capture` and `Landlock.capture_fork` require an actual restriction: provide Landlock rules, `seccomp_deny_network: true`, or `rlimits:`. This avoids accidentally running work completely unsandboxed when a dynamically built policy is empty. They also require Linux Landlock support and raise `Landlock::UnsupportedError` when unavailable; they do not fall back to running work unsandboxed. Pass `stdin:` when a tool should read from standard input instead of a file: @@ -155,6 +135,26 @@ 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. +## Capturing a forked Ruby block + +`Landlock.capture_fork` forks the current Ruby process, applies the requested restrictions in the child, runs the block there, 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.capture_fork( + read: [input_path], + write: [File.dirname(output_path)], + timeout: 5, + rlimits: { cpu_seconds: 5, memory_bytes: 512 * 1024 * 1024 }, + seccomp_deny_network: true +) do + process_image(input_path, output_path) +end +``` + +The block runs in a separate process. Its return value is discarded; write response data to stdout and inspect the capture result. An exception makes the child exit with status 1 and writes a diagnostic to stderr. `capture_fork` accepts the capture options listed above except `success_status_codes:` and `failure_message:`, which only apply to `capture!`. By default the child closes inherited Ruby `IO` objects other than stdin, stdout, and stderr. Pass `close_others: false` only when the block intentionally needs an inherited descriptor. + +Fork only from a process whose loaded libraries and runtime state are safe to use after `fork`. `capture_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. From d5bba33f622d637a5b01f83855ab32e9f68e5688 Mon Sep 17 00:00:00 2001 From: Alan Guo Xiang Tan Date: Thu, 27 Aug 2026 09:25:43 +0800 Subject: [PATCH 05/14] DEV: Rename `capture_fork` to `Landlock.fork` --- CHANGELOG.md | 2 +- README.md | 18 ++++---- lib/landlock.rb | 4 +- lib/landlock/execution.rb | 4 +- ...ure_fork_test.rb => landlock_fork_test.rb} | 42 +++++++++---------- 5 files changed, 34 insertions(+), 36 deletions(-) rename test/{landlock_capture_fork_test.rb => landlock_fork_test.rb} (58%) diff --git a/CHANGELOG.md b/CHANGELOG.md index b9158b9..04e84e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to this project will be documented in this file. ### Added -- Add `Landlock.capture_fork` for capturing a sandboxed Ruby block in a forked child. +- Add `Landlock.fork` for running a sandboxed Ruby block in a supervised forked child. ## [0.4.1] - 2026-08-20 diff --git a/README.md b/README.md index 85c38d5..70f2b90 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ Landlock.exec( ## Capturing subprocess output -`Landlock.capture` is the stdout/stderr-capturing sibling of `Landlock.exec`: it launches a child process, applies Landlock rules, resource limits, and the optional seccomp network-deny filter before the target command starts, then execs that command directly. When the packaged `landlock-safe-exec` helper is available, `exec`, `spawn`, and `capture` all spawn that small native helper with policy arguments so the parent does not need to fork a bloated Ruby process; they fall back to the Ruby fork path when the helper cannot be used or when an unusually large helper argv would exceed the platform `ARG_MAX`. Environment changes are applied by Ruby when spawning the helper rather than encoded in helper argv. Use `capture!` when unsuccessful exit statuses should raise. +`Landlock.capture` is the stdout/stderr-capturing sibling of `Landlock.exec`: it launches a child process, applies Landlock rules, resource limits, and the optional seccomp network-deny filter before the target command starts, then execs that command directly. When the packaged `landlock-safe-exec` helper is available, `exec`, `spawn`, and `capture` all spawn that small native helper with policy arguments so the parent does not need to fork a bloated Ruby process. When the helper cannot be used, they fall back to a Ruby child that applies the restrictions and still execs the requested command. Environment changes are applied by Ruby when spawning the helper rather than encoded in helper argv. Use `capture!` when unsuccessful exit statuses should raise. ```ruby result = Landlock.capture( @@ -104,7 +104,7 @@ stdout, stderr, status = Landlock.capture( `Landlock.capture!` has the same return shape for successful commands, but raises `Landlock::CommandError` for unsuccessful statuses. The error also exposes `stdout`, `stderr`, `status`, and `result`. -`Landlock.capture` and `Landlock.capture_fork` require an actual restriction: provide Landlock rules, `seccomp_deny_network: true`, or `rlimits:`. This avoids accidentally running work completely unsandboxed when a dynamically built policy is empty. They also require Linux Landlock support and raise `Landlock::UnsupportedError` when unavailable; they do not fall back to running work unsandboxed. +`Landlock.capture` and `Landlock.fork` require an actual restriction: provide Landlock rules, `seccomp_deny_network: true`, or `rlimits:`. This avoids accidentally running work completely unsandboxed when a dynamically built policy is empty. They also require Linux Landlock support and raise `Landlock::UnsupportedError` when unavailable; they do not fall back to running work unsandboxed. Pass `stdin:` when a tool should read from standard input instead of a file: @@ -135,12 +135,12 @@ 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. -## Capturing a forked Ruby block +## Forking a Ruby block -`Landlock.capture_fork` forks the current Ruby process, applies the requested restrictions in the child, runs the block there, and returns a `Landlock::CaptureResult`. It is intended for applications that need to reuse initialized Ruby state without executing a new command: +`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.capture_fork( +result = Landlock.fork( read: [input_path], write: [File.dirname(output_path)], timeout: 5, @@ -151,9 +151,9 @@ result = Landlock.capture_fork( end ``` -The block runs in a separate process. Its return value is discarded; write response data to stdout and inspect the capture result. An exception makes the child exit with status 1 and writes a diagnostic to stderr. `capture_fork` accepts the capture options listed above except `success_status_codes:` and `failure_message:`, which only apply to `capture!`. By default the child closes inherited Ruby `IO` objects other than stdin, stdout, and stderr. Pass `close_others: false` only when the block intentionally needs an inherited descriptor. +The block runs in a separate process. Its return value is discarded; write response data to stdout and inspect the capture result. 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 the child closes inherited Ruby `IO` objects other than stdin, stdout, and stderr. Pass `close_others: false` only when the block intentionally needs an inherited descriptor. -Fork only from a process whose loaded libraries and runtime state are safe to use after `fork`. `capture_fork` cannot make an unsafe parent fork-safe, and the block must not depend on threads that exist only in the parent. +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 @@ -221,11 +221,11 @@ Treat small positive or negative deltas as noise and benchmark on the kernel, fi Landlock is not a complete container. It restricts selected kernel-mediated actions for the current thread and its future descendants, but it does not create namespaces, hide process IDs, virtualize the filesystem, or isolate the process from every kernel interface. For serious untrusted execution, combine Landlock with a controlled environment, resource limits, seccomp, and process isolation appropriate to your threat model. -`Landlock.restrict!` only installs a Landlock ruleset. It does not close already-open file descriptors, impose resource limits, clean the environment, or kill subprocess trees. The subprocess helpers add practical hardening around this: `exec`/`spawn` add controlled environments and `close_others`, while `capture` and `capture_fork` also add optional `rlimits:`, optional `seccomp_deny_network:`, output limits, timeout handling, and process-group termination. This is still not a VM/container boundary. By default, `exec`, `spawn`, and `capture` close inherited file descriptors numbered 3 and higher before installing the sandbox. `capture_fork` closes inherited Ruby `IO` objects, but native extensions may hold descriptors Ruby does not expose as `IO` objects. Pass `close_others: false` only when the child intentionally needs inherited descriptors. Direct `landlock-safe-exec` use also closes inherited descriptors by default. +`Landlock.restrict!` only installs a Landlock ruleset. It does not close already-open file descriptors, impose resource limits, clean the environment, or kill subprocess trees. The subprocess helpers add practical hardening around this: `exec`/`spawn` add controlled environments and `close_others`, while `capture` and `fork` also add optional `rlimits:`, optional `seccomp_deny_network:`, output limits, timeout handling, and process-group termination. This is still not a VM/container boundary. By default, `exec`, `spawn`, and `capture` close inherited file descriptors numbered 3 and higher before installing the sandbox. `Landlock.fork` closes inherited Ruby `IO` objects, but native extensions may hold descriptors Ruby does not expose as `IO` objects. Pass `close_others: false` only when the child intentionally needs inherited descriptors. Direct `landlock-safe-exec` use also closes inherited descriptors by default. When the native helper is used, sandbox policy details such as allowed paths, TCP ports, scopes, rights, and rlimits are passed as helper argv. They may be visible to same-user processes through tools such as `ps` or `/proc//cmdline` until the helper execs the target command. Environment values passed with `env:` are not encoded in helper argv, but do not put secrets in policy path names or other policy arguments. -If `Landlock.exec`, `Landlock.spawn`, `Landlock.capture`, or `Landlock.capture_fork` child setup fails, the child prints a diagnostic and exits 127. A `capture_fork` block exception exits 1. `landlock-safe-exec` setup/argument failures exit 126. These codes can collide with commands that legitimately exit with the same status, so inspect stderr when debugging failures. +If `Landlock.exec`, `Landlock.spawn`, `Landlock.capture`, or `Landlock.fork` child setup fails, the child prints a diagnostic and exits 127. A `Landlock.fork` block exception exits 1. `landlock-safe-exec` setup/argument failures exit 126. These codes can collide with commands that legitimately exit with the same status, so inspect stderr when debugging failures. Path rules follow the kernel's normal path resolution when the rule is installed. Because paths are opened without `O_NOFOLLOW`, a symlink rule applies to the symlink target's inode, not to the symlink path itself. Capture APIs validate explicit `read:`, `write:`, and `execute:` paths before launching so typos fail closed instead of silently weakening a policy. diff --git a/lib/landlock.rb b/lib/landlock.rb index 9e079da..3e1ae00 100644 --- a/lib/landlock.rb +++ b/lib/landlock.rb @@ -40,8 +40,8 @@ def capture!(...) Execution.capture!(...) end - def capture_fork(...) - Execution.capture_fork(...) + def fork(...) + Execution.fork(...) end end end diff --git a/lib/landlock/execution.rb b/lib/landlock/execution.rb index 5ac3f11..c4f720f 100644 --- a/lib/landlock/execution.rb +++ b/lib/landlock/execution.rb @@ -80,8 +80,8 @@ def capture!(argv, **options) capture_with(argv, raise_on_failure: true, **options) end - def capture_fork(**options, &block) - raise ArgumentError, "capture_fork requires a block" if !block + def fork(**options, &block) + raise ArgumentError, "fork requires a block" if !block Runner::Fork.call_block(**prepare_capture_options(**options), &block) rescue OutputTooLargeError => error diff --git a/test/landlock_capture_fork_test.rb b/test/landlock_fork_test.rb similarity index 58% rename from test/landlock_capture_fork_test.rb rename to test/landlock_fork_test.rb index 38b5f05..5b691b5 100644 --- a/test/landlock_capture_fork_test.rb +++ b/test/landlock_fork_test.rb @@ -2,13 +2,13 @@ require_relative "test_helper" -class LandlockCaptureForkTest < LandlockTestCase - def test_capture_fork_captures_an_inherited_ruby_block +class LandlockForkTest < LandlockTestCase + def test_fork_captures_an_inherited_ruby_block skip "Landlock unsupported" unless Landlock.supported? inherited = "ready" result = - Landlock.capture_fork(rlimits: { open_files: 64 }) do + Landlock.fork(rlimits: { open_files: 64 }) do print inherited warn "warning" end @@ -18,68 +18,66 @@ def test_capture_fork_captures_an_inherited_ruby_block assert_predicate result, :success? end - def test_capture_fork_returns_block_errors + def test_fork_returns_block_errors skip "Landlock unsupported" unless Landlock.supported? - result = Landlock.capture_fork(rlimits: { open_files: 64 }) { raise "failed" } + result = Landlock.fork(rlimits: { open_files: 64 }) { raise "failed" } assert_equal 1, result.status.exitstatus assert_match(/RuntimeError: failed/, result.stderr) refute_predicate result, :success? end - def test_capture_fork_enforces_timeout + def test_fork_enforces_timeout skip "Landlock unsupported" unless Landlock.supported? - result = Landlock.capture_fork(timeout: 0.01, rlimits: { open_files: 64 }) { sleep 30 } + result = Landlock.fork(timeout: 0.01, rlimits: { open_files: 64 }) { sleep 30 } assert_predicate result, :timed_out? refute_predicate result, :success? end - def test_capture_fork_applies_the_filesystem_policy + def test_fork_applies_the_filesystem_policy skip "Landlock unsupported" unless Landlock.supported? Dir.mktmpdir do |directory| path = File.join(directory, "secret") File.write(path, "secret") - result = Landlock.capture_fork(read: [], write: []) { File.read(path) } + result = Landlock.fork(read: [], write: []) { File.read(path) } assert_equal 1, result.status.exitstatus assert_match(/Errno::EACCES/, result.stderr) end end - def test_capture_fork_applies_process_options + def test_fork_applies_process_options skip "Landlock unsupported" unless Landlock.supported? Dir.mktmpdir do |directory| result = - Landlock.capture_fork( + Landlock.fork( chdir: directory, env: { - LANDLOCK_CAPTURE_FORK: "child" + LANDLOCK_FORK: "child" }, unsetenv_others: true, stdin: "input", rlimits: { open_files: 32 } - ) do - print [Dir.pwd, ENV.fetch("LANDLOCK_CAPTURE_FORK"), STDIN.read, Process.getrlimit(:NOFILE).first].join(":") - end + ) { print [Dir.pwd, ENV.fetch("LANDLOCK_FORK"), STDIN.read, Process.getrlimit(:NOFILE).first].join(":") } assert_equal "#{directory}:child:input:32", result.stdout assert_predicate result, :success? end end - def test_capture_fork_closes_inherited_io + def test_fork_closes_inherited_io skip "Landlock unsupported" unless Landlock.supported? reader, writer = IO.pipe - result = Landlock.capture_fork(rlimits: { open_files: 64 }) { print writer.closed? } + result = Landlock.fork(rlimits: { open_files: 64 }) { print writer.closed? } assert_equal "true", result.stdout ensure @@ -87,21 +85,21 @@ def test_capture_fork_closes_inherited_io writer&.close end - def test_capture_fork_enforces_output_limit + def test_fork_enforces_output_limit skip "Landlock unsupported" unless Landlock.supported? error = assert_raises(Landlock::CommandError) do - Landlock.capture_fork(rlimits: { open_files: 64 }, max_output_bytes: 4) { print "output" } + Landlock.fork(rlimits: { open_files: 64 }, max_output_bytes: 4) { print "output" } end assert_equal "outp", error.stdout assert_predicate error.result, :output_truncated? end - def test_capture_fork_requires_a_block - error = assert_raises(ArgumentError) { Landlock.capture_fork(rlimits: { open_files: 64 }) } + def test_fork_requires_a_block + error = assert_raises(ArgumentError) { Landlock.fork(rlimits: { open_files: 64 }) } - assert_equal "capture_fork requires a block", error.message + assert_equal "fork requires a block", error.message end end From 89397001cf4bd982dcc5bf516205f22586f1e4d8 Mon Sep 17 00:00:00 2001 From: Alan Guo Xiang Tan Date: Thu, 27 Aug 2026 09:50:50 +0800 Subject: [PATCH 06/14] DEV: Yield captured streams from `Landlock.fork` --- README.md | 9 +++++---- lib/landlock/runner/fork.rb | 2 +- test/landlock_fork_test.rb | 15 ++++++++++++--- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 70f2b90..4e8fe3e 100644 --- a/README.md +++ b/README.md @@ -142,16 +142,17 @@ Capture options: ```ruby result = Landlock.fork( read: [input_path], - write: [File.dirname(output_path)], timeout: 5, rlimits: { cpu_seconds: 5, memory_bytes: 512 * 1024 * 1024 }, seccomp_deny_network: true -) do - process_image(input_path, output_path) +) do |stdout, _stderr| + stdout.write(calculate_dominant_color(input_path)) end + +color = result.stdout if result.success? ``` -The block runs in a separate process. Its return value is discarded; write response data to stdout and inspect the capture result. 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 the child closes inherited Ruby `IO` objects other than stdin, stdout, and stderr. Pass `close_others: false` only when the block intentionally needs an inherited descriptor. +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 the child closes inherited Ruby `IO` objects other than stdin, stdout, and stderr. Pass `close_others: false` only when the block intentionally needs an inherited descriptor. 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. diff --git a/lib/landlock/runner/fork.rb b/lib/landlock/runner/fork.rb index 53de008..eca08e1 100644 --- a/lib/landlock/runner/fork.rb +++ b/lib/landlock/runner/fork.rb @@ -103,7 +103,7 @@ def call_block(timeout:, stdin:, max_output_bytes:, truncate_output:, **options, Runner.exit_child!(error) end - block.call + block.call(STDOUT, STDERR) exit! 0 rescue Exception => error Runner.exit_forked_block!(error) diff --git a/test/landlock_fork_test.rb b/test/landlock_fork_test.rb index 5b691b5..08aab4f 100644 --- a/test/landlock_fork_test.rb +++ b/test/landlock_fork_test.rb @@ -8,9 +8,9 @@ def test_fork_captures_an_inherited_ruby_block inherited = "ready" result = - Landlock.fork(rlimits: { open_files: 64 }) do - print inherited - warn "warning" + Landlock.fork(rlimits: { open_files: 64 }) do |stdout, stderr| + stdout.print inherited + stderr.puts "warning" end assert_equal "ready", result.stdout @@ -28,6 +28,15 @@ def test_fork_returns_block_errors refute_predicate result, :success? end + def test_fork_discards_the_block_return_value + skip "Landlock unsupported" unless Landlock.supported? + + result = Landlock.fork(rlimits: { open_files: 64 }) { Object.new } + + assert_empty result.stdout + assert_predicate result, :success? + end + def test_fork_enforces_timeout skip "Landlock unsupported" unless Landlock.supported? From 1ce21409abc1779955543c10cf282e557dbc006b Mon Sep 17 00:00:00 2001 From: Alan Guo Xiang Tan Date: Thu, 27 Aug 2026 09:55:19 +0800 Subject: [PATCH 07/14] DEV: Contain `Landlock.fork` README changes --- README.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 4e8fe3e..857093e 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ Landlock.exec( ## Capturing subprocess output -`Landlock.capture` is the stdout/stderr-capturing sibling of `Landlock.exec`: it launches a child process, applies Landlock rules, resource limits, and the optional seccomp network-deny filter before the target command starts, then execs that command directly. When the packaged `landlock-safe-exec` helper is available, `exec`, `spawn`, and `capture` all spawn that small native helper with policy arguments so the parent does not need to fork a bloated Ruby process. When the helper cannot be used, they fall back to a Ruby child that applies the restrictions and still execs the requested command. Environment changes are applied by Ruby when spawning the helper rather than encoded in helper argv. Use `capture!` when unsuccessful exit statuses should raise. +`Landlock.capture` is the stdout/stderr-capturing sibling of `Landlock.exec`: it launches a child process, applies Landlock rules, resource limits, and the optional seccomp network-deny filter before the target command starts, then execs that command directly. When the packaged `landlock-safe-exec` helper is available, `exec`, `spawn`, and `capture` all spawn that small native helper with policy arguments so the parent does not need to fork a bloated Ruby process; they fall back to the Ruby fork path when the helper cannot be used or when an unusually large helper argv would exceed the platform `ARG_MAX`. Environment changes are applied by Ruby when spawning the helper rather than encoded in helper argv. Use `capture!` when unsuccessful exit statuses should raise. ```ruby result = Landlock.capture( @@ -104,7 +104,7 @@ stdout, stderr, status = Landlock.capture( `Landlock.capture!` has the same return shape for successful commands, but raises `Landlock::CommandError` for unsuccessful statuses. The error also exposes `stdout`, `stderr`, `status`, and `result`. -`Landlock.capture` and `Landlock.fork` require an actual restriction: provide Landlock rules, `seccomp_deny_network: true`, or `rlimits:`. This avoids accidentally running work completely unsandboxed when a dynamically built policy is empty. They also require Linux Landlock support and raise `Landlock::UnsupportedError` when unavailable; they do not fall back to running work unsandboxed. +`Landlock.capture` requires an actual restriction: provide Landlock rules, `seccomp_deny_network: true`, or `rlimits:`. This avoids accidentally running a command completely unsandboxed when a dynamically built policy is empty. It also requires Linux Landlock support and raises `Landlock::UnsupportedError` when unavailable; it does not fall back to running the command unsandboxed. Pass `stdin:` when a tool should read from standard input instead of a file: @@ -152,7 +152,9 @@ 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 the child closes inherited Ruby `IO` objects other than stdin, stdout, and stderr. Pass `close_others: false` only when the block intentionally needs an inherited descriptor. +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!`. + +`Landlock.fork` requires an actual restriction and Linux Landlock support; it never runs the block unsandboxed. 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. 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. @@ -222,11 +224,11 @@ Treat small positive or negative deltas as noise and benchmark on the kernel, fi Landlock is not a complete container. It restricts selected kernel-mediated actions for the current thread and its future descendants, but it does not create namespaces, hide process IDs, virtualize the filesystem, or isolate the process from every kernel interface. For serious untrusted execution, combine Landlock with a controlled environment, resource limits, seccomp, and process isolation appropriate to your threat model. -`Landlock.restrict!` only installs a Landlock ruleset. It does not close already-open file descriptors, impose resource limits, clean the environment, or kill subprocess trees. The subprocess helpers add practical hardening around this: `exec`/`spawn` add controlled environments and `close_others`, while `capture` and `fork` also add optional `rlimits:`, optional `seccomp_deny_network:`, output limits, timeout handling, and process-group termination. This is still not a VM/container boundary. By default, `exec`, `spawn`, and `capture` close inherited file descriptors numbered 3 and higher before installing the sandbox. `Landlock.fork` closes inherited Ruby `IO` objects, but native extensions may hold descriptors Ruby does not expose as `IO` objects. Pass `close_others: false` only when the child intentionally needs inherited descriptors. Direct `landlock-safe-exec` use also closes inherited descriptors by default. +`Landlock.restrict!` only installs a Landlock ruleset. It does not close already-open file descriptors, impose resource limits, clean the environment, or kill subprocess trees. The subprocess helpers add practical hardening around this: `exec`/`spawn` add controlled environments and `close_others`, while `capture` also adds optional `rlimits:`, optional `seccomp_deny_network:`, output limits, timeout handling, and process-group termination. This is still not a VM/container boundary. By default, subprocess helpers close inherited file descriptors numbered 3 and higher before installing the sandbox; pass `close_others: false` only when the child intentionally needs inherited descriptors. Direct `landlock-safe-exec` use also closes inherited descriptors by default. When the native helper is used, sandbox policy details such as allowed paths, TCP ports, scopes, rights, and rlimits are passed as helper argv. They may be visible to same-user processes through tools such as `ps` or `/proc//cmdline` until the helper execs the target command. Environment values passed with `env:` are not encoded in helper argv, but do not put secrets in policy path names or other policy arguments. -If `Landlock.exec`, `Landlock.spawn`, `Landlock.capture`, or `Landlock.fork` child setup fails, the child prints a diagnostic and exits 127. A `Landlock.fork` block exception exits 1. `landlock-safe-exec` setup/argument failures exit 126. These codes can collide with commands that legitimately exit with the same status, so inspect stderr when debugging failures. +If `Landlock.exec`, `Landlock.spawn`, or `Landlock.capture` child setup fails before `exec`, the child prints a diagnostic and exits 127. `landlock-safe-exec` setup/argument failures exit 126. These codes can collide with commands that legitimately exit with the same status, so inspect stderr when debugging failures. Path rules follow the kernel's normal path resolution when the rule is installed. Because paths are opened without `O_NOFOLLOW`, a symlink rule applies to the symlink target's inode, not to the symlink path itself. Capture APIs validate explicit `read:`, `write:`, and `execute:` paths before launching so typos fail closed instead of silently weakening a policy. From aef6457a08cdeef87287469a06f2820f1f558030 Mon Sep 17 00:00:00 2001 From: Alan Guo Xiang Tan Date: Thu, 27 Aug 2026 10:29:38 +0800 Subject: [PATCH 08/14] FIX: Terminate forked captures with their parent A supervised fork could outlive its Ruby parent if that parent was killed before it could run capture cleanup. The orphan then lost its wall-clock supervisor and could continue until another resource limit stopped it. Set a Linux parent-death signal before running the child block, and verify the parent did not exit during setup. This keeps forked captures bounded when a worker crashes. --- ext/landlock/landlock.c | 15 +++++++++++++++ lib/landlock/native.rb | 4 ++++ lib/landlock/runner/fork.rb | 3 +++ test/landlock_fork_test.rb | 37 +++++++++++++++++++++++++++++++++++++ 4 files changed, 59 insertions(+) diff --git a/ext/landlock/landlock.c b/ext/landlock/landlock.c index 55b8f43..84f9f08 100644 --- a/ext/landlock/landlock.c +++ b/ext/landlock/landlock.c @@ -2,6 +2,7 @@ #include "landlock_native.h" #include "seccomp_deny_network.h" +#include #include static VALUE mLandlock; @@ -135,6 +136,18 @@ static VALUE rb_ll_pidfd_open(VALUE self, VALUE pid_value) { #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) { @@ -165,6 +178,8 @@ void Init_landlock(void) { 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, "_pidfd_open", rb_ll_pidfd_open, 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)); diff --git a/lib/landlock/native.rb b/lib/landlock/native.rb index 546c9c1..8e0f0be 100644 --- a/lib/landlock/native.rb +++ b/lib/landlock/native.rb @@ -35,6 +35,10 @@ def pidfd_open(pid) Landlock.__send__(:_pidfd_open, pid) end + def set_parent_death_signal! + Landlock.__send__(:_set_parent_death_signal) + end + def seccomp_deny_network! Landlock.seccomp_deny_network! end diff --git a/lib/landlock/runner/fork.rb b/lib/landlock/runner/fork.rb index eca08e1..2ee2c6f 100644 --- a/lib/landlock/runner/fork.rb +++ b/lib/landlock/runner/fork.rb @@ -114,9 +114,12 @@ def capture_pipes(timeout:, stdin:, max_output_bytes:, truncate_output:) stdout_reader, stdout_writer = IO.pipe stderr_reader, stderr_writer = IO.pipe stdin_reader, stdin_writer = IO.pipe + parent_pid = ::Process.pid pid = fork do + Landlock::Native.set_parent_death_signal! + exit! 1 if ::Process.ppid != parent_pid stdout_reader.close stderr_reader.close stdin_writer.close diff --git a/test/landlock_fork_test.rb b/test/landlock_fork_test.rb index 08aab4f..7441ae7 100644 --- a/test/landlock_fork_test.rb +++ b/test/landlock_fork_test.rb @@ -46,6 +46,34 @@ def test_fork_enforces_timeout refute_predicate result, :success? end + def test_fork_child_exits_with_its_parent + skip "Landlock unsupported" unless Landlock.supported? + + Dir.mktmpdir do |directory| + pid_path = File.join(directory, "child.pid") + supervisor_pid = + fork do + Landlock.fork(write: [directory]) do + File.write(pid_path, Process.pid) + sleep 30 + end + end + + sleep 0.01 until File.exist?(pid_path) + child_pid = Integer(File.read(pid_path)) + Process.kill("KILL", supervisor_pid) + Process.waitpid(supervisor_pid) + + deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 1 + while process_alive?(child_pid) && + Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline + sleep 0.01 + end + + refute process_alive?(child_pid) + end + end + def test_fork_applies_the_filesystem_policy skip "Landlock unsupported" unless Landlock.supported? @@ -111,4 +139,13 @@ def test_fork_requires_a_block assert_equal "fork requires a block", error.message end + + private + + def process_alive?(pid) + Process.kill(0, pid) + !File.read("/proc/#{pid}/stat").split.fetch(2).eql?("Z") + rescue Errno::ESRCH + false + end end From e39d65acd53fcee8d44fc0efcdb7c846e153cab9 Mon Sep 17 00:00:00 2001 From: Alan Guo Xiang Tan Date: Fri, 28 Aug 2026 11:09:42 +0800 Subject: [PATCH 09/14] FEATURE: Add optional Landlock.fork fallback --- README.md | 13 +++++- lib/landlock/execution.rb | 21 +++++++-- lib/landlock/runner/fork.rb | 7 +-- test/landlock_fork_test.rb | 86 +++++++++++++++++++++++++++++++++++-- 4 files changed, 115 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 857093e..1a31a9e 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,18 @@ 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!`. -`Landlock.fork` requires an actual restriction and Linux Landlock support; it never runs the block unsandboxed. 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. +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`. `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. 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. diff --git a/lib/landlock/execution.rb b/lib/landlock/execution.rb index c4f720f..de2c7cd 100644 --- a/lib/landlock/execution.rb +++ b/lib/landlock/execution.rb @@ -80,10 +80,22 @@ def capture!(argv, **options) capture_with(argv, raise_on_failure: true, **options) end - def fork(**options, &block) + 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 - Runner::Fork.call_block(**prepare_capture_options(**options), &block) + Runner::Fork.call_block( + **prepare_capture_options(**options, require_landlock: enforce_landlock), + enforce_landlock:, + &block + ) rescue OutputTooLargeError => error result = error.result raise CommandError.new( @@ -181,9 +193,10 @@ def prepare_capture_options( rlimits: {}, seccomp_deny_network: false, max_output_bytes: nil, - truncate_output: false + truncate_output: false, + require_landlock: true ) - ensure_landlock_supported! + 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) diff --git a/lib/landlock/runner/fork.rb b/lib/landlock/runner/fork.rb index 2ee2c6f..f754b86 100644 --- a/lib/landlock/runner/fork.rb +++ b/lib/landlock/runner/fork.rb @@ -95,10 +95,10 @@ def call( end end - def call_block(timeout:, stdin:, max_output_bytes:, truncate_output:, **options, &block) + def call_block(timeout:, stdin:, max_output_bytes:, truncate_output:, enforce_landlock:, **options, &block) capture_pipes(timeout:, stdin:, max_output_bytes:, truncate_output:) do begin - prepare_forked_block!(**options) + prepare_forked_block!(**options, enforce_landlock:) rescue Exception => error Runner.exit_child!(error) end @@ -198,13 +198,14 @@ def prepare_forked_block!( close_others:, rlimits:, seccomp_deny_network:, + enforce_landlock:, **policy ) close_inherited_ios if close_others Dir.public_send(:chdir, chdir) if chdir ENV.clear if unsetenv_others env&.each { |key, value| value.nil? ? ENV.delete(key) : ENV[key] = value } - Landlock.restrict!(**policy) if Policy.requested?(**policy) + Landlock.restrict!(**policy) if enforce_landlock && Policy.requested?(**policy) Landlock::Native.seccomp_deny_network! if seccomp_deny_network Rlimits.apply!(rlimits) end diff --git a/test/landlock_fork_test.rb b/test/landlock_fork_test.rb index 7441ae7..0a772fe 100644 --- a/test/landlock_fork_test.rb +++ b/test/landlock_fork_test.rb @@ -3,6 +3,87 @@ require_relative "test_helper" class LandlockForkTest < LandlockTestCase + def test_fork_raises_when_landlock_is_unsupported_by_default + Landlock.stub(:abi_version, 0) do + assert_raises(Landlock::UnsupportedError) { Landlock.fork(rlimits: { open_files: 64 }) { print "unreachable" } } + end + end + + def test_fork_runs_without_landlock_when_explicitly_requested + skip "Landlock fallback is Linux-only" if RUBY_PLATFORM !~ /linux/ + + Dir.mktmpdir do |directory| + path = File.join(directory, "secret") + File.write(path, "secret") + + Landlock.stub(:abi_version, 0) do + result = + Landlock.fork( + on_unsupported: :run_without_landlock, + read: [], + write: [], + timeout: 1, + env: { + "LANDLOCK_FORK_FALLBACK" => "enabled" + }, + rlimits: { + open_files: 32 + } + ) { print [File.read(path), ENV.fetch("LANDLOCK_FORK_FALLBACK"), Process.getrlimit(:NOFILE).first].join(":") } + + assert_equal "secret:enabled:32", result.stdout + assert_predicate result, :success? + end + end + end + + def test_fork_fallback_enforces_timeout + skip "Landlock fallback is Linux-only" if RUBY_PLATFORM !~ /linux/ + + Landlock.stub(:abi_version, 0) do + result = + Landlock.fork(on_unsupported: :run_without_landlock, timeout: 0.01, rlimits: { open_files: 64 }) { sleep 30 } + + assert_predicate result, :timed_out? + refute_predicate result, :success? + end + end + + def test_fork_fallback_applies_seccomp + skip "Landlock fallback is Linux-only" if RUBY_PLATFORM !~ /linux/ + + Landlock.stub(:abi_version, 0) do + result = + Landlock.fork(on_unsupported: :run_without_landlock, seccomp_deny_network: true) do + Socket.new(:INET, :STREAM) + rescue Errno::EPERM + print "denied" + end + + assert_equal "denied", result.stdout + assert_predicate result, :success? + end + end + + def test_fork_rejects_an_invalid_on_unsupported_value + error = + assert_raises(ArgumentError) do + Landlock.fork(on_unsupported: :ignore, rlimits: { open_files: 64 }) { print "unreachable" } + end + + assert_equal "on_unsupported must be :raise or :run_without_landlock", error.message + end + + def test_fork_does_not_fallback_on_non_linux + skip "Non-Linux behavior" if RUBY_PLATFORM.include?("linux") + + Landlock.stub(:abi_version, 0) do + assert_raises(Landlock::UnsupportedError) do + Landlock.fork(on_unsupported: :run_without_landlock, rlimits: { open_files: 64 }) { print "unreachable" } + end + end + end + def test_fork_captures_an_inherited_ruby_block skip "Landlock unsupported" unless Landlock.supported? @@ -65,10 +146,7 @@ def test_fork_child_exits_with_its_parent Process.waitpid(supervisor_pid) deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 1 - while process_alive?(child_pid) && - Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline - sleep 0.01 - end + sleep 0.01 while process_alive?(child_pid) && Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline refute process_alive?(child_pid) end From e0305cc1e43a42259427f5bd7b27808e30ff5d39 Mon Sep 17 00:00:00 2001 From: Sam Saffron Date: Fri, 28 Aug 2026 14:12:20 +1000 Subject: [PATCH 10/14] FIX: Close inherited file descriptors in forks Prevent sandboxed children from retaining raw descriptors opened by the parent. Use close_range when available, with procfs and descriptor-limit fallbacks, and cover the behavior with a regression test. --- ext/landlock/landlock.c | 41 +++++++++++++++++++++++++++++++++++++ lib/landlock/native.rb | 4 ++++ lib/landlock/runner/fork.rb | 2 ++ test/landlock_fork_test.rb | 17 +++++++++++++++ 4 files changed, 64 insertions(+) diff --git a/ext/landlock/landlock.c b/ext/landlock/landlock.c index 84f9f08..1c7e14f 100644 --- a/ext/landlock/landlock.c +++ b/ext/landlock/landlock.c @@ -5,6 +5,11 @@ #include #include +#ifdef __linux__ +#include +#include +#endif + static VALUE mLandlock; static VALUE eLandlockError; static VALUE eSyscallError; @@ -122,6 +127,41 @@ static VALUE rb_ll_close_fd(VALUE self, VALUE fd_value) { return Qnil; } +static VALUE rb_ll_close_inherited_fds(VALUE self) { +#ifdef SYS_close_range + if (syscall(SYS_close_range, 3U, ~0U, 0U) == 0) { + return Qtrue; + } +#endif + +#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) { + 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++) { + 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); @@ -177,6 +217,7 @@ 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, "_set_parent_death_signal", rb_ll_set_parent_death_signal, 0); diff --git a/lib/landlock/native.rb b/lib/landlock/native.rb index 8e0f0be..6f698dc 100644 --- a/lib/landlock/native.rb +++ b/lib/landlock/native.rb @@ -31,6 +31,10 @@ 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 diff --git a/lib/landlock/runner/fork.rb b/lib/landlock/runner/fork.rb index f754b86..856a62b 100644 --- a/lib/landlock/runner/fork.rb +++ b/lib/landlock/runner/fork.rb @@ -220,6 +220,8 @@ def close_inherited_ios io.close rescue IOError end + + Landlock::Native.close_inherited_fds! end end end diff --git a/test/landlock_fork_test.rb b/test/landlock_fork_test.rb index 0a772fe..37a72be 100644 --- a/test/landlock_fork_test.rb +++ b/test/landlock_fork_test.rb @@ -200,6 +200,23 @@ def test_fork_closes_inherited_io writer&.close end + def test_fork_closes_inherited_raw_file_descriptors + skip "Landlock unsupported" unless Landlock.supported? + + fd = IO.sysopen(File::NULL) + result = + Landlock.fork(rlimits: { open_files: 64 }) do + IO.for_fd(fd, autoclose: false).stat + print "open" + rescue Errno::EBADF + print "closed" + end + + assert_equal "closed", result.stdout + ensure + Landlock::Native.close_fd(fd) if fd + end + def test_fork_enforces_output_limit skip "Landlock unsupported" unless Landlock.supported? From 735eea9de3a75014783af18af3b877c7d3193faf Mon Sep 17 00:00:00 2001 From: Sam Saffron Date: Fri, 28 Aug 2026 16:36:28 +1000 Subject: [PATCH 11/14] FIX: Kill fork descendants with supervisor Make Landlock.fork workers terminate their process group when the supervising Ruby thread exits. This prevents ordinary descendants from surviving after their worker loses its parent while preserving existing capture behavior for other runner paths. Document the lifecycle guarantee and its process-group limitations, and cover descendant cleanup with an integration test. --- README.md | 2 ++ ext/landlock/landlock.c | 46 +++++++++++++++++++++++++++++++++ lib/landlock/native.rb | 4 +++ lib/landlock/runner/fork.rb | 27 ++++++++++++++++---- test/landlock_fork_test.rb | 51 ++++++++++++++++++++++++++++++++++++- 5 files changed, 124 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 1a31a9e..2803996 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,8 @@ result = Landlock.fork( 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`. `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 diff --git a/ext/landlock/landlock.c b/ext/landlock/landlock.c index 1c7e14f..39ce3a3 100644 --- a/ext/landlock/landlock.c +++ b/ext/landlock/landlock.c @@ -176,6 +176,50 @@ 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) { @@ -219,6 +263,8 @@ void Init_landlock(void) { 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); diff --git a/lib/landlock/native.rb b/lib/landlock/native.rb index 6f698dc..65588fb 100644 --- a/lib/landlock/native.rb +++ b/lib/landlock/native.rb @@ -39,6 +39,10 @@ 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 diff --git a/lib/landlock/runner/fork.rb b/lib/landlock/runner/fork.rb index 856a62b..222adbd 100644 --- a/lib/landlock/runner/fork.rb +++ b/lib/landlock/runner/fork.rb @@ -96,7 +96,13 @@ def call( end def call_block(timeout:, stdin:, max_output_bytes:, truncate_output:, enforce_landlock:, **options, &block) - capture_pipes(timeout:, stdin:, max_output_bytes:, truncate_output:) do + capture_pipes( + timeout:, + stdin:, + max_output_bytes:, + truncate_output:, + kill_process_group_on_parent_death: true + ) do begin prepare_forked_block!(**options, enforce_landlock:) rescue Exception => error @@ -110,7 +116,13 @@ def call_block(timeout:, stdin:, max_output_bytes:, truncate_output:, enforce_la end end - def capture_pipes(timeout:, stdin:, max_output_bytes:, truncate_output:) + def capture_pipes( + timeout:, + stdin:, + max_output_bytes:, + truncate_output:, + kill_process_group_on_parent_death: false + ) stdout_reader, stdout_writer = IO.pipe stderr_reader, stderr_writer = IO.pipe stdin_reader, stdin_writer = IO.pipe @@ -118,12 +130,17 @@ def capture_pipes(timeout:, stdin:, max_output_bytes:, truncate_output:) pid = fork do - Landlock::Native.set_parent_death_signal! - exit! 1 if ::Process.ppid != parent_pid + # Arm group cleanup only after leaving the supervisor's process group. + ::Process.setpgrp + if kill_process_group_on_parent_death + Landlock::Native.arm_parent_death_process_group!(parent_pid) + else + Landlock::Native.set_parent_death_signal! + exit! 1 if ::Process.ppid != parent_pid + end stdout_reader.close stderr_reader.close stdin_writer.close - ::Process.setpgrp STDIN.reopen(stdin_reader) STDOUT.reopen(stdout_writer) STDERR.reopen(stderr_writer) diff --git a/test/landlock_fork_test.rb b/test/landlock_fork_test.rb index 37a72be..8658a2b 100644 --- a/test/landlock_fork_test.rb +++ b/test/landlock_fork_test.rb @@ -152,6 +152,55 @@ def test_fork_child_exits_with_its_parent end end + def test_fork_descendants_exit_with_their_supervisor + skip "Landlock unsupported" unless Landlock.supported? + + supervisor_pid = nil + descendant_pid = nil + + Dir.mktmpdir do |directory| + pid_path = File.join(directory, "descendant.pid") + fork_options = { write: [directory], close_others: false } + fork_options[:scope] = [:signal] if Landlock.abi_version >= 6 + supervisor_pid = + fork do + Landlock.fork(**fork_options) do + # A nested Ruby fork needs the runtime descriptors inherited by the worker. + fork do + contents = [Process.pid, Process.ppid, Process.getpgrp].join(":") + File.write("#{pid_path}.tmp", contents) + File.rename("#{pid_path}.tmp", pid_path) + sleep 30 + end + sleep 30 + end + end + + descendant_pid, worker_pid, process_group = + Timeout.timeout(2) do + loop do + break File.read(pid_path).split(":").map { |value| Integer(value) } if File.size?(pid_path) + + sleep 0.01 + end + end + + assert_equal worker_pid, process_group, "descendant did not inherit the worker process group" + + Process.kill("KILL", supervisor_pid) + Process.waitpid(supervisor_pid) + supervisor_pid = nil + + deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 1 + sleep 0.01 while process_alive?(descendant_pid) && Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline + + refute process_alive?(descendant_pid), "forked descendant survived its supervisor" + end + ensure + kill_process_if_alive(supervisor_pid) if supervisor_pid + kill_process_if_alive(descendant_pid) if descendant_pid + end + def test_fork_applies_the_filesystem_policy skip "Landlock unsupported" unless Landlock.supported? @@ -240,7 +289,7 @@ def test_fork_requires_a_block def process_alive?(pid) Process.kill(0, pid) !File.read("/proc/#{pid}/stat").split.fetch(2).eql?("Z") - rescue Errno::ESRCH + rescue Errno::ESRCH, Errno::ENOENT false end end From 0a22043850813a30deee53f5cb90c52de0a03598 Mon Sep 17 00:00:00 2001 From: Sam Saffron Date: Fri, 28 Aug 2026 16:54:03 +1000 Subject: [PATCH 12/14] FIX: Harden Landlock.fork failure handling Require fallback execution to include seccomp network denial or an rlimit so unsupported Landlock policies cannot run without an effective restriction. Capture child bootstrap failures through the configured stderr pipe and exit with status 127, ensuring callers receive setup diagnostics. --- README.md | 2 +- lib/landlock/execution.rb | 11 +++++++- lib/landlock/runner.rb | 5 ++-- lib/landlock/runner/fork.rb | 50 ++++++++++++++++++++++--------------- test/landlock_fork_test.rb | 26 +++++++++++++++++++ 5 files changed, 70 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 2803996..0293104 100644 --- a/README.md +++ b/README.md @@ -165,7 +165,7 @@ result = Landlock.fork( ) { |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`. `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. +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. diff --git a/lib/landlock/execution.rb b/lib/landlock/execution.rb index de2c7cd..0391c1e 100644 --- a/lib/landlock/execution.rb +++ b/lib/landlock/execution.rb @@ -91,8 +91,11 @@ def fork(on_unsupported: :raise, **options, &block) 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( - **prepare_capture_options(**options, require_landlock: enforce_landlock), + **capture_options, enforce_landlock:, &block ) @@ -270,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:, diff --git a/lib/landlock/runner.rb b/lib/landlock/runner.rb index 025af25..68e0f6f 100644 --- a/lib/landlock/runner.rb +++ b/lib/landlock/runner.rb @@ -16,8 +16,9 @@ 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 diff --git a/lib/landlock/runner/fork.rb b/lib/landlock/runner/fork.rb index 222adbd..59fa93e 100644 --- a/lib/landlock/runner/fork.rb +++ b/lib/landlock/runner/fork.rb @@ -130,27 +130,31 @@ def capture_pipes( pid = fork do - # Arm group cleanup only after leaving the supervisor's process group. - ::Process.setpgrp - if kill_process_group_on_parent_death - Landlock::Native.arm_parent_death_process_group!(parent_pid) - else - Landlock::Native.set_parent_death_signal! - exit! 1 if ::Process.ppid != parent_pid + begin + # Arm group cleanup only after leaving the supervisor's process group. + ::Process.setpgrp + if kill_process_group_on_parent_death + Landlock::Native.arm_parent_death_process_group!(parent_pid) + else + Landlock::Native.set_parent_death_signal! + exit! 1 if ::Process.ppid != parent_pid + end + stdout_reader.close + stderr_reader.close + stdin_writer.close + STDIN.reopen(stdin_reader) + STDOUT.reopen(stdout_writer) + STDERR.reopen(stderr_writer) + STDOUT.sync = true + STDERR.sync = true + stdin_reader.close + stdout_writer.close + stderr_writer.close + + yield + rescue Exception => error + Runner.exit_child!(error, stderr: capture_error_stream(stderr_writer)) end - stdout_reader.close - stderr_reader.close - stdin_writer.close - STDIN.reopen(stdin_reader) - STDOUT.reopen(stdout_writer) - STDERR.reopen(stderr_writer) - STDOUT.sync = true - STDERR.sync = true - stdin_reader.close - stdout_writer.close - stderr_writer.close - - yield end stdin_reader.close @@ -182,6 +186,12 @@ def capture_pipes( end end + def capture_error_stream(stderr_writer) + stderr_writer && !stderr_writer.closed? ? stderr_writer : STDERR + rescue IOError + STDERR + end + def setup_child!( argv, read:, diff --git a/test/landlock_fork_test.rb b/test/landlock_fork_test.rb index 8658a2b..7549410 100644 --- a/test/landlock_fork_test.rb +++ b/test/landlock_fork_test.rb @@ -37,6 +37,19 @@ def test_fork_runs_without_landlock_when_explicitly_requested end end + def test_fork_fallback_rejects_landlock_only_policy + skip "Landlock fallback is Linux-only" if RUBY_PLATFORM !~ /linux/ + + Landlock.stub(:abi_version, 0) do + error = + assert_raises(ArgumentError) do + Landlock.fork(on_unsupported: :run_without_landlock, read: []) { print "unreachable" } + end + + assert_equal "Landlock fallback requires seccomp_deny_network or rlimits", error.message + end + end + def test_fork_fallback_enforces_timeout skip "Landlock fallback is Linux-only" if RUBY_PLATFORM !~ /linux/ @@ -109,6 +122,19 @@ def test_fork_returns_block_errors refute_predicate result, :success? end + def test_fork_captures_child_bootstrap_errors + skip "Landlock unsupported" unless Landlock.supported? + + result = nil + Landlock::Native.stub(:arm_parent_death_process_group!, ->(*) { raise "bootstrap failed" }) do + result = Landlock.fork(rlimits: { open_files: 64 }) { print "unreachable" } + end + + assert_equal 127, result.status.exitstatus + assert_equal "Landlock child setup failed: RuntimeError: bootstrap failed\n", result.stderr + refute_predicate result, :success? + end + def test_fork_discards_the_block_return_value skip "Landlock unsupported" unless Landlock.supported? From ae0216fb50f87a2312175775ead7cee0987dc4e8 Mon Sep 17 00:00:00 2001 From: Sam Saffron Date: Fri, 28 Aug 2026 17:11:56 +1000 Subject: [PATCH 13/14] FIX: Preserve fork child process semantics Keep Ruby-reserved descriptors open while closing inherited files so the forked interpreter remains functional. Preserve explicit exit and signal statuses, and report block failures through the captured stderr stream. --- ext/landlock/landlock.c | 15 ++++++------ lib/landlock/runner.rb | 5 ++-- lib/landlock/runner/fork.rb | 6 +++++ test/landlock_fork_test.rb | 49 +++++++++++++++++++++++++++++++++++++ 4 files changed, 65 insertions(+), 10 deletions(-) diff --git a/ext/landlock/landlock.c b/ext/landlock/landlock.c index 39ce3a3..9fad9a7 100644 --- a/ext/landlock/landlock.c +++ b/ext/landlock/landlock.c @@ -128,12 +128,8 @@ static VALUE rb_ll_close_fd(VALUE self, VALUE fd_value) { } static VALUE rb_ll_close_inherited_fds(VALUE self) { -#ifdef SYS_close_range - if (syscall(SYS_close_range, 3U, ~0U, 0U) == 0) { - return Qtrue; - } -#endif - + /* 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) { @@ -143,7 +139,8 @@ static VALUE rb_ll_close_inherited_fds(VALUE self) { char *end = NULL; errno = 0; long fd = strtol(entry->d_name, &end, 10); - if (errno == 0 && end && *end == '\0' && fd >= 3 && fd != dir_fd) { + if (errno == 0 && end && *end == '\0' && fd >= 3 && fd != dir_fd && + !rb_reserved_fd_p((int)fd)) { close((int)fd); } } @@ -157,7 +154,9 @@ static VALUE rb_ll_close_inherited_fds(VALUE self) { max_fd = 1024; } for (long fd = 3; fd < max_fd; fd++) { - close((int)fd); + if (!rb_reserved_fd_p((int)fd)) { + close((int)fd); + } } return Qtrue; } diff --git a/lib/landlock/runner.rb b/lib/landlock/runner.rb index 68e0f6f..29a1db7 100644 --- a/lib/landlock/runner.rb +++ b/lib/landlock/runner.rb @@ -23,8 +23,9 @@ def exit_child!(error, stderr: STDERR) exit! 127 end - def exit_forked_block!(error) - warn "Landlock forked block failed: #{error.class}: #{error.message}" + def exit_forked_block!(error, stderr: STDERR) + stderr.puts "Landlock forked block failed: #{error.class}: #{error.message}" + stderr.flush ensure exit! 1 end diff --git a/lib/landlock/runner/fork.rb b/lib/landlock/runner/fork.rb index 59fa93e..971e2ba 100644 --- a/lib/landlock/runner/fork.rb +++ b/lib/landlock/runner/fork.rb @@ -111,6 +111,10 @@ def call_block(timeout:, stdin:, max_output_bytes:, truncate_output:, enforce_la block.call(STDOUT, STDERR) exit! 0 + rescue SystemExit => error + exit! error.status + rescue SignalException + raise rescue Exception => error Runner.exit_forked_block!(error) end @@ -152,6 +156,8 @@ def capture_pipes( stderr_writer.close yield + rescue SystemExit, SignalException + raise rescue Exception => error Runner.exit_child!(error, stderr: capture_error_stream(stderr_writer)) end diff --git a/test/landlock_fork_test.rb b/test/landlock_fork_test.rb index 7549410..7c1b509 100644 --- a/test/landlock_fork_test.rb +++ b/test/landlock_fork_test.rb @@ -122,6 +122,52 @@ def test_fork_returns_block_errors refute_predicate result, :success? end + def test_fork_preserves_system_exit_status + skip "Landlock unsupported" unless Landlock.supported? + + [0, 7].each do |exit_status| + result = Landlock.fork(rlimits: { open_files: 64 }) { exit exit_status } + + assert_predicate result.status, :exited? + assert_equal exit_status, result.status.exitstatus + assert_equal exit_status.zero?, result.success? + assert_empty result.stderr + end + end + + def test_fork_preserves_signal_status + skip "Landlock unsupported" unless Landlock.supported? + + result = + Landlock.fork(rlimits: { open_files: 64 }) do + Process.kill("TERM", Process.pid) + sleep 1 + end + + assert_predicate result.status, :signaled? + assert_equal Signal.list.fetch("TERM"), result.status.termsig + assert_empty result.stderr + refute_predicate result, :success? + end + + def test_fork_captures_block_errors_when_global_stderr_is_reassigned + skip "Landlock unsupported" unless Landlock.supported? + + original_stderr = $stderr + replacement_stderr = StringIO.new + result = + begin + $stderr = replacement_stderr + Landlock.fork(rlimits: { open_files: 64 }) { raise "failed" } + ensure + $stderr = original_stderr + end + + assert_equal 1, result.status.exitstatus + assert_equal "Landlock forked block failed: RuntimeError: failed\n", result.stderr + assert_empty replacement_stderr.string + end + def test_fork_captures_child_bootstrap_errors skip "Landlock unsupported" unless Landlock.supported? @@ -150,6 +196,9 @@ def test_fork_enforces_timeout result = Landlock.fork(timeout: 0.01, rlimits: { open_files: 64 }) { sleep 30 } assert_predicate result, :timed_out? + assert_predicate result.status, :signaled? + assert_equal Signal.list.fetch("TERM"), result.status.termsig + assert_empty result.stderr refute_predicate result, :success? end From 5cef41635b25e9b1130adea9569acb8ca41d8e63 Mon Sep 17 00:00:00 2001 From: Sam Saffron Date: Fri, 28 Aug 2026 17:21:09 +1000 Subject: [PATCH 14/14] correct flake --- lib/landlock/runner/fork.rb | 2 ++ test/landlock_fork_test.rb | 16 ++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/lib/landlock/runner/fork.rb b/lib/landlock/runner/fork.rb index 971e2ba..2261091 100644 --- a/lib/landlock/runner/fork.rb +++ b/lib/landlock/runner/fork.rb @@ -105,6 +105,8 @@ def call_block(timeout:, stdin:, max_output_bytes:, truncate_output:, enforce_la ) do begin prepare_forked_block!(**options, enforce_landlock:) + rescue SystemExit, SignalException + raise rescue Exception => error Runner.exit_child!(error) end diff --git a/test/landlock_fork_test.rb b/test/landlock_fork_test.rb index 7c1b509..242035c 100644 --- a/test/landlock_fork_test.rb +++ b/test/landlock_fork_test.rb @@ -181,6 +181,22 @@ def test_fork_captures_child_bootstrap_errors refute_predicate result, :success? end + def test_fork_timeout_during_child_bootstrap_preserves_signal_status + skip "Landlock unsupported" unless Landlock.supported? + + slow_setup = ->(**) { sleep 30 } + result = nil + Landlock::Runner::Fork.stub(:prepare_forked_block!, slow_setup) do + result = Landlock.fork(timeout: 0.01, rlimits: { open_files: 64 }) { raise "unreachable" } + end + + assert_predicate result, :timed_out? + assert_predicate result.status, :signaled? + assert_equal Signal.list.fetch("TERM"), result.status.termsig + assert_empty result.stderr + refute_predicate result, :success? + end + def test_fork_discards_the_block_return_value skip "Landlock unsupported" unless Landlock.supported?