Class: LittleGhost::Sandboxes::Bubblewrap

Inherits:
LittleGhost::Sandbox::IsolatedBackend show all
Defined in:
lib/little_ghost/sandboxes/bubblewrap.rb

Overview

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.

Constant Summary collapse

DEFAULT_EXECUTABLE =

:nodoc:

"/usr/bin/bwrap"
RUNTIME_ROOTS =

:nodoc:

%w[/usr].freeze
COMPATIBILITY_ROOTS =

:nodoc:

%w[/bin /sbin /lib /lib64].freeze

Instance Attribute Summary

Attributes inherited from LittleGhost::Sandbox::IsolatedBackend

#effective_policy

Class Method Summary collapse

Instance Method Summary collapse

Methods inherited from LittleGhost::Sandbox::IsolatedBackend

#list, #read, #replace, #writable?, #write

Constructor Details

#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) ⇒ Bubblewrap

Builds a command-scoped Linux namespace sandbox.



53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
# 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

Class Method Details

.backend_capabilitiesObject

Describes the isolation and operations provided by this backend.



41
42
43
44
45
46
47
48
49
50
# 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

.probe(executable: DEFAULT_EXECUTABLE, platform: RUBY_PLATFORM) ⇒ Object

Reports whether Bubblewrap is usable on platform.



19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# 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

Instance Method Details

#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) ⇒ Object

Returns the exact Bubblewrap policy arguments used before the command.



163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
# 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

#capabilitiesObject

Returns this backend's declared capabilities.



74
# File 'lib/little_ghost/sandboxes/bubblewrap.rb', line 74

def capabilities = self.class.backend_capabilities

#closeObject

Stops the policy gateway. Calling close more than once is safe.



100
101
102
103
104
# File 'lib/little_ghost/sandboxes/bubblewrap.rb', line 100

def close
  close_gateway
  @opened = false
  nil
end

#exec_program(command, scope: nil, cwd: nil, environment: {}, inherit_environment: false) ⇒ Object

Replaces the current process with an interactively attached Bubblewrap command after applying the same policy and scope validation as #execute.



154
155
156
157
158
159
160
# 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

#execute_program(command, timeout:, context: nil, max_output_bytes: nil, environment: {}, inherit_environment: false, scope: nil, cwd: nil) ⇒ Object

Executes command in a fresh Bubblewrap namespace.

Raises:

  • (ArgumentError)


107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
# 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

#open(run: nil) ⇒ Object

Validates dependencies and starts any policy gateway.



77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
# 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

#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) ⇒ Object

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.



130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
# 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