diff options
Diffstat (limited to 'lib/dispatch')
| -rw-r--r-- | lib/dispatch/tool/files.rb | 15 | ||||
| -rw-r--r-- | lib/dispatch/tool/files/create_file.rb | 46 | ||||
| -rw-r--r-- | lib/dispatch/tool/files/edit_file.rb | 91 | ||||
| -rw-r--r-- | lib/dispatch/tool/files/list_files.rb | 60 | ||||
| -rw-r--r-- | lib/dispatch/tool/files/read_file.rb | 68 | ||||
| -rw-r--r-- | lib/dispatch/tool/files/register.rb | 21 | ||||
| -rw-r--r-- | lib/dispatch/tool/files/sandbox.rb | 69 | ||||
| -rw-r--r-- | lib/dispatch/tool/files/search_files.rb | 89 | ||||
| -rw-r--r-- | lib/dispatch/tool/files/write_file.rb | 43 |
9 files changed, 501 insertions, 1 deletions
diff --git a/lib/dispatch/tool/files.rb b/lib/dispatch/tool/files.rb index b7418c3..50e1a58 100644 --- a/lib/dispatch/tool/files.rb +++ b/lib/dispatch/tool/files.rb @@ -1,12 +1,25 @@ # frozen_string_literal: true +require "dispatch/tools/interface" +require "fileutils" require_relative "files/version" module Dispatch module Tool module Files class Error < StandardError; end - # Your code goes here... + class SandboxError < Error; end + class FileNotFoundError < Error; end + class FileExistsError < Error; end end end end + +require_relative "files/sandbox" +require_relative "files/read_file" +require_relative "files/write_file" +require_relative "files/edit_file" +require_relative "files/create_file" +require_relative "files/list_files" +require_relative "files/search_files" +require_relative "files/register" diff --git a/lib/dispatch/tool/files/create_file.rb b/lib/dispatch/tool/files/create_file.rb new file mode 100644 index 0000000..5cc13e4 --- /dev/null +++ b/lib/dispatch/tool/files/create_file.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Dispatch + module Tool + module Files + CREATE_FILE = Dispatch::Tools::Definition.new( + name: "create_file", + description: "Create a new file with the given content. Fails if the file already exists.", + parameters: { + type: "object", + properties: { + path: { + type: "string", + description: "Path to the file to create, relative to the worktree root." + }, + content: { + type: "string", + description: "The content to write to the new file." + } + }, + required: %w[path content], + additionalProperties: false + } + ) do |params, context| + worktree_path = context[:worktree_path] + path = params[:path] + content = params[:content] + + resolved = begin + Sandbox.resolve_path(path, worktree_path:) + rescue SandboxError => e + next Dispatch::Tools::Result.failure(error: e.message) + end + + if File.exist?(resolved) + next Dispatch::Tools::Result.failure(error: "File already exists: #{path}. Use write_file to overwrite.") + end + + FileUtils.mkdir_p(File.dirname(resolved)) + File.write(resolved, content) + + Dispatch::Tools::Result.success(output: "Created #{path}") + end + end + end +end diff --git a/lib/dispatch/tool/files/edit_file.rb b/lib/dispatch/tool/files/edit_file.rb new file mode 100644 index 0000000..b1bd159 --- /dev/null +++ b/lib/dispatch/tool/files/edit_file.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +module Dispatch + module Tool + module Files + EDIT_FILE = Dispatch::Tools::Definition.new( + name: "edit_file", + description: "Apply text edits to an existing file. Each edit replaces old_text with new_text sequentially.", + parameters: { + type: "object", + properties: { + path: { + type: "string", + description: "Path to the file to edit, relative to the worktree root." + }, + edits: { + type: "array", + description: "Array of edit operations to apply sequentially.", + items: { + type: "object", + properties: { + old_text: { + type: "string", + description: "The exact text to find in the file." + }, + new_text: { + type: "string", + description: "The text to replace old_text with." + } + }, + required: %w[old_text new_text], + additionalProperties: false + } + } + }, + required: %w[path edits], + additionalProperties: false + } + ) do |params, context| + worktree_path = context[:worktree_path] + path = params[:path] + edits = params[:edits] + + resolved = begin + Sandbox.resolve_path(path, worktree_path:) + rescue SandboxError => e + next Dispatch::Tools::Result.failure(error: e.message) + end + + unless File.exist?(resolved) + next Dispatch::Tools::Result.failure(error: "File not found: #{path}") + end + + content = File.read(resolved) + edits_applied = 0 + error_result = nil + + edits.each do |edit| + old_text = edit[:old_text] || edit["old_text"] + new_text = edit[:new_text] || edit["new_text"] + + escaped = Regexp.new(Regexp.escape(old_text)) + occurrences = content.scan(escaped).length + + if occurrences.zero? + error_result = Dispatch::Tools::Result.failure( + error: "Edit #{edits_applied + 1}: old_text not found in #{path}" + ) + break + end + + if occurrences > 1 + error_result = Dispatch::Tools::Result.failure( + error: "Edit #{edits_applied + 1}: ambiguous match — old_text found #{occurrences} times in #{path}. " \ + "Provide more context to make the match unique." + ) + break + end + + content = content.sub(old_text, new_text) + edits_applied += 1 + end + + next error_result if error_result + + File.write(resolved, content) + Dispatch::Tools::Result.success(output: "Applied #{edits_applied} edit(s) to #{path}") + end + end + end +end diff --git a/lib/dispatch/tool/files/list_files.rb b/lib/dispatch/tool/files/list_files.rb new file mode 100644 index 0000000..7e7510d --- /dev/null +++ b/lib/dispatch/tool/files/list_files.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module Dispatch + module Tool + module Files + LIST_FILES = Dispatch::Tools::Definition.new( + name: "list_files", + description: "List files in a directory. Returns paths relative to the worktree root.", + parameters: { + type: "object", + properties: { + path: { + type: "string", + description: "Directory path relative to the worktree root. Defaults to the root." + }, + pattern: { + type: "string", + description: "Glob pattern to filter files (e.g. '**/*.rb')." + }, + recursive: { + type: "boolean", + description: "Whether to list files recursively. Defaults to true." + } + }, + additionalProperties: false + } + ) do |params, context| + worktree_path = context[:worktree_path] + path = params.fetch(:path, ".") + pattern = params[:pattern] + recursive = params.fetch(:recursive, true) + + resolved = begin + Sandbox.resolve_path(path, worktree_path:) + rescue SandboxError => e + next Dispatch::Tools::Result.failure(error: e.message) + end + + unless File.directory?(resolved) + next Dispatch::Tools::Result.failure(error: "Directory not found: #{path}") + end + + glob = if pattern + File.join(resolved, pattern) + elsif recursive + File.join(resolved, "**", "*") + else + File.join(resolved, "*") + end + + entries = Dir.glob(glob).select { |f| File.file?(f) } + + worktree_real = File.realpath(worktree_path) + relative_paths = entries.map { |f| f.delete_prefix("#{worktree_real}/") }.sort + + Dispatch::Tools::Result.success(output: relative_paths.join("\n")) + end + end + end +end diff --git a/lib/dispatch/tool/files/read_file.rb b/lib/dispatch/tool/files/read_file.rb new file mode 100644 index 0000000..c0c5edc --- /dev/null +++ b/lib/dispatch/tool/files/read_file.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +module Dispatch + module Tool + module Files + READ_FILE = Dispatch::Tools::Definition.new( + name: "read_file", + description: "Read the contents of a file. Returns file contents with line numbers.", + parameters: { + type: "object", + properties: { + path: { + type: "string", + description: "Path to the file to read, relative to the worktree root." + }, + start_line: { + type: "integer", + description: "The line number to start reading from (0-based). Defaults to 0." + }, + end_line: { + type: "integer", + description: "The inclusive line number to stop reading at (0-based). Use -1 for end of file." + } + }, + required: %w[path], + additionalProperties: false + } + ) do |params, context| + worktree_path = context[:worktree_path] + path = params[:path] + + resolved = begin + Sandbox.resolve_path(path, worktree_path:) + rescue SandboxError => e + next Dispatch::Tools::Result.failure(error: e.message) + end + + unless File.exist?(resolved) + next Dispatch::Tools::Result.failure(error: "File not found: #{path}") + end + + # Binary file detection: check first 8192 bytes for null bytes + sample = File.binread(resolved, 8192) + if sample.include?("\x00") + next Dispatch::Tools::Result.failure(error: "Cannot read binary file: #{path}") + end + + lines = File.readlines(resolved) + + start_line = params.fetch(:start_line, 0) + end_line = params.fetch(:end_line, -1) + end_line = lines.length - 1 if end_line == -1 + + selected = lines[start_line..end_line] || [] + + # Calculate padding width based on the highest line number + width = end_line.to_s.length + + output = selected.each_with_index.map do |line, idx| + line_num = start_line + idx + "#{line_num.to_s.rjust(width)}: #{line}" + end.join + + Dispatch::Tools::Result.success(output:) + end + end + end +end diff --git a/lib/dispatch/tool/files/register.rb b/lib/dispatch/tool/files/register.rb new file mode 100644 index 0000000..6877c6c --- /dev/null +++ b/lib/dispatch/tool/files/register.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Dispatch + module Tool + module Files + TOOLS = [ + READ_FILE, + WRITE_FILE, + EDIT_FILE, + CREATE_FILE, + LIST_FILES, + SEARCH_FILES + ].freeze + + def self.register(registry) + TOOLS.each { |tool| registry.register(tool) } + registry + end + end + end +end diff --git a/lib/dispatch/tool/files/sandbox.rb b/lib/dispatch/tool/files/sandbox.rb new file mode 100644 index 0000000..6ceccb1 --- /dev/null +++ b/lib/dispatch/tool/files/sandbox.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module Dispatch + module Tool + module Files + module Sandbox + module_function + + def resolve_path(path, worktree_path:) + worktree_real = File.realpath(worktree_path) + + # Join the path against the worktree root + joined = File.expand_path(path, worktree_real) + + # Check the expanded (logical) path first — catches ../traversal and absolute paths + unless joined.start_with?("#{worktree_real}/") || joined == worktree_real + raise SandboxError, "Path '#{path}' resolves outside the worktree sandbox" + end + + # For existing paths, resolve symlinks and verify the real path + if File.exist?(joined) + real = File.realpath(joined) + + unless real.start_with?("#{worktree_real}/") || real == worktree_real + raise SandboxError, "Path '#{path}' resolves outside the worktree sandbox (symlink escape)" + end + + real + else + # For non-existent paths, resolve as much of the existing prefix as possible + # to catch symlinked directory components pointing outside the worktree + parts = joined.delete_prefix("#{worktree_real}/").split("/") + current = worktree_real + + parts.each_with_index do |part, i| + candidate = File.join(current, part) + + if File.symlink?(candidate) + real_target = File.realpath(candidate) + + unless real_target.start_with?("#{worktree_real}/") || real_target == worktree_real + raise SandboxError, "Path '#{path}' resolves outside the worktree sandbox (symlink escape)" + end + + current = real_target + elsif File.exist?(candidate) + current = File.realpath(candidate) + else + # Remainder of the path doesn't exist on disk — that's fine, return the logical path + remaining = parts[(i + 1)..] + + return remaining.empty? ? candidate : File.join(candidate, *remaining) + end + end + + current + end + end + + def within_worktree?(path, worktree_path:) + resolve_path(path, worktree_path:) + true + rescue SandboxError + false + end + end + end + end +end diff --git a/lib/dispatch/tool/files/search_files.rb b/lib/dispatch/tool/files/search_files.rb new file mode 100644 index 0000000..b0ccc9e --- /dev/null +++ b/lib/dispatch/tool/files/search_files.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +module Dispatch + module Tool + module Files + SEARCH_FILES = Dispatch::Tools::Definition.new( + name: "search_files", + description: "Search for text in files. Returns matching lines with file paths and line numbers.", + parameters: { + type: "object", + properties: { + query: { + type: "string", + description: "The text or regex pattern to search for." + }, + path: { + type: "string", + description: "Directory to search within, relative to the worktree root. Defaults to root." + }, + pattern: { + type: "string", + description: "Glob pattern to filter which files to search (e.g. '**/*.rb')." + }, + is_regex: { + type: "boolean", + description: "Whether the query is a regular expression. Defaults to false." + } + }, + required: %w[query], + additionalProperties: false + } + ) do |params, context| + worktree_path = context[:worktree_path] + query = params[:query] + path = params.fetch(:path, ".") + file_pattern = params[:pattern] + is_regex = params.fetch(:is_regex, false) + + resolved = begin + Sandbox.resolve_path(path, worktree_path:) + rescue SandboxError => e + next Dispatch::Tools::Result.failure(error: e.message) + end + + regex = if is_regex + begin + Regexp.new(query) + rescue RegexpError => e + next Dispatch::Tools::Result.failure(error: "Invalid regex: #{e.message}") + end + else + Regexp.new(Regexp.escape(query)) + end + + # Build file list + glob = if file_pattern + File.join(resolved, file_pattern) + else + File.join(resolved, "**", "*") + end + + files = Dir.glob(glob).select { |f| File.file?(f) }.sort + + worktree_real = File.realpath(worktree_path) + max_results = 100 + matches = [] + + files.each do |file_path| + # Skip binary files + sample = File.binread(file_path, 8192) + next if sample&.include?("\x00") + + relative = file_path.delete_prefix("#{worktree_real}/") + + File.readlines(file_path).each_with_index do |line, idx| + if regex.match?(line) + matches << "#{relative}:#{idx + 1}: #{line.chomp}" + break if matches.length >= max_results + end + end + + break if matches.length >= max_results + end + + Dispatch::Tools::Result.success(output: matches.join("\n")) + end + end + end +end diff --git a/lib/dispatch/tool/files/write_file.rb b/lib/dispatch/tool/files/write_file.rb new file mode 100644 index 0000000..218c310 --- /dev/null +++ b/lib/dispatch/tool/files/write_file.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Dispatch + module Tool + module Files + WRITE_FILE = Dispatch::Tools::Definition.new( + name: "write_file", + description: "Write or overwrite a file with the given content. Creates parent directories if needed.", + parameters: { + type: "object", + properties: { + path: { + type: "string", + description: "Path to the file to write, relative to the worktree root." + }, + content: { + type: "string", + description: "The content to write to the file." + } + }, + required: %w[path content], + additionalProperties: false + } + ) do |params, context| + worktree_path = context[:worktree_path] + path = params[:path] + content = params[:content] + + resolved = begin + Sandbox.resolve_path(path, worktree_path:) + rescue SandboxError => e + next Dispatch::Tools::Result.failure(error: e.message) + end + + FileUtils.mkdir_p(File.dirname(resolved)) + File.write(resolved, content) + + byte_count = content.bytesize + Dispatch::Tools::Result.success(output: "Wrote #{byte_count} bytes to #{path}") + end + end + end +end |
