Class: Rubino::Files::Workspace

Inherits:
Object
  • Object
show all
Defined in:
lib/rubino/files/workspace.rb

Overview

Sandboxed filesystem access for the agent. Every path coming in from the HTTP API (read, upload, etc.) must be resolved through #resolve so the result is guaranteed to live under @root.

Root defaults to config.paths_home (the agent home); uploads are written under ‘<root>/uploads/`. The root is overridable in tests.

Path-traversal defense:

  • Pathname#+ does not normalize: ‘Pathname.new(“/a”) + “/b”` returns `/b`, so an attacker-supplied absolute path would silently escape.

  • We therefore call #expand_path on the joined path and then verify it begins with ‘@root + File::SEPARATOR` (or equals @root). If not, we raise Workspace::PathTraversal (a ValidationError subclass).

Defined Under Namespace

Classes: PathTraversal

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(root: nil) ⇒ Workspace

Returns a new instance of Workspace.



29
30
31
32
33
34
35
36
37
38
39
40
# File 'lib/rubino/files/workspace.rb', line 29

def initialize(root: nil)
  path = root || ::Rubino.configuration.paths_home
  expanded = File.expand_path(path)
  FileUtils.mkdir_p(expanded)
  # Resolve symlinks (macOS' /tmp → /private/tmp is the usual offender)
  # so #resolve compares apples to apples. Tools and AttachFileTool
  # both call File.expand_path on their inputs, which follows OS
  # symlinks; storing the raw configured root here would then make
  # every absolute path under /tmp look like an escape, even though
  # it really points inside the sandbox.
  @root = Pathname.new(File.realpath(expanded))
end

Instance Attribute Details

#rootObject (readonly)

Returns the value of attribute root.



42
43
44
# File 'lib/rubino/files/workspace.rb', line 42

def root
  @root
end

Instance Method Details

#read(relative_path) ⇒ String

Reads a file from the sandbox.

Parameters:

  • relative_path (String)

    path relative to the workspace root

Returns:

  • (String)

    binary contents of the file

Raises:



68
69
70
71
72
73
# File 'lib/rubino/files/workspace.rb', line 68

def read(relative_path)
  path = resolve(relative_path)
  raise ::Rubino::NotFoundError.new("file", relative_path) unless path.file?

  path.binread
end

#resolve(relative_path) ⇒ Object

Resolves a relative path against the workspace root. Raises PathTraversal if the resolved path escapes the root.



46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
# File 'lib/rubino/files/workspace.rb', line 46

def resolve(relative_path)
  candidate = (@root + relative_path).expand_path
  # If the candidate exists on disk, run it through realpath too so
  # symlink components in the leading path don't make us reject a
  # path that physically lives under @root. For paths that don't
  # exist yet (the upload-create case) we keep the expand_path form
  # — File.realpath would raise on a missing file.
  candidate = Pathname.new(File.realpath(candidate.to_s)) if candidate.exist?

  unless candidate.to_s.start_with?(@root.to_s + File::SEPARATOR) || candidate == @root
    raise PathTraversal, relative_path
  end

  candidate
end

#upload(filename:, io:) ⇒ Hash

Stores an uploaded file under ‘uploads/<uuid>-<basename>`. The original filename is reduced to its basename before joining, so callers cannot influence the destination directory.

Parameters:

  • filename (String)

    client-supplied name (basename only is kept)

  • io (IO)

    readable stream containing the upload body

Returns:

  • (Hash)

    descriptor with keys :id, :filename, :size, :path



82
83
84
85
86
87
88
89
90
# File 'lib/rubino/files/workspace.rb', line 82

def upload(filename:, io:)
  uploads_dir = @root + "uploads"
  FileUtils.mkdir_p(uploads_dir)
  safe_name = File.basename(filename.to_s)
  id = SecureRandom.uuid
  target = uploads_dir + "#{id}-#{safe_name}"
  size = IO.copy_stream(io, target.to_s)
  { id: id, filename: safe_name, size: size, path: target.to_s }
end