class LittleGhost::Sandboxes::Seatbelt
Runs child programs under macOS Seatbelt. Seatbelt grants access to the workspace’s existing physical paths; it does not create Linux-style bind mounts or virtual path aliases. Child processes inherit the profile, but macOS cannot provide PID-namespace ownership for detached descendants.
Public Class Methods
Source
# File lib/little_ghost/sandboxes/seatbelt.rb, line 28 def self.backend_capabilities Capabilities.new( features: %i[ filesystem_read filesystem_list filesystem_write filesystem_replace process_execute process_spawn process_spawn_denial ], network_modes: %i[inherit none], isolation: :seatbelt ) end
Capabilities the Seatbelt backend can enforce before a Policy narrows them.
# File lib/little_ghost/sandboxes/seatbelt.rb, line 40 def initialize(workspace:, policy: nil, profiles: {}, limits: {}, executable: DEFAULT_EXECUTABLE, platform: RUBY_PLATFORM) super(workspace:, policy: policy || {}, profiles:, limits:) @executable = File.expand_path(executable) @platform = platform @opened = false end
Builds a Seatbelt backend around workspace without opening it.
# File lib/little_ghost/sandboxes/seatbelt.rb, line 17 def self.probe(executable: DEFAULT_EXECUTABLE, platform: RUBY_PLATFORM) available = platform.include?("darwin") && File.executable?(executable) { available:, reason: available ? nil : "Seatbelt sandboxing requires macOS and #{executable}", capabilities: available ? backend_capabilities : Capabilities.new(features: [], network_modes: []) } end
Reports whether Seatbelt is available and returns its capabilities.
Public Instance Methods
Source
# File lib/little_ghost/sandboxes/seatbelt.rb, line 50 def capabilities supported = self.class.backend_capabilities return supported unless effective_policy.root_filesystem == :isolated Capabilities.new( features: supported.features - [:process_spawn], network_modes: supported.network_modes, isolation: supported.isolation ) end
Effective capabilities after the configured root-filesystem policy is applied.
Source
# File lib/little_ghost/sandboxes/seatbelt.rb, line 82 def close FileUtils.remove_entry(@temporary_directory) if @temporary_directory && File.exist?(@temporary_directory) @temporary_directory = nil @opened = false nil end
Removes temporary storage owned by this backend. Safe to call more than once.
# File lib/little_ghost/sandboxes/seatbelt.rb, line 91 def execute_program(command, timeout:, context: nil, max_output_bytes: nil, environment: {}, inherit_environment: false, scope: nil, cwd: nil) selected_scope = scope || self.scope session = start_program( command, context:, environment:, inherit_environment:, scope: selected_scope, cwd:, output_bytes: max_output_bytes, allow_subprocesses: selected_scope.supports?(:process_spawn) ) session.close_write stdout = +"" stderr = +"" deadline = monotonic_time + Float(timeout) while session.alive? context&.check! remaining = deadline - monotonic_time raise ToolError, "Command timed out after #{timeout} seconds" unless remaining.positive? chunk = session.read(timeout: [remaining, 0.05].min) stdout << chunk.stdout stderr << chunk.stderr end chunk = session.read(timeout: 0) stdout << chunk.stdout stderr << chunk.stderr status = session.wait Sandbox::Execution.new(stdout:, stderr:, exit_code: status&.exitstatus) ensure session&.close end
Runs command to completion under Seatbelt and returns its bounded stdout, stderr, and exit status.
Source
# File lib/little_ghost/sandboxes/seatbelt.rb, line 63 def open(run: nil) return self if @opened raise UnsupportedPlatformError, "Seatbelt sandboxing is supported only on macOS" unless @platform.include?("darwin") raise DependencyError, "Seatbelt sandboxing requires #{@executable}" unless File.executable?(@executable) if effective_policy.network&.allowlist? raise CapabilityError, "Seatbelt does not implement allowlisted network egress" end workspace.validate! @temporary_directory = Dir.mktmpdir("little-ghost-seatbelt-") @opened = true self rescue close raise end
# File lib/little_ghost/sandboxes/seatbelt.rb, line 128 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: false) open unless @opened selected_scope = scope || self.scope selected_scope.validate! if allow_subprocesses && !selected_scope.supports?(:process_spawn) raise CapabilityError, "sandbox scope does not allow subprocess creation" end configured_environment = workspace.environment .merge(effective_policy.environment.to_h) .merge(environment.transform_keys(&:to_s).transform_values(&:to_s)) .merge("TMPDIR" => @temporary_directory) profile = seatbelt_profile(selected_scope, allow_subprocesses:) Sandbox::ProcessSession.new( command: [@executable, "-p", profile, "--", *Array(command).map(&:to_s)], environment: configured_environment, inherit_environment: effective_policy.environment.inherit? && inherit_environment, chdir: cwd ? workspace.resolve(cwd) : workspace.root, output_bytes: output_bytes || limits.output_bytes, memory_bytes:, cpu_seconds:, file_bytes: ) end
Starts command under Seatbelt and returns an owned ProcessSession. The caller must close the returned session.