seccomp-ruby

Native CRuby bindings for libseccomp. The gem exposes the C API through Seccomp::LowLevel and a Ruby-friendly filter, argument DSL, transaction, and user-notification API.

[!WARNING] A loaded seccomp filter cannot be removed or relaxed before the process exits. Ruby itself uses many system calls; an incomplete allowlist can crash the VM immediately. Always validate a filter in a forked child process before using it in production.

Requirements

  • Linux and CRuby 3.1 or newer
  • libseccomp 2.5 or newer and its development headers
  • A C compiler

On Debian or Ubuntu:

sudo apt-get install libseccomp-dev build-essential
bundle install
bundle exec rake compile

The gem name is currently seccomp-ruby:

gem "seccomp-ruby"

Quick start

Construct and inspect filters in the parent process, but load them only after fork:

require "libseccomp"

filter = Seccomp.filter(default: :kill_process) do
  no_new_privs true
  deny :ptrace, errno: Errno::EPERM
  allow :read, :write, :exit, :exit_group, :rt_sigreturn
end

puts filter.to_pfc
pid = fork do
  filter.load!
  # Run sandboxed work here.
  Process.exit!(0)
end
Process.waitpid(pid)
filter.close

Argument comparisons use explicit keywords for masked equality:

filter.allow(:openat, filter.arg(2).masked_eq(mask: 0o3, value: 0))
filter.deny(:close, filter.arg(0).eq(2), errno: Errno::EBADF)

Use Seccomp.supports? before calling optional APIs. It checks both compile-time availability and the runtime API level where applicable:

filter.transaction { filter.allow(:read) } if Seccomp.supports?(:transaction)
filter.precompute if Seccomp.supports?(:precompute)
notification.add_fd(file) if Seccomp.supports?(:notify_addfd)

User notifications

Seccomp::Notifier#receive integrates with Ruby IO waiting and accepts a timeout. Notification errors use normal positive errno values at the Ruby boundary:

notification.deny(Errno::EPERM) # converted to the kernel's negative errno convention
notification.respond(error: Errno::EPERM) # direct responses use the same conversion
notification.continue!

[!CAUTION] Notification arguments may contain pointers into another process. If you read through /proc/<pid>/mem, call notification.valid? again after reading and before responding. This reduces, but cannot eliminate, time-of-check/time-of-use races. continue! must only be used when the target syscall is safe to execute with its current arguments.

libseccomp keeps one notification listener in process-global state. Closing its Notifier or owning Filter closes and resets that listener. A second notification filter is rejected while the listener is active; use one notification supervisor per process.

[!WARNING] A loaded notification filter must allow close(2) while its listener is being released. A rule that denies or kills close(2) can leave the listener open, and a notification rule for close(2) requires a supervisor to answer during cleanup. This cannot be repaired after the irreversible filter is loaded; keep the supervisor running or end the sandbox with Process.exit!.

If file descriptor 0 is closed when a notification filter is loaded, the gem reserves it with /dev/null and keeps it open after a successful load; closing it afterward could itself be blocked by the irreversible filter. Filters without notification actions leave file descriptor 0 unchanged. If a notification load fails after an earlier filter was loaded, the reservation is kept rather than risk an internal close(2) being intercepted. Rolling back a transaction that added a notification rule requires Filter#reset or a new notification rule before loading that filter, due to native libseccomp state retained by the rejected transaction.

Thread and architecture notes

  • tsync applies a filter to every thread. Ruby VM threads require syscalls such as futex, rt_sigprocmask, and membarrier; blocking them may terminate or deadlock the VM.
  • width: 32 masks comparison values to 32 bits. On 32-bit ABIs, upper halves of 64-bit syscall arguments may not be meaningful.
  • x32 and x86_64 are distinct architecture tokens.
  • Seccomp.api_level= changes process-global libseccomp state and should be used once at startup.

Development

bundle exec rake compile
bundle exec rake spec:unit
bundle exec rake spec:live # all load! calls are fork-isolated
bundle exec rubocop
bundle exec rbs validate
bundle exec yard stats --list-undoc

The live suite must run on Linux. In a container, use --security-opt seccomp=unconfined.

License

This project retains its existing MIT license. It dynamically links to libseccomp, which is licensed under LGPL-2.1.