Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions lib/ruby_llm/code/workspace.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,13 @@ def initialize(root = Dir.pwd)
end

# Turns a model-supplied path into an absolute Pathname, refusing
# anything that would climb out of the workspace.
# anything that would climb out of the workspace. A path is judged by
# where it really leads, so a symlink inside cannot reach outside.
def resolve(path)
raise ToolError, 'path is required' if path.nil? || path.to_s.strip.empty?

candidate = Pathname.new(File.expand_path(path.to_s, root.to_s))
return candidate if candidate == root || candidate.to_s.start_with?("#{root}#{File::SEPARATOR}")
return candidate if within?(candidate) && within?(real(candidate, path), real_root)

raise ToolError, "#{path} is outside the workspace (#{root})"
end
Expand Down Expand Up @@ -100,6 +101,27 @@ def overview(limit: 40)

private

def within?(path, base = root) = path == base || path.to_s.start_with?("#{base}#{File::SEPARATOR}")

# The root as the file system knows it, since temporary folders and
# home directories are often reached through a link.
def real_root = @real_root ||= root.realpath

# Where +candidate+ really leads: the part that exists with its links
# resolved, then the rest, which a write would create. A broken link
# on the way would let a write follow it anywhere, so it is refused.
def real(candidate, path)
existing = candidate
rest = []
until existing.exist?
raise ToolError, "#{path} goes through a broken symlink" if existing.symlink?

rest.unshift(existing.basename)
existing = existing.dirname
end
existing.realpath.join(*rest)
end

def capture(*command)
stdout, = Open3.capture2e(*command, chdir: root.to_s)
stdout
Expand Down
58 changes: 58 additions & 0 deletions spec/ruby_llm/code/workspace_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,64 @@
expect { sandbox.resolve(' ') }.to raise_error(RubyLLM::Code::ToolError, /required/)
end

context 'with symlinks' do
# A folder outside the workspace, and a link to it from inside.
let(:outside) { Pathname.new(Dir.mktmpdir('outside')) }

before do
outside.join('secret.txt').write("outside\n")
sandbox.root.join('link').make_symlink(outside)
end

after { FileUtils.remove_entry(outside) }

it 'refuses a file that a link inside leads to outside' do
expect { sandbox.resolve('link/secret.txt') }.to raise_error(RubyLLM::Code::ToolError, /outside the workspace/)
end

it 'refuses a new file in a folder that a link leads to outside' do
expect { sandbox.resolve('link/planted.txt') }.to raise_error(RubyLLM::Code::ToolError, /outside the workspace/)
end

it 'refuses the link itself' do
expect { sandbox.resolve('link') }.to raise_error(RubyLLM::Code::ToolError, /outside the workspace/)
end

it 'refuses a broken link, which writing would follow wherever it points' do
sandbox.root.join('dangling').make_symlink(outside.join('not-yet.txt'))

expect { sandbox.resolve('dangling') }.to raise_error(RubyLLM::Code::ToolError, /broken symlink/)
expect { sandbox.resolve('dangling/inside.txt') }.to raise_error(RubyLLM::Code::ToolError, /broken symlink/)
end

it 'follows links that stay inside' do
file('lib/thing.rb')
sandbox.root.join('code').make_symlink(sandbox.root.join('lib'))

expect(sandbox.resolve('code/thing.rb').to_s).to eq(sandbox.root.join('code/thing.rb').to_s)
end

it 'leaves links that lead outside out of globs' do
file('lib/thing.rb')

expect(sandbox.files('**/*').map { |path| sandbox.relative(path) }).to eq(['lib/thing.rb'])
end

it 'still resolves new files in folders that do not exist yet' do
expect(sandbox.resolve('new/deep/file.rb').to_s).to eq(sandbox.root.join('new/deep/file.rb').to_s)
end

it 'works from a root reached through a link, as temporary folders are on macOS' do
sandbox.root.join('real').mkpath
sandbox.root.join('real/inner.txt').write("x\n")
sandbox.root.join('via').make_symlink(sandbox.root.join('real'))
linked = described_class.new(sandbox.root.join('via').to_s)

expect(linked.resolve('inner.txt').to_s).to eq(sandbox.root.join('via/inner.txt').to_s)
expect { linked.resolve('../link/secret.txt') }.to raise_error(RubyLLM::Code::ToolError)
end
end

it 'knows what is noise' do
expect(sandbox.noise?(sandbox.root.join('node_modules/left-pad/index.js'))).to be(true)
expect(sandbox.noise?(sandbox.root.join('lib/left_pad.rb'))).to be(false)
Expand Down
Loading