Class: Rubino::Files::Workspace
- Inherits:
-
Object
- Object
- Rubino::Files::Workspace
- 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
-
#root ⇒ Object
readonly
Returns the value of attribute root.
Instance Method Summary collapse
-
#initialize(root: nil) ⇒ Workspace
constructor
A new instance of Workspace.
-
#read(relative_path) ⇒ String
Reads a file from the sandbox.
-
#resolve(relative_path) ⇒ Object
Resolves a relative path against the workspace root.
-
#upload(filename:, io:) ⇒ Hash
Stores an uploaded file under ‘uploads/<uuid>-<basename>`.
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 = File.(path) FileUtils.mkdir_p() # 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()) end |
Instance Attribute Details
#root ⇒ Object (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.
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). # 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.
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 |