class LittleGhost::Sandboxes::Bubblewrap
Runs each command in a fresh Bubblewrap namespace on Linux. Bubblewrap is selected explicitly and is never installed or replaced with host execution. The namespace shares the outer Linux kernel and trusts the configured runtime roots, mounts, command wrapper, and hosting environment. It governs child processes, not arbitrary Ruby code in the parent runtime.
Public Class Methods
Source
# File lib/little_ghost/sandboxes/bubblewrap.rb, line 41 def self.backend_capabilities Capabilities.new( features: %i[ filesystem_read filesystem_list filesystem_write filesystem_replace process_execute process_spawn process_tree_ownership ], network_modes: %i[inherit none allowlist], isolation: :namespace ) end
Describes the isolation and operations provided by this backend.
# File lib/little_ghost/sandboxes/bubblewrap.rb, line 53 def initialize(workspace:, policy: nil, profiles: {}, limits: {}, bubblewrap: DEFAULT_EXECUTABLE, platform: RUBY_PLATFORM, socat: "/usr/bin/socat", gateway_options: {}, command_wrapper: nil, proc: :new, tmpfs: %w[/tmp /run], masks: [], runtime_roots: RUNTIME_ROOTS, uid: nil, gid: nil) super(workspace:, policy: policy || {}, profiles:, limits:) @bubblewrap = File.expand_path(bubblewrap) @platform = platform @socat = File.expand_path(socat) @gateway_options = gateway_options @command_wrapper = command_wrapper @proc = normalize_proc(proc) @tmpfs = Array(tmpfs).map { |path| Sandbox::Mount.send(:normalize_virtual_path, path) }.uniq.freeze @masks = Array(masks).map { |path| Sandbox::Mount.send(:normalize_virtual_path, path) }.uniq.freeze @runtime_roots = Array(runtime_roots).map { |path| File.expand_path(path) }.uniq.freeze @uid = normalize_identity(uid, "uid") @gid = normalize_identity(gid, "gid") @opened = false end
Builds a command-scoped Linux namespace sandbox.
# File lib/little_ghost/sandboxes/bubblewrap.rb, line 19 def self.probe(executable: DEFAULT_EXECUTABLE, platform: RUBY_PLATFORM) if !platform.include?("linux") {available: false, reason: "Bubblewrap is supported only on Linux", capabilities: Capabilities.new(features: [], network_modes: [])} elsif !File.file?(executable) || !File.executable?(executable) {available: false, reason: "Bubblewrap is not installed at #{executable}", capabilities: Capabilities.new(features: [], network_modes: [])} else result = Sandbox::ProcessRunner.run( command: [executable, "--unshare-user", "--unshare-pid", "--new-session", "--die-with-parent", "--ro-bind", "/", "/", "--", "/bin/true"], timeout: 5, max_output_bytes: 16_384 ) if result.success? {available: true, reason: nil, capabilities: backend_capabilities} else detail = result.stderr.to_s.lines.first.to_s.strip detail = "the namespace probe failed" if detail.empty? {available: false, reason: "Bubblewrap is installed but unavailable: #{detail}", capabilities: Capabilities.new(features: [], network_modes: [])} end end end
Reports whether Bubblewrap is usable on platform.
Public Instance Methods
# File lib/little_ghost/sandboxes/bubblewrap.rb, line 163 def bubblewrap_args(mounts: effective_policy.process_grants(workspace), cwd: workspace.root, environment: effective_policy.environment.to_h, inherit_environment: effective_policy.environment.inherit?, network: effective_policy.network) mounts = protect_execution_mounts(mounts) args = %w[ --unshare-user --unshare-pid --unshare-ipc --unshare-uts --unshare-cgroup-try --new-session --die-with-parent --cap-drop ALL ] args.concat(root_filesystem_args) args << "--unshare-net" unless network.inherit? args.concat(runtime_mount_args) if effective_policy.root_filesystem == :isolated args.concat(%w[--dev /dev]) args.concat(proc_args) @tmpfs.each { |path| args.concat(["--tmpfs", path]) } paths = mounts.map(&:target) + @tmpfs + @masks + [cwd] args.concat(if effective_policy.root_filesystem == :isolated directory_args(paths) else tmpfs_directory_args(paths) end) mounts.each do |mount| args.concat([mount.read_only? ? "--ro-bind" : "--bind", mount.source, mount.target]) end @masks.each { |path| args.concat(["--tmpfs", path, "--remount-ro", path]) } args.concat(["--chdir", validated_cwd(cwd, mounts)]) args << "--remount-ro" << "/" if effective_policy.root_filesystem == :isolated args << "--clearenv" unless inherit_environment environment.each { |name, value| args.concat(["--setenv", name, value]) } args.concat(["--uid", @uid.to_s]) if @uid args.concat(["--gid", @gid.to_s]) if @gid args end
Returns the exact Bubblewrap policy arguments used before the command.
Source
# File lib/little_ghost/sandboxes/bubblewrap.rb, line 74 def capabilities = self.class.backend_capabilities
Returns this backend’s declared capabilities.
Source
# File lib/little_ghost/sandboxes/bubblewrap.rb, line 100 def close close_gateway @opened = false nil end
Stops the policy gateway. Calling close more than once is safe.
# File lib/little_ghost/sandboxes/bubblewrap.rb, line 154 def exec_program(command, scope: nil, cwd: nil, environment: {}, inherit_environment: false) open unless @opened process, inherit = sandbox_process_command( command, scope:, cwd:, environment:, inherit_environment: ) Kernel.exec(inherit ? ENV.to_h : {}, *process, unsetenv_others: !inherit) end
Replaces the current process with an interactively attached Bubblewrap command after applying the same policy and scope validation as #execute.
# File lib/little_ghost/sandboxes/bubblewrap.rb, line 107 def execute_program(command, timeout:, context: nil, max_output_bytes: nil, environment: {}, inherit_environment: false, scope: nil, cwd: nil) timeout = Float(timeout) raise ArgumentError, "timeout must be positive" unless timeout.positive? && timeout.finite? max_output_bytes ||= limits.output_bytes open unless @opened process, inherit = sandbox_process_command( command, scope:, cwd:, environment:, inherit_environment: ) Sandbox::ProcessRunner.run( command: process, timeout:, context:, max_output_bytes:, environment: inherit ? ENV.to_h : {}, inherit_environment: inherit ) end
Executes command in a fresh Bubblewrap namespace.
Source
# File lib/little_ghost/sandboxes/bubblewrap.rb, line 77 def open(run: nil) return self if @opened raise UnsupportedPlatformError, "Bubblewrap sandboxing is supported only on Linux" unless @platform.include?("linux") unless File.file?(@bubblewrap) && File.executable?(@bubblewrap) raise DependencyError, "Bubblewrap sandboxing requires an executable at #{@bubblewrap}" end if effective_policy.network.allowlist? && (!File.file?(@socat) || !File.executable?(@socat)) raise DependencyError, "filtered Bubblewrap egress requires socat at #{@socat}" end validate_mounts! capture_mount_identities! functional_probe! open_gateway(run:, transport: :unix, **@gateway_options) @opened = true self rescue close raise end
Validates dependencies and starts any policy gateway.
# File lib/little_ghost/sandboxes/bubblewrap.rb, line 130 def start_program(command, context: nil, environment: {}, inherit_environment: false, scope: nil, cwd: nil, output_bytes: nil, memory_bytes: nil, cpu_seconds: nil, file_bytes: nil, allow_subprocesses: true) unless allow_subprocesses raise CapabilityError, "Bubblewrap owns subprocess descendants but cannot prohibit their creation" end open unless @opened process, inherit = sandbox_process_command( command, scope:, cwd:, environment:, inherit_environment: ) Sandbox::ProcessSession.new( command: process, environment: inherit ? ENV.to_h : {}, inherit_environment: inherit, output_bytes: output_bytes || limits.output_bytes, memory_bytes:, cpu_seconds:, file_bytes: ) end
Starts a duplex process in a fresh Bubblewrap namespace. Descendants are allowed and remain owned by its PID namespace; Bubblewrap cannot enforce a per-program request to deny subprocess creation.