Class: LittleGhost::UnrestrictedSandbox

Inherits:
Sandbox
  • Object
show all
Defined in:
lib/little_ghost/unrestricted_sandbox.rb

Overview

UnrestrictedSandbox is a convenient host-backed sandbox for trusted local work. It offers bounded text-file operations and command execution using only Ruby's standard library.

workspace = LittleGhost::Workspace.new(root: Dir.pwd)
sandbox = LittleGhost::UnrestrictedSandbox.new(workspace:)
sandbox.read("README.md").lines.first # => "# LittleGhost\n"

Reads return valid UTF-8 text. Writes preserve the supplied String bytes. Paths must be relative, may not contain .., and are checked against the configured workspace root.

Security and trust

This sandbox is not a security boundary. Commands run directly on the host with the Ruby process's permissions, and filesystem containment cannot defend against concurrent adversarial mutation. Use an isolated Sandbox implementation for untrusted work.

Instance Attribute Summary

Attributes inherited from Sandbox

#workspace

Instance Method Summary collapse

Methods inherited from Sandbox

#close, #execute

Constructor Details

#initialize(workspace:, writable: false, max_read_bytes: 1_000_000, max_write_bytes: 1_000_000, max_list_entries: 10_000) ⇒ UnrestrictedSandbox

Configures a host sandbox with explicit read, write, and listing limits. Filesystem writes remain disabled unless writable is true.



27
28
29
30
31
32
33
34
35
36
37
38
39
# File 'lib/little_ghost/unrestricted_sandbox.rb', line 27

def initialize(workspace:, writable: false, max_read_bytes: 1_000_000, max_write_bytes: 1_000_000, max_list_entries: 10_000)
  super(workspace:)
  @writable = writable
  @max_read_bytes = Integer(max_read_bytes)
  @max_write_bytes = Integer(max_write_bytes)
  @max_list_entries = Integer(max_list_entries)
  unless [@max_read_bytes, @max_write_bytes, @max_list_entries].all?(&:positive?)
    raise ArgumentError, "sandbox limits must be positive"
  end

  @root = File.expand_path(workspace.root)
  capture_root_identity if File.exist?(@root)
end

Instance Method Details

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

Executes an argument vector on the host from the workspace root.

Shell syntax is not interpreted. The child starts with an empty environment unless inherit_environment is true, is terminated when the context is cancelled or the timeout expires, and has each output stream truncated to max_output_bytes.

Raises:



125
126
127
128
129
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/unrestricted_sandbox.rb', line 125

def execute_program(
  command,
  timeout:,
  context: nil,
  max_output_bytes: 1_000_000,
  environment: {},
  inherit_environment: false
)
  argv = Array(command).map(&:to_s)
  raise ToolError, "Command must contain an executable" if argv.empty? || argv.first.empty?

  timeout = Float(timeout)
  max_output_bytes = Integer(max_output_bytes)
  raise ArgumentError, "timeout must be positive" unless timeout.positive?
  raise ArgumentError, "max_output_bytes must be positive" unless max_output_bytes.positive?

  stdout, stderr, status = capture(
    argv,
    timeout:,
    context:,
    max_output_bytes:,
    environment:,
    inherit_environment:
  )
  Execution.new(stdout:, stderr:, exit_code: status.exitstatus)
end

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

Produces a newline-delimited, sorted directory listing. Directories end in /.

Raises:



74
75
76
77
78
79
80
81
82
83
84
85
# File 'lib/little_ghost/unrestricted_sandbox.rb', line 74

def list(path = ".", context: nil)
  context&.check!
  directory = existing_path(path, allow_root: true)
  raise ToolError, "Path is not a directory" unless File.directory?(directory)

  entries = Dir.children(directory)
  raise ToolError, "Directory exceeds the listing limit" if entries.length > @max_list_entries

  entries.sort.map do |entry|
    File.lstat(File.join(directory, entry)).directory? ? "#{entry}/" : entry
  end.join("\n")
end

#open(run: nil) ⇒ Object

Opens the sandbox and verifies that the workspace root has not changed.



42
43
44
45
46
47
48
49
50
# File 'lib/little_ghost/unrestricted_sandbox.rb', line 42

def open(run: nil)
  if @root_identity
    validate_root!
  else
    @root = File.realpath(workspace.root)
    capture_root_identity
  end
  self
end

#read(path, context: nil) ⇒ Object

Reads a bounded UTF-8 file within the workspace.



56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
# File 'lib/little_ghost/unrestricted_sandbox.rb', line 56

def read(path, context: nil)
  context&.check!
  File.open(existing_path(path), read_flags) do |file|
    raise ToolError, "Path is not a file" unless file.stat.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"
end

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

Replaces exactly one occurrence of old_text in a writable file.

Raises:



107
108
109
110
111
112
113
114
115
116
117
# File 'lib/little_ghost/unrestricted_sandbox.rb', line 107

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

#writable?Boolean

Indicates whether this sandbox accepts filesystem mutations.

Returns:

  • (Boolean)


53
# File 'lib/little_ghost/unrestricted_sandbox.rb', line 53

def writable? = @writable

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

Writes a bounded String without following a symbolic-link target.



88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
# File 'lib/little_ghost/unrestricted_sandbox.rb', line 88

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

  flags = File::WRONLY | File::CREAT | File::TRUNC
  flags |= File::NOFOLLOW if defined?(File::NOFOLLOW)
  flags |= File::NONBLOCK if defined?(File::NONBLOCK)
  File.open(writable_path(path), flags, 0o644) do |file|
    raise ToolError, "Path is not a file" unless file.stat.file?

    file.write(content)
  end
  "Wrote #{content.bytesize} bytes to #{display_path(path)}"
rescue Errno::ELOOP
  raise ToolError, "Write target cannot be a symbolic link"
end