Class: LittleGhost::Sandbox::Scope

Inherits:
Object
  • Object
show all
Defined in:
lib/little_ghost/sandbox/scope.rb

Overview

A non-owning, capability-reduced view of a Sandbox for one agent or Tool set. Scopes never open or close their parent and cannot widen it. They constrain only callers that receive and use the Scope; code retaining the parent Sandbox retains its broader authority.

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(sandbox:, files: nil, runtime_paths: nil, capabilities: nil, network: nil, parent_scope: nil) ⇒ Scope

Creates a view of sandbox. mounts and capabilities may only narrow the parent scope or sandbox.



12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
# File 'lib/little_ghost/sandbox/scope.rb', line 12

def initialize(sandbox:, files: nil, runtime_paths: nil, capabilities: nil, network: nil, parent_scope: nil)
  @sandbox = sandbox
  @parent_mounts = parent_scope&.process_grants || sandbox.effective_policy.process_grants(sandbox.workspace)
  @parent_tool_denies = if parent_scope
    parent_scope.send(:tool_deny_mounts)
  else
    @parent_mounts.reject(&:tool_visible?)
  end
  requested = scoped_mounts(files, runtime_paths)
  requested_mounts = requested ? preserve_restrictive_overlays(normalize_mounts(requested)) : @parent_mounts
  @mounts = validate_mounts(requested_mounts).sort_by { |mount| -mount.target.length }.freeze
  @tool_deny_mounts = relevant_tool_denies(@mounts).freeze
  parent_capabilities = parent_scope&.capabilities || sandbox.capabilities
  requested_capabilities = capabilities || parent_capabilities
  requested_capabilities = Capabilities.new(features: requested_capabilities) unless requested_capabilities.is_a?(Capabilities)
  @capabilities = parent_capabilities.intersect(requested_capabilities)
  @network = narrow_network(parent_scope&.network || sandbox.effective_policy.network, network)
  if @network
    unless parent_capabilities.supports?(:network, @network.mode)
      raise CapabilityError, "sandbox backend cannot enforce scoped network mode :#{@network.mode}"
    end
    @capabilities = @capabilities.intersect(Capabilities.new(
      features: @capabilities.features,
      network_modes: [@network.mode],
      isolation: @capabilities.isolation
    ))
  end
  @filesystem = Filesystem.new(
    mounts: (@mounts + @tool_deny_mounts).uniq,
    relative_root: sandbox.workspace.root,
    max_read_bytes: sandbox.limits.read_bytes,
    max_write_bytes: sandbox.limits.write_bytes,
    max_list_entries: sandbox.limits.list_entries
  )
  @mount_identities = mount_identities
end

Instance Attribute Details

#capabilitiesObject (readonly)

Operations exposed through this scope.



52
53
54
# File 'lib/little_ghost/sandbox/scope.rb', line 52

def capabilities
  @capabilities
end

#networkObject (readonly)

Outbound connectivity available to processes launched through this scope.



54
55
56
# File 'lib/little_ghost/sandbox/scope.rb', line 54

def network
  @network
end

#sandboxObject (readonly)

Sandbox that enforces process execution.



50
51
52
# File 'lib/little_ghost/sandbox/scope.rb', line 50

def sandbox
  @sandbox
end

Instance Method Details

#allows?(operation, path = nil) ⇒ Boolean

Indicates whether operation is available at optional virtual path.

Returns:

  • (Boolean)


71
72
73
74
75
76
77
78
# File 'lib/little_ghost/sandbox/scope.rb', line 71

def allows?(operation, path = nil)
  operation = Capabilities.normalize(operation)
  return supports?(operation) unless path

  @filesystem.allows?(operation, absolute_path(path))
rescue PolicyError, ToolError
  false
end

#closeObject

Scopes own no resources, so closing has no effect.



121
# File 'lib/little_ghost/sandbox/scope.rb', line 121

def close = nil

#execute(command, **options) ⇒ Object

Executes a shell command through the parent sandbox and this scope.



105
106
107
108
# File 'lib/little_ghost/sandbox/scope.rb', line 105

def execute(command, **options)
  require_capability!(:process_execute)
  sandbox.execute(command, scope: self, **options)
end

#execute_program(command, **options) ⇒ Object

Executes an argument vector through the parent sandbox and this scope.



111
112
113
114
# File 'lib/little_ghost/sandbox/scope.rb', line 111

def execute_program(command, **options)
  require_capability!(:process_execute)
  sandbox.execute_program(command, scope: self, **options)
end

#list(path = ".", context: nil) ⇒ Object

Lists one directory through the scoped filesystem.



87
88
89
90
# File 'lib/little_ghost/sandbox/scope.rb', line 87

def list(path = ".", context: nil)
  require_capability!(:filesystem_list)
  @filesystem.list(logical_path(path), context:)
end

#open(run: nil) ⇒ Object

Scopes own no resources; opening returns the same object.



119
120
# File 'lib/little_ghost/sandbox/scope.rb', line 119

def open(run: nil) = self
# Scopes own no resources, so closing has no effect.

#policyObject Also known as: effective_policy

Effective policy enforced by the parent sandbox.



59
60
# File 'lib/little_ghost/sandbox/scope.rb', line 59

def policy = sandbox.effective_policy
# Effective policy enforced by the parent sandbox.

#process_grantsObject

:nodoc:



68
# File 'lib/little_ghost/sandbox/scope.rb', line 68

def process_grants = @mounts # :nodoc:

#read(path, context: nil) ⇒ Object

Reads bounded UTF-8 text through the scoped filesystem.



81
82
83
84
# File 'lib/little_ghost/sandbox/scope.rb', line 81

def read(path, context: nil)
  require_capability!(:filesystem_read)
  @filesystem.read(logical_path(path), context:)
end

#replace(path, old_text, new_text, context: nil) ⇒ Object

Replaces one unique text occurrence through a writable scoped mount.



99
100
101
102
# File 'lib/little_ghost/sandbox/scope.rb', line 99

def replace(path, old_text, new_text, context: nil)
  require_capability!(:filesystem_replace)
  @filesystem.replace(logical_path(path), old_text, new_text, context:)
end

#scope(**options) ⇒ Object

Produces another view that can only narrow this scope.



117
118
# File 'lib/little_ghost/sandbox/scope.rb', line 117

def scope(**options) = self.class.new(sandbox:, parent_scope: self, **options)
# Scopes own no resources; opening returns the same object.

#supports?(feature, value = nil) ⇒ Boolean

Indicates whether this scope exposes feature.

Returns:

  • (Boolean)


64
65
# File 'lib/little_ghost/sandbox/scope.rb', line 64

def supports?(feature, value = nil) = capabilities.supports?(feature, value)
# Indicates whether any visible mount accepts writes.

#validate!Object

Fails closed if a selected host mount was replaced after scope creation.



124
125
126
127
128
129
130
131
# File 'lib/little_ghost/sandbox/scope.rb', line 124

def validate!
  current = mount_identities
  unless current == @mount_identities
    raise CapabilityError, "sandbox scope mount source changed after initialization"
  end

  self
end

#workspaceObject

Workspace owned by the parent sandbox.



57
58
# File 'lib/little_ghost/sandbox/scope.rb', line 57

def workspace = sandbox.workspace
# Effective policy enforced by the parent sandbox.

#writable?Boolean

Indicates whether any visible mount accepts writes.

Returns:

  • (Boolean)


66
# File 'lib/little_ghost/sandbox/scope.rb', line 66

def writable? = supports?(:filesystem_write) && @mounts.any? { |mount| mount.tool_visible? && mount.writable? }

#write(path, content, context: nil) ⇒ Object

Writes bounded content through a writable scoped mount.



93
94
95
96
# File 'lib/little_ghost/sandbox/scope.rb', line 93

def write(path, content, context: nil)
  require_capability!(:filesystem_write)
  @filesystem.write(logical_path(path), content, context:)
end