Class: LittleGhost::Sandbox::Filesystem
- Inherits:
-
Object
- Object
- LittleGhost::Sandbox::Filesystem
- 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
-
#mounts ⇒ Object
readonly
Returns the value of attribute mounts.
-
#relative_root ⇒ Object
readonly
Returns the value of attribute relative_root.
Instance Method Summary collapse
- #allows?(operation, path) ⇒ Boolean
-
#initialize(mounts:, relative_root:, max_read_bytes: 1_000_000, max_write_bytes: 1_000_000, max_list_entries: 10_000) ⇒ Filesystem
constructor
A new instance of Filesystem.
- #list(path = ".", context: nil) ⇒ Object
- #read(path, context: nil) ⇒ Object
- #replace(path, old_text, new_text, context: nil) ⇒ Object
- #write(path, content, context: nil) ⇒ Object
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
#mounts ⇒ Object (readonly)
Returns the value of attribute mounts.
47 48 49 |
# File 'lib/little_ghost/sandbox/filesystem.rb', line 47 def mounts @mounts end |
#relative_root ⇒ Object (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
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
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 |