Class: LittleGhost::Sandbox::Filesystem

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

Overview

Performs bounded host filesystem operations through a set of virtual mounts. This broker is intended for trusted tool dispatch outside an OS sandbox; path traversal is anchored to mount descriptors and rejects symbolic links.

Constant Summary collapse

PLATFORM_OPEN_FLAGS =

:nodoc:

case RUBY_PLATFORM
when /darwin/
  {close_on_exec: 0x0100_0000}
when /linux/
  {close_on_exec: 0x0008_0000}
end&.freeze
OPENAT =
if PLATFORM_OPEN_FLAGS
  Fiddle::Function.new(
    Fiddle::Handle::DEFAULT["openat"],
    [Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT, Fiddle::TYPE_VARIADIC],
    Fiddle::TYPE_INT
  )
end

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(mounts:, relative_root:, max_read_bytes: 1_000_000, max_write_bytes: 1_000_000, max_list_entries: 10_000) ⇒ Filesystem

Returns a new instance of Filesystem.



26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
# File 'lib/little_ghost/sandbox/filesystem.rb', line 26

def initialize(
  mounts:,
  relative_root:,
  max_read_bytes: 1_000_000,
  max_write_bytes: 1_000_000,
  max_list_entries: 10_000
)
  @mounts = Array(mounts).sort_by { |mount| -mount.target.length }.freeze
  @relative_root = Mount.send(:normalize_virtual_path, relative_root)
  @max_read_bytes = positive_integer(max_read_bytes, "read limit")
  @max_write_bytes = positive_integer(max_write_bytes, "write limit")
  @max_list_entries = positive_integer(max_list_entries, "listing limit")
  @mount_identities = @mounts.to_h do |mount|
    root = File.realpath(mount.source)
    stat = File.stat(root)
    [mount, [root.freeze, stat.dev, stat.ino].freeze]
  rescue Errno::ENOENT
    raise ToolError, "Sandbox mount source does not exist"
  end.freeze
end

Instance Attribute Details

#mountsObject (readonly)

Returns the value of attribute mounts.



47
48
49
# File 'lib/little_ghost/sandbox/filesystem.rb', line 47

def mounts
  @mounts
end

#relative_rootObject (readonly)

Returns the value of attribute relative_root.



47
48
49
# File 'lib/little_ghost/sandbox/filesystem.rb', line 47

def relative_root
  @relative_root
end

Instance Method Details

#allows?(operation, path) ⇒ Boolean

Returns:

  • (Boolean)


49
50
51
52
53
54
55
56
57
# File 'lib/little_ghost/sandbox/filesystem.rb', line 49

def allows?(operation, path)
  writable = %i[filesystem_write filesystem_replace].include?(operation.to_sym)
  mount, relative = resolve(path, allow_root: true)
  return false if writable && !mount.writable?

  path_available?(mount, relative, allow_missing: writable)
rescue SystemCallError, ToolError
  false
end

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



80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
# File 'lib/little_ghost/sandbox/filesystem.rb', line 80

def list(path = ".", context: nil)
  context&.check!
  normalized_path = virtual_path(path)
  children = virtual_children(normalized_path)
  unless mounted_path?(normalized_path)
    return children.join("\n") unless children.empty?
  end

  mount, relative = resolve(normalized_path, allow_root: true)
  listing = with_directory(mount, relative) do |directory|
    entries = directory_children(directory)
    raise ToolError, "Directory exceeds the listing limit" if entries.length > @max_list_entries

    entries.sort.map do |entry|
      directory_entry?(directory, entry) ? "#{entry}/" : entry
    end
  end
  (listing + children).uniq.sort.join("\n")
rescue Errno::ELOOP
  raise ToolError, "Path cannot traverse a symbolic link"
rescue Errno::ENOENT
  raise ToolError, "Path does not exist"
rescue Errno::ENOTDIR
  raise ToolError, "Path is not a directory"
end

#read(path, context: nil) ⇒ Object



59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
# File 'lib/little_ghost/sandbox/filesystem.rb', line 59

def read(path, context: nil)
  context&.check!
  mount, relative = resolve(path)
  with_file(mount, relative, flags: read_flags, mode: "r") do |file|
    validate_regular_file!(file)

    content = file.read(@max_read_bytes + 1)
    raise ToolError, "File exceeds the read limit" if content.bytesize > @max_read_bytes

    content.force_encoding(Encoding::UTF_8)
    raise ToolError, "File is not valid UTF-8 text" unless content.valid_encoding?
    content
  end
rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError
  raise ToolError, "File is not valid UTF-8 text"
rescue Errno::ELOOP
  raise ToolError, "Path cannot be a symbolic link"
rescue Errno::ENOENT, Errno::ENOTDIR
  raise ToolError, "Path does not exist"
end

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

Raises:



125
126
127
128
129
130
131
132
133
134
135
# File 'lib/little_ghost/sandbox/filesystem.rb', line 125

def replace(path, old_text, new_text, context: nil)
  context&.check!
  raise ToolError, "Text to replace cannot be empty" if old_text.empty?

  content = read(path, context:)
  occurrences = content.scan(old_text).length
  raise ToolError, "Text was not found in #{display_path(path)}" if occurrences.zero?
  raise ToolError, "Text occurs more than once in #{display_path(path)}" if occurrences > 1

  write(path, content.sub(old_text, new_text), context:)
end

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



106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
# File 'lib/little_ghost/sandbox/filesystem.rb', line 106

def write(path, content, context: nil)
  context&.check!
  mount, relative = resolve(path)
  raise ToolError, "Sandbox scope is read-only" unless mount.writable?
  raise ToolError, "Content exceeds the write limit" if content.bytesize > @max_write_bytes

  with_file(mount, relative, flags: write_flags, mode: "w", permissions: 0o644) do |file|
    validate_regular_file!(file)

    file.truncate(0)
    file.write(content)
  end
  "Wrote #{content.bytesize} bytes to #{display_path(path)}"
rescue Errno::ELOOP
  raise ToolError, "Write target cannot be a symbolic link"
rescue Errno::ENOENT, Errno::ENOTDIR
  raise ToolError, "Write target parent does not exist"
end