From d22479723dbcfa91e2f8cdb1c8a849f07971bed3 Mon Sep 17 00:00:00 2001 From: Stephan Kaag Date: Fri, 23 Oct 2026 19:02:40 +0200 Subject: [PATCH] Keep tools inside the workspace through symlinks Workspace#resolve compared a path's spelling with the root, so a symlink inside the workspace that led outside let read and list reach whatever it pointed at, and write create files there. A cloned repository can bring such a link, and code run in the workspace can make one. A path is now judged by where it really leads: the part that exists with its links resolved, then the rest, which a write would create, against the root as the file system knows it. A broken link on the way is refused, since a write would follow it anywhere. Links that stay inside keep working. --- lib/ruby_llm/code/workspace.rb | 26 ++++++++++++- spec/ruby_llm/code/workspace_spec.rb | 58 ++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/lib/ruby_llm/code/workspace.rb b/lib/ruby_llm/code/workspace.rb index 982319f..694b0f0 100644 --- a/lib/ruby_llm/code/workspace.rb +++ b/lib/ruby_llm/code/workspace.rb @@ -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 @@ -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 diff --git a/spec/ruby_llm/code/workspace_spec.rb b/spec/ruby_llm/code/workspace_spec.rb index cf60f92..1b394a0 100644 --- a/spec/ruby_llm/code/workspace_spec.rb +++ b/spec/ruby_llm/code/workspace_spec.rb @@ -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)