From 57c56daf5906442dacc15951c9b3405f89309839 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Tue, 31 Mar 2026 23:10:45 +0900 Subject: imp --- .rules/changelog/2026-03/31/01.md | 21 ++++ .rules/plan/dispatch-tool-files-plan.md | 154 ++++++++++++++++++++++++ Gemfile | 2 + Gemfile.lock | 146 +++++++++++++++++++++++ dispatch-tool-files.gemspec | 12 +- lib/dispatch/tool/files.rb | 15 ++- lib/dispatch/tool/files/create_file.rb | 46 ++++++++ lib/dispatch/tool/files/edit_file.rb | 91 ++++++++++++++ lib/dispatch/tool/files/list_files.rb | 60 ++++++++++ lib/dispatch/tool/files/read_file.rb | 68 +++++++++++ lib/dispatch/tool/files/register.rb | 21 ++++ lib/dispatch/tool/files/sandbox.rb | 69 +++++++++++ lib/dispatch/tool/files/search_files.rb | 89 ++++++++++++++ lib/dispatch/tool/files/write_file.rb | 43 +++++++ spec/dispatch/tool/files/create_file_spec.rb | 70 +++++++++++ spec/dispatch/tool/files/edit_file_spec.rb | 142 ++++++++++++++++++++++ spec/dispatch/tool/files/list_files_spec.rb | 125 ++++++++++++++++++++ spec/dispatch/tool/files/read_file_spec.rb | 108 +++++++++++++++++ spec/dispatch/tool/files/sandbox_spec.rb | 163 ++++++++++++++++++++++++++ spec/dispatch/tool/files/search_files_spec.rb | 132 +++++++++++++++++++++ spec/dispatch/tool/files/write_file_spec.rb | 74 ++++++++++++ spec/dispatch/tool/files_spec.rb | 6 +- spec/spec_helper.rb | 3 + 23 files changed, 1648 insertions(+), 12 deletions(-) create mode 100644 .rules/changelog/2026-03/31/01.md create mode 100644 .rules/plan/dispatch-tool-files-plan.md create mode 100644 Gemfile.lock create mode 100644 lib/dispatch/tool/files/create_file.rb create mode 100644 lib/dispatch/tool/files/edit_file.rb create mode 100644 lib/dispatch/tool/files/list_files.rb create mode 100644 lib/dispatch/tool/files/read_file.rb create mode 100644 lib/dispatch/tool/files/register.rb create mode 100644 lib/dispatch/tool/files/sandbox.rb create mode 100644 lib/dispatch/tool/files/search_files.rb create mode 100644 lib/dispatch/tool/files/write_file.rb create mode 100644 spec/dispatch/tool/files/create_file_spec.rb create mode 100644 spec/dispatch/tool/files/edit_file_spec.rb create mode 100644 spec/dispatch/tool/files/list_files_spec.rb create mode 100644 spec/dispatch/tool/files/read_file_spec.rb create mode 100644 spec/dispatch/tool/files/sandbox_spec.rb create mode 100644 spec/dispatch/tool/files/search_files_spec.rb create mode 100644 spec/dispatch/tool/files/write_file_spec.rb diff --git a/.rules/changelog/2026-03/31/01.md b/.rules/changelog/2026-03/31/01.md new file mode 100644 index 0000000..ff5291f --- /dev/null +++ b/.rules/changelog/2026-03/31/01.md @@ -0,0 +1,21 @@ +# 2026-03-31 — Add interface dependency and full test suite + +## Changes + +### Dependency Setup +- Added `dispatch-tools-interface` as a runtime dependency in `gemspec` (`~> 0.1`) +- Added local path reference in `Gemfile` for development + +### Source Updates +- `lib/dispatch/tool/files.rb`: Added `require "dispatch/tools/interface"` and defined `SandboxError`, `FileNotFoundError`, `FileExistsError` error classes +- `spec/spec_helper.rb`: Added interface, tmpdir, and fileutils requires +- `spec/dispatch/tool/files_spec.rb`: Removed placeholder failing test + +### Test Suite (7 spec files) +- `sandbox_spec.rb` — Path resolution, traversal attacks, symlinks, absolute paths, `within_worktree?` +- `read_file_spec.rb` — Full read, line ranges, line numbers, not found, binary detection +- `write_file_spec.rb` — Write new, overwrite, parent dirs, byte count, sandbox +- `edit_file_spec.rb` — Single/multiple edits, sequential application, not found, ambiguous match +- `create_file_spec.rb` — Create new, fail on existing, parent dirs, sandbox +- `list_files_spec.rb` — Recursive/non-recursive, glob patterns, relative paths, directory errors +- `search_files_spec.rb` — Plain text, regex, invalid regex, path scoping, pattern filter, result limit diff --git a/.rules/plan/dispatch-tool-files-plan.md b/.rules/plan/dispatch-tool-files-plan.md new file mode 100644 index 0000000..353f31c --- /dev/null +++ b/.rules/plan/dispatch-tool-files-plan.md @@ -0,0 +1,154 @@ +# Dispatch Tool Files — Gem Implementation Plan + +This plan covers the full implementation of the `dispatch-tool-files` gem. + +--- + +## Overview + +This gem provides file operation tools for Subagents. All operations are sandboxed to the agent's worktree — no file access outside the worktree is permitted. + +**Dependency:** `dispatch-tools-interface` + +--- + +## Gem Structure + +``` +dispatch-tool-files/ +├── lib/ +│ └── dispatch/ +│ └── tool/ +│ └── files/ +│ ├── read_file.rb +│ ├── write_file.rb +│ ├── edit_file.rb +│ ├── create_file.rb +│ ├── list_files.rb +│ ├── search_files.rb +│ ├── sandbox.rb +│ └── register.rb +├── spec/ +│ └── dispatch/ +│ └── tool/ +│ └── files/ +│ ├── read_file_spec.rb +│ ├── write_file_spec.rb +│ ├── edit_file_spec.rb +│ ├── create_file_spec.rb +│ ├── list_files_spec.rb +│ ├── search_files_spec.rb +│ └── sandbox_spec.rb +├── dispatch-tool-files.gemspec +├── Gemfile +├── Rakefile +└── README.md +``` + +--- + +## 1. Sandbox Module (`Dispatch::Tool::Files::Sandbox`) + +All tools use this module to validate that file paths stay within the worktree. + +### Methods + +- `resolve_path(path, worktree_path:)` — Resolve a relative path against the worktree root. Expand symlinks, resolve `..`, and verify the resulting absolute path starts with `worktree_path`. Raises `Dispatch::Tool::Files::SandboxError` if the path escapes. +- `within_worktree?(path, worktree_path:)` — Boolean check. + +### Security Considerations + +- Must handle symlink attacks (symlink pointing outside worktree). +- Must handle `../` traversal. +- Must handle absolute paths (reject or re-root them). + +--- + +## 2. Tools + +Each tool is a `Dispatch::Tools::Definition` instance. All tools require `worktree_path` in the `context` hash. + +### `read_file` + +- **Parameters:** `path` (required, String), `start_line` (optional, Integer, 0-based), `end_line` (optional, Integer, 0-based, -1 for EOF). +- **Behavior:** Read file contents. If line range specified, return only those lines. Prefix each line with its line number. +- **Success output:** File contents as a string with line numbers. +- **Failure:** File not found, path outside sandbox, binary file detection. + +### `write_file` + +- **Parameters:** `path` (required, String), `content` (required, String). +- **Behavior:** Write/overwrite the entire file with the given content. Creates parent directories if needed. +- **Success output:** Confirmation message with path and byte count. +- **Failure:** Path outside sandbox, permission errors. + +### `edit_file` + +- **Parameters:** `path` (required, String), `edits` (required, Array of `{ old_text: String, new_text: String }`). +- **Behavior:** For each edit, find `old_text` in the file and replace with `new_text`. Edits are applied sequentially. If `old_text` is not found, the edit fails. +- **Success output:** Confirmation with number of edits applied. +- **Failure:** File not found, `old_text` not found, ambiguous match (multiple occurrences — require more context). + +### `create_file` + +- **Parameters:** `path` (required, String), `content` (required, String). +- **Behavior:** Create a new file. Fails if the file already exists (use `write_file` to overwrite). Creates parent directories. +- **Success output:** Confirmation message. +- **Failure:** File already exists, path outside sandbox. + +### `list_files` + +- **Parameters:** `path` (optional, String, defaults to `.`), `pattern` (optional, String, glob pattern), `recursive` (optional, Boolean, default `true`). +- **Behavior:** List files in the directory. Apply glob pattern if provided. Returns paths relative to the worktree root. +- **Success output:** Newline-separated list of file paths. +- **Failure:** Directory not found, path outside sandbox. + +### `search_files` + +- **Parameters:** `query` (required, String), `path` (optional, String, defaults to `.`), `pattern` (optional, String, file glob to filter), `is_regex` (optional, Boolean, default `false`). +- **Behavior:** Search for text in files. Returns matching lines with file paths and line numbers. Limit results to a reasonable maximum (e.g. 100 matches). +- **Success output:** Formatted search results. +- **Failure:** Invalid regex, path outside sandbox. + +--- + +## 3. Registration (`Dispatch::Tool::Files.register(registry)`) + +A convenience method that registers all file tools into a `Dispatch::Tools::Registry`. + +```ruby +registry = Dispatch::Tools::Registry.new +Dispatch::Tool::Files.register(registry) +# Now registry contains: read_file, write_file, edit_file, create_file, list_files, search_files +``` + +--- + +## 4. Error Classes + +- `Dispatch::Tool::Files::Error` — base error. +- `Dispatch::Tool::Files::SandboxError` — path escapes the worktree. +- `Dispatch::Tool::Files::FileNotFoundError` — file does not exist. +- `Dispatch::Tool::Files::FileExistsError` — file already exists (for create_file). + +--- + +## 5. Testing + +- **Sandbox tests:** Path resolution, traversal attacks, symlink attacks, absolute path rejection. +- **Per-tool tests:** Use a temporary directory as a fake worktree. + - `read_file`: read full file, read line range, file not found, binary detection. + - `write_file`: write new file, overwrite existing, create parent dirs. + - `edit_file`: single edit, multiple edits, old_text not found, sequential application. + - `create_file`: create new, fail on existing. + - `list_files`: list all, glob filter, recursive/non-recursive. + - `search_files`: plain text search, regex search, file pattern filter, result limiting. + +--- + +## Key Constraints + +- **All paths must be validated through the Sandbox** before any filesystem operation. +- Tools return `Dispatch::Tools::Result` (success or failure) — they never raise exceptions to the caller. +- The `context[:worktree_path]` is the absolute path to the worktree root, provided by the Rails agent loop. +- Binary file detection: `read_file` should detect and refuse to read binary files (check for null bytes in first N bytes). diff --git a/Gemfile b/Gemfile index 532a949..47f0c88 100644 --- a/Gemfile +++ b/Gemfile @@ -5,6 +5,8 @@ source "https://rubygems.org" # Specify your gem's dependencies in dispatch-tool-files.gemspec gemspec +gem "dispatch-tools-interface", path: "dispatch-tools-interface" + gem "irb" gem "rake", "~> 13.0" diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..0f51084 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,146 @@ +PATH + remote: . + specs: + dispatch-tool-files (0.1.0) + dispatch-tools-interface (~> 0.1) + +PATH + remote: dispatch-tools-interface + specs: + dispatch-tools-interface (0.1.0) + json_schemer (~> 2.0) + +GEM + remote: https://rubygems.org/ + specs: + ast (2.4.3) + bigdecimal (4.1.0) + date (3.5.1) + diff-lcs (1.6.2) + erb (6.0.2) + hana (1.3.7) + io-console (0.8.2) + irb (1.17.0) + pp (>= 0.6.0) + prism (>= 1.3.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + json (2.19.3) + json_schemer (2.5.0) + bigdecimal + hana (~> 1.3) + regexp_parser (~> 2.0) + simpleidn (~> 0.2) + language_server-protocol (3.17.0.5) + lint_roller (1.1.0) + parallel (1.27.0) + parser (3.3.11.1) + ast (~> 2.4.1) + racc + pp (0.6.3) + prettyprint + prettyprint (0.2.0) + prism (1.9.0) + psych (5.3.1) + date + stringio + racc (1.8.1) + rainbow (3.1.1) + rake (13.3.1) + rdoc (7.2.0) + erb + psych (>= 4.0.0) + tsort + regexp_parser (2.11.3) + reline (0.6.3) + io-console (~> 0.5) + rspec (3.13.2) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.8) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-support (3.13.7) + rubocop (1.86.0) + json (~> 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (~> 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.49.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.49.1) + parser (>= 3.3.7.2) + prism (~> 1.7) + ruby-progressbar (1.13.0) + simpleidn (0.2.3) + stringio (3.2.0) + tsort (0.2.0) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) + +PLATFORMS + ruby + x86_64-linux + +DEPENDENCIES + dispatch-tool-files! + dispatch-tools-interface! + irb + rake (~> 13.0) + rspec (~> 3.0) + rubocop (~> 1.21) + +CHECKSUMS + ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 + bigdecimal (4.1.0) sha256=6dc07767aa3dc456ccd48e7ae70a07b474e9afd7c5bc576f80bd6da5c8dd6cae + date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0 + diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962 + dispatch-tool-files (0.1.0) + dispatch-tools-interface (0.1.0) + erb (6.0.2) sha256=9fe6264d44f79422c87490a1558479bd0e7dad4dd0e317656e67ea3077b5242b + hana (1.3.7) sha256=5425db42d651fea08859811c29d20446f16af196308162894db208cac5ce9b0d + io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc + irb (1.17.0) sha256=168c4ddb93d8a361a045c41d92b2952c7a118fa73f23fe14e55609eb7a863aae + json (2.19.3) sha256=289b0bb53052a1fa8c34ab33cc750b659ba14a5c45f3fcf4b18762dc67c78646 + json_schemer (2.5.0) sha256=2f01fb4cce721a4e08dd068fc2030cffd0702a7f333f1ea2be6e8991f00ae396 + language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc + lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 + parallel (1.27.0) sha256=4ac151e1806b755fb4e2dc2332cbf0e54f2e24ba821ff2d3dcf86bf6dc4ae130 + parser (3.3.11.1) sha256=d17ace7aabe3e72c3cc94043714be27cc6f852f104d81aa284c2281aecc65d54 + pp (0.6.3) sha256=2951d514450b93ccfeb1df7d021cae0da16e0a7f95ee1e2273719669d0ab9df6 + prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 + prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 + psych (5.3.1) sha256=eb7a57cef10c9d70173ff74e739d843ac3b2c019a003de48447b2963d81b1974 + racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f + rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a + rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c + rdoc (7.2.0) sha256=8650f76cd4009c3b54955eb5d7e3a075c60a57276766ebf36f9085e8c9f23192 + regexp_parser (2.11.3) sha256=ca13f381a173b7a93450e53459075c9b76a10433caadcb2f1180f2c741fc55a4 + reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835 + rspec (3.13.2) sha256=206284a08ad798e61f86d7ca3e376718d52c0bc944626b2349266f239f820587 + rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d + rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836 + rspec-mocks (3.13.8) sha256=086ad3d3d17533f4237643de0b5c42f04b66348c28bf6b9c2d3f4a3b01af1d47 + rspec-support (3.13.7) sha256=0640e5570872aafefd79867901deeeeb40b0c9875a36b983d85f54fb7381c47c + rubocop (1.86.0) sha256=4ff1186fe16ebe9baff5e7aad66bb0ad4cabf5cdcd419f773146dbba2565d186 + rubocop-ast (1.49.1) sha256=4412f3ee70f6fe4546cc489548e0f6fcf76cafcfa80fa03af67098ffed755035 + ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 + simpleidn (0.2.3) sha256=08ce96f03fa1605286be22651ba0fc9c0b2d6272c9b27a260bc88be05b0d2c29 + stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1 + tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f + unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42 + unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f + +BUNDLED WITH + 4.0.9 diff --git a/dispatch-tool-files.gemspec b/dispatch-tool-files.gemspec index 08615c8..6685a28 100644 --- a/dispatch-tool-files.gemspec +++ b/dispatch-tool-files.gemspec @@ -8,14 +8,14 @@ Gem::Specification.new do |spec| spec.authors = ["Adam Malczewski"] spec.email = ["github@tradam.dev"] - spec.summary = "TODO: Write a short summary, because RubyGems requires one." - spec.description = "TODO: Write a longer description or delete this line." - spec.homepage = "TODO: Put your gem's website or public repo URL here." + spec.summary = "File operation tools for Dispatch agents, sandboxed to the agent worktree." + spec.description = "Provides read, write, edit, create, list, and search file tools for Dispatch subagents." + spec.homepage = "https://github.com/tradam/dispatch-tool-files" spec.license = "MIT" spec.required_ruby_version = ">= 3.2.0" - spec.metadata["allowed_push_host"] = "TODO: Set to your gem server 'https://example.com'" + spec.metadata["allowed_push_host"] = "https://rubygems.org" spec.metadata["homepage_uri"] = spec.homepage - spec.metadata["source_code_uri"] = "TODO: Put your gem's public repo URL here." + spec.metadata["source_code_uri"] = "https://github.com/tradam/dispatch-tool-files" # Specify which files should be added to the gem when it is released. # The `git ls-files -z` loads the files in the RubyGem that have been added into git. @@ -31,7 +31,7 @@ Gem::Specification.new do |spec| spec.require_paths = ["lib"] # Uncomment to register a new dependency of your gem - # spec.add_dependency "example-gem", "~> 1.0" + spec.add_dependency "dispatch-tools-interface", "~> 0.1" # For more information and examples about making a new gem, check out our # guide at: https://bundler.io/guides/creating_gem.html 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 diff --git a/spec/dispatch/tool/files/create_file_spec.rb b/spec/dispatch/tool/files/create_file_spec.rb new file mode 100644 index 0000000..91c1469 --- /dev/null +++ b/spec/dispatch/tool/files/create_file_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +RSpec.describe "create_file tool" do + let(:worktree_path) { Dir.mktmpdir("create-file-test") } + let(:context) { { worktree_path: } } + let(:registry) { Dispatch::Tools::Registry.new } + + before { Dispatch::Tool::Files.register(registry) } + + after { FileUtils.remove_entry(worktree_path) } + + subject(:tool) { registry.get("create_file") } + + describe "creating a new file" do + it "creates a file with the given content" do + result = tool.call({ "path" => "new_file.rb", "content" => "puts 'hello'" }, context:) + + expect(result.success?).to be true + expect(File.read(File.join(worktree_path, "new_file.rb"))).to eq("puts 'hello'") + end + + it "returns a confirmation message" do + result = tool.call({ "path" => "created.txt", "content" => "content" }, context:) + + expect(result.success?).to be true + expect(result.output).to include("created.txt") + end + + it "creates parent directories as needed" do + result = tool.call({ "path" => "deep/nested/dir/file.txt", "content" => "deep content" }, context:) + + expect(result.success?).to be true + expect(File.read(File.join(worktree_path, "deep/nested/dir/file.txt"))).to eq("deep content") + end + end + + describe "error cases" do + it "returns failure when the file already exists" do + file_path = File.join(worktree_path, "existing.txt") + File.write(file_path, "original") + + result = tool.call({ "path" => "existing.txt", "content" => "overwrite attempt" }, context:) + + expect(result.failure?).to be true + expect(result.error).to match(/already exists/i) + expect(File.read(file_path)).to eq("original") + end + + it "returns failure when the path escapes the sandbox" do + result = tool.call({ "path" => "../../../tmp/evil.txt", "content" => "bad" }, context:) + + expect(result.failure?).to be true + expect(result.error).to match(/sandbox|outside/i) + end + end + + describe "parameter validation" do + it "requires the path parameter" do + result = tool.call({ "content" => "data" }, context:) + + expect(result.failure?).to be true + end + + it "requires the content parameter" do + result = tool.call({ "path" => "file.txt" }, context:) + + expect(result.failure?).to be true + end + end +end diff --git a/spec/dispatch/tool/files/edit_file_spec.rb b/spec/dispatch/tool/files/edit_file_spec.rb new file mode 100644 index 0000000..b0366dd --- /dev/null +++ b/spec/dispatch/tool/files/edit_file_spec.rb @@ -0,0 +1,142 @@ +# frozen_string_literal: true + +RSpec.describe "edit_file tool" do + let(:worktree_path) { Dir.mktmpdir("edit-file-test") } + let(:context) { { worktree_path: } } + let(:registry) { Dispatch::Tools::Registry.new } + + before { Dispatch::Tool::Files.register(registry) } + + after { FileUtils.remove_entry(worktree_path) } + + subject(:tool) { registry.get("edit_file") } + + describe "single edit" do + it "replaces old_text with new_text in the file" do + file_path = File.join(worktree_path, "code.rb") + File.write(file_path, "def hello\n puts 'hello'\nend\n") + + result = tool.call({ + "path" => "code.rb", + "edits" => [{ "old_text" => "puts 'hello'", "new_text" => "puts 'goodbye'" }] + }, context:) + + expect(result.success?).to be true + expect(File.read(file_path)).to include("puts 'goodbye'") + expect(File.read(file_path)).not_to include("puts 'hello'") + end + + it "returns a confirmation with the number of edits applied" do + file_path = File.join(worktree_path, "file.txt") + File.write(file_path, "foo bar baz") + + result = tool.call({ + "path" => "file.txt", + "edits" => [{ "old_text" => "bar", "new_text" => "qux" }] + }, context:) + + expect(result.success?).to be true + expect(result.output).to match(/1/i) + end + end + + describe "multiple edits" do + it "applies multiple edits sequentially" do + file_path = File.join(worktree_path, "multi.txt") + File.write(file_path, "aaa bbb ccc") + + result = tool.call({ + "path" => "multi.txt", + "edits" => [ + { "old_text" => "aaa", "new_text" => "xxx" }, + { "old_text" => "ccc", "new_text" => "zzz" } + ] + }, context:) + + expect(result.success?).to be true + expect(File.read(file_path)).to eq("xxx bbb zzz") + end + + it "applies edits sequentially so later edits see results of earlier ones" do + file_path = File.join(worktree_path, "sequential.txt") + File.write(file_path, "hello world") + + result = tool.call({ + "path" => "sequential.txt", + "edits" => [ + { "old_text" => "hello", "new_text" => "hi" }, + { "old_text" => "hi world", "new_text" => "hi there" } + ] + }, context:) + + expect(result.success?).to be true + expect(File.read(file_path)).to eq("hi there") + end + end + + describe "error cases" do + it "returns failure when old_text is not found in the file" do + file_path = File.join(worktree_path, "missing.txt") + File.write(file_path, "actual content") + + result = tool.call({ + "path" => "missing.txt", + "edits" => [{ "old_text" => "nonexistent text", "new_text" => "replacement" }] + }, context:) + + expect(result.failure?).to be true + expect(result.error).to match(/not found/i) + end + + it "returns failure when the file does not exist" do + result = tool.call({ + "path" => "ghost.txt", + "edits" => [{ "old_text" => "a", "new_text" => "b" }] + }, context:) + + expect(result.failure?).to be true + expect(result.error).to match(/not found|does not exist/i) + end + + it "returns failure when the path escapes the sandbox" do + result = tool.call({ + "path" => "../../../etc/passwd", + "edits" => [{ "old_text" => "root", "new_text" => "hacked" }] + }, context:) + + expect(result.failure?).to be true + expect(result.error).to match(/sandbox|outside/i) + end + + it "returns failure when old_text matches ambiguously (multiple occurrences)" do + file_path = File.join(worktree_path, "ambiguous.txt") + File.write(file_path, "foo bar foo baz foo") + + result = tool.call({ + "path" => "ambiguous.txt", + "edits" => [{ "old_text" => "foo", "new_text" => "qux" }] + }, context:) + + expect(result.failure?).to be true + expect(result.error).to match(/ambiguous|multiple/i) + end + end + + describe "parameter validation" do + it "requires the path parameter" do + result = tool.call({ + "edits" => [{ "old_text" => "a", "new_text" => "b" }] + }, context:) + + expect(result.failure?).to be true + end + + it "requires the edits parameter" do + File.write(File.join(worktree_path, "file.txt"), "content") + + result = tool.call({ "path" => "file.txt" }, context:) + + expect(result.failure?).to be true + end + end +end diff --git a/spec/dispatch/tool/files/list_files_spec.rb b/spec/dispatch/tool/files/list_files_spec.rb new file mode 100644 index 0000000..05c4088 --- /dev/null +++ b/spec/dispatch/tool/files/list_files_spec.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +RSpec.describe "list_files tool" do + let(:worktree_path) { Dir.mktmpdir("list-files-test") } + let(:context) { { worktree_path: } } + let(:registry) { Dispatch::Tools::Registry.new } + + before { Dispatch::Tool::Files.register(registry) } + + after { FileUtils.remove_entry(worktree_path) } + + subject(:tool) { registry.get("list_files") } + + describe "listing all files" do + before do + FileUtils.mkdir_p(File.join(worktree_path, "src", "lib")) + File.write(File.join(worktree_path, "README.md"), "readme") + File.write(File.join(worktree_path, "src", "main.rb"), "main") + File.write(File.join(worktree_path, "src", "lib", "helper.rb"), "helper") + end + + it "lists all files recursively by default" do + result = tool.call({}, context:) + + expect(result.success?).to be true + expect(result.output).to include("README.md") + expect(result.output).to include("src/main.rb") + expect(result.output).to include("src/lib/helper.rb") + end + + it "returns paths relative to the worktree root" do + result = tool.call({}, context:) + + expect(result.success?).to be true + expect(result.output).not_to include(worktree_path) + end + end + + describe "listing with a path" do + before do + FileUtils.mkdir_p(File.join(worktree_path, "src")) + FileUtils.mkdir_p(File.join(worktree_path, "test")) + File.write(File.join(worktree_path, "src", "app.rb"), "app") + File.write(File.join(worktree_path, "test", "app_test.rb"), "test") + end + + it "lists files only in the specified subdirectory" do + result = tool.call({ "path" => "src" }, context:) + + expect(result.success?).to be true + expect(result.output).to include("app.rb") + expect(result.output).not_to include("app_test.rb") + end + end + + describe "glob pattern filtering" do + before do + FileUtils.mkdir_p(File.join(worktree_path, "src")) + File.write(File.join(worktree_path, "src", "main.rb"), "main") + File.write(File.join(worktree_path, "src", "style.css"), "css") + File.write(File.join(worktree_path, "README.md"), "readme") + end + + it "filters files using a glob pattern" do + result = tool.call({ "pattern" => "**/*.rb" }, context:) + + expect(result.success?).to be true + expect(result.output).to include("main.rb") + expect(result.output).not_to include("style.css") + expect(result.output).not_to include("README.md") + end + + it "supports multiple extension glob patterns" do + result = tool.call({ "pattern" => "**/*.{rb,md}" }, context:) + + expect(result.success?).to be true + expect(result.output).to include("main.rb") + expect(result.output).to include("README.md") + expect(result.output).not_to include("style.css") + end + end + + describe "recursive vs non-recursive" do + before do + FileUtils.mkdir_p(File.join(worktree_path, "nested", "deep")) + File.write(File.join(worktree_path, "top.txt"), "top") + File.write(File.join(worktree_path, "nested", "mid.txt"), "mid") + File.write(File.join(worktree_path, "nested", "deep", "bottom.txt"), "bottom") + end + + it "includes nested files when recursive is true" do + result = tool.call({ "recursive" => true }, context:) + + expect(result.success?).to be true + expect(result.output).to include("top.txt") + expect(result.output).to include("nested/mid.txt") + expect(result.output).to include("nested/deep/bottom.txt") + end + + it "lists only top-level files when recursive is false" do + result = tool.call({ "recursive" => false }, context:) + + expect(result.success?).to be true + expect(result.output).to include("top.txt") + expect(result.output).not_to include("mid.txt") + expect(result.output).not_to include("bottom.txt") + end + end + + describe "error cases" do + it "returns failure when the directory does not exist" do + result = tool.call({ "path" => "nonexistent_dir" }, context:) + + expect(result.failure?).to be true + expect(result.error).to match(/not found|does not exist/i) + end + + it "returns failure when the path escapes the sandbox" do + result = tool.call({ "path" => "../../../etc" }, context:) + + expect(result.failure?).to be true + expect(result.error).to match(/sandbox|outside/i) + end + end +end diff --git a/spec/dispatch/tool/files/read_file_spec.rb b/spec/dispatch/tool/files/read_file_spec.rb new file mode 100644 index 0000000..01b7828 --- /dev/null +++ b/spec/dispatch/tool/files/read_file_spec.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +RSpec.describe "read_file tool" do + let(:worktree_path) { Dir.mktmpdir("read-file-test") } + let(:context) { { worktree_path: } } + let(:registry) { Dispatch::Tools::Registry.new } + + before { Dispatch::Tool::Files.register(registry) } + + after { FileUtils.remove_entry(worktree_path) } + + subject(:tool) { registry.get("read_file") } + + describe "reading a full file" do + it "returns file contents with line numbers" do + File.write(File.join(worktree_path, "hello.txt"), "line one\nline two\nline three\n") + + result = tool.call({ "path" => "hello.txt" }, context:) + + expect(result.success?).to be true + expect(result.output).to include("line one") + expect(result.output).to include("line two") + expect(result.output).to include("line three") + end + + it "prefixes each line with its line number" do + File.write(File.join(worktree_path, "numbered.txt"), "alpha\nbeta\ngamma\n") + + result = tool.call({ "path" => "numbered.txt" }, context:) + + expect(result.success?).to be true + lines = result.output.split("\n") + expect(lines[0]).to match(/\A\s*0.*alpha/) + expect(lines[1]).to match(/\A\s*1.*beta/) + expect(lines[2]).to match(/\A\s*2.*gamma/) + end + end + + describe "reading a line range" do + before do + content = (0..9).map { |i| "line #{i}" }.join("\n") + "\n" + File.write(File.join(worktree_path, "lines.txt"), content) + end + + it "returns only the specified line range (0-based)" do + result = tool.call({ "path" => "lines.txt", "start_line" => 2, "end_line" => 4 }, context:) + + expect(result.success?).to be true + expect(result.output).to include("line 2") + expect(result.output).to include("line 3") + expect(result.output).to include("line 4") + expect(result.output).not_to include("line 1") + expect(result.output).not_to include("line 5") + end + + it "reads from start_line to end of file when end_line is -1" do + result = tool.call({ "path" => "lines.txt", "start_line" => 8, "end_line" => -1 }, context:) + + expect(result.success?).to be true + expect(result.output).to include("line 8") + expect(result.output).to include("line 9") + expect(result.output).not_to include("line 7") + end + + it "reads from the beginning when only end_line is specified" do + result = tool.call({ "path" => "lines.txt", "end_line" => 1 }, context:) + + expect(result.success?).to be true + expect(result.output).to include("line 0") + expect(result.output).to include("line 1") + expect(result.output).not_to include("line 2") + end + end + + describe "error cases" do + it "returns failure when the file does not exist" do + result = tool.call({ "path" => "nonexistent.txt" }, context:) + + expect(result.failure?).to be true + expect(result.error).to match(/not found|does not exist/i) + end + + it "returns failure when the path escapes the sandbox" do + result = tool.call({ "path" => "../../../etc/passwd" }, context:) + + expect(result.failure?).to be true + expect(result.error).to match(/sandbox|outside/i) + end + + it "returns failure for a binary file" do + binary_path = File.join(worktree_path, "binary.bin") + File.write(binary_path, "Hello\x00World\x00Binary\x00Content") + + result = tool.call({ "path" => "binary.bin" }, context:) + + expect(result.failure?).to be true + expect(result.error).to match(/binary/i) + end + end + + describe "parameter validation" do + it "requires the path parameter" do + result = tool.call({}, context:) + + expect(result.failure?).to be true + end + end +end diff --git a/spec/dispatch/tool/files/sandbox_spec.rb b/spec/dispatch/tool/files/sandbox_spec.rb new file mode 100644 index 0000000..f86cb5a --- /dev/null +++ b/spec/dispatch/tool/files/sandbox_spec.rb @@ -0,0 +1,163 @@ +# frozen_string_literal: true + +RSpec.describe Dispatch::Tool::Files::Sandbox do + let(:worktree_path) { Dir.mktmpdir("sandbox-test") } + + after { FileUtils.remove_entry(worktree_path) } + + describe ".resolve_path" do + it "resolves a simple relative path within the worktree" do + file_path = File.join(worktree_path, "hello.txt") + FileUtils.touch(file_path) + + resolved = described_class.resolve_path("hello.txt", worktree_path:) + + expect(resolved).to eq(file_path) + end + + it "resolves a nested relative path within the worktree" do + nested_dir = File.join(worktree_path, "src", "lib") + FileUtils.mkdir_p(nested_dir) + file_path = File.join(nested_dir, "main.rb") + FileUtils.touch(file_path) + + resolved = described_class.resolve_path("src/lib/main.rb", worktree_path:) + + expect(resolved).to eq(file_path) + end + + it "resolves paths with . components" do + file_path = File.join(worktree_path, "hello.txt") + FileUtils.touch(file_path) + + resolved = described_class.resolve_path("./hello.txt", worktree_path:) + + expect(resolved).to eq(file_path) + end + + it "raises SandboxError for .. traversal escaping the worktree" do + expect do + described_class.resolve_path("../../../etc/passwd", worktree_path:) + end.to raise_error(Dispatch::Tool::Files::SandboxError) + end + + it "raises SandboxError for deeply nested .. traversal that escapes" do + FileUtils.mkdir_p(File.join(worktree_path, "a", "b")) + + expect do + described_class.resolve_path("a/b/../../../../etc/passwd", worktree_path:) + end.to raise_error(Dispatch::Tool::Files::SandboxError) + end + + it "allows .. traversal that stays within the worktree" do + FileUtils.mkdir_p(File.join(worktree_path, "a", "b")) + file_path = File.join(worktree_path, "a", "file.txt") + FileUtils.touch(file_path) + + resolved = described_class.resolve_path("a/b/../file.txt", worktree_path:) + + expect(resolved).to eq(file_path) + end + + it "raises SandboxError for absolute paths outside the worktree" do + expect do + described_class.resolve_path("/etc/passwd", worktree_path:) + end.to raise_error(Dispatch::Tool::Files::SandboxError) + end + + it "raises SandboxError for absolute paths that happen to share a prefix" do + expect do + described_class.resolve_path("#{worktree_path}-evil/secret.txt", worktree_path:) + end.to raise_error(Dispatch::Tool::Files::SandboxError) + end + + it "raises SandboxError for symlinks pointing outside the worktree" do + link_path = File.join(worktree_path, "evil_link") + File.symlink("/etc/passwd", link_path) + + expect do + described_class.resolve_path("evil_link", worktree_path:) + end.to raise_error(Dispatch::Tool::Files::SandboxError) + end + + it "raises SandboxError for symlinked directories pointing outside the worktree" do + link_path = File.join(worktree_path, "evil_dir") + File.symlink("/tmp", link_path) + + expect do + described_class.resolve_path("evil_dir/some_file.txt", worktree_path:) + end.to raise_error(Dispatch::Tool::Files::SandboxError) + end + + it "allows symlinks that resolve within the worktree" do + target_path = File.join(worktree_path, "real.txt") + FileUtils.touch(target_path) + + link_path = File.join(worktree_path, "link.txt") + File.symlink(target_path, link_path) + + resolved = described_class.resolve_path("link.txt", worktree_path:) + + expect(resolved).to eq(target_path) + end + + it "resolves the worktree path itself for an empty string" do + resolved = described_class.resolve_path("", worktree_path:) + + expect(resolved).to eq(worktree_path) + end + + it "resolves the worktree path for ." do + resolved = described_class.resolve_path(".", worktree_path:) + + expect(resolved).to eq(worktree_path) + end + + it "handles paths to non-existent files within the worktree" do + resolved = described_class.resolve_path("nonexistent.txt", worktree_path:) + + expect(resolved).to eq(File.join(worktree_path, "nonexistent.txt")) + end + + it "handles paths to non-existent nested directories within the worktree" do + resolved = described_class.resolve_path("a/b/c/file.txt", worktree_path:) + + expect(resolved).to eq(File.join(worktree_path, "a/b/c/file.txt")) + end + end + + describe ".within_worktree?" do + it "returns true for a path inside the worktree" do + FileUtils.touch(File.join(worktree_path, "file.txt")) + + expect(described_class.within_worktree?("file.txt", worktree_path:)).to be true + end + + it "returns false for a path escaping the worktree" do + expect(described_class.within_worktree?("../../etc/passwd", worktree_path:)).to be false + end + + it "returns false for an absolute path outside the worktree" do + expect(described_class.within_worktree?("/etc/passwd", worktree_path:)).to be false + end + + it "returns false for a symlink pointing outside the worktree" do + link_path = File.join(worktree_path, "evil_link") + File.symlink("/etc/passwd", link_path) + + expect(described_class.within_worktree?("evil_link", worktree_path:)).to be false + end + + it "returns true for a symlink that stays inside the worktree" do + target = File.join(worktree_path, "real.txt") + FileUtils.touch(target) + File.symlink(target, File.join(worktree_path, "link.txt")) + + expect(described_class.within_worktree?("link.txt", worktree_path:)).to be true + end + + it "returns true for non-existent paths within the worktree" do + expect(described_class.within_worktree?("does/not/exist.txt", worktree_path:)).to be true + end + end +end diff --git a/spec/dispatch/tool/files/search_files_spec.rb b/spec/dispatch/tool/files/search_files_spec.rb new file mode 100644 index 0000000..139a5e9 --- /dev/null +++ b/spec/dispatch/tool/files/search_files_spec.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +RSpec.describe "search_files tool" do + let(:worktree_path) { Dir.mktmpdir("search-files-test") } + let(:context) { { worktree_path: } } + let(:registry) { Dispatch::Tools::Registry.new } + + before { Dispatch::Tool::Files.register(registry) } + + after { FileUtils.remove_entry(worktree_path) } + + subject(:tool) { registry.get("search_files") } + + describe "plain text search" do + before do + FileUtils.mkdir_p(File.join(worktree_path, "src")) + File.write(File.join(worktree_path, "src", "app.rb"), "def hello\n puts 'hello world'\nend\n") + File.write(File.join(worktree_path, "src", "utils.rb"), "def goodbye\n puts 'goodbye world'\nend\n") + File.write(File.join(worktree_path, "README.md"), "# Hello World\n\nA sample project.\n") + end + + it "finds matches across multiple files" do + result = tool.call({ "query" => "world" }, context:) + + expect(result.success?).to be true + expect(result.output).to include("hello world") + expect(result.output).to include("goodbye world") + end + + it "includes file paths in the results" do + result = tool.call({ "query" => "hello" }, context:) + + expect(result.success?).to be true + expect(result.output).to include("src/app.rb") + end + + it "includes line numbers in the results" do + result = tool.call({ "query" => "puts" }, context:) + + expect(result.success?).to be true + # line numbers should appear in the output + expect(result.output).to match(/\d+/) + end + end + + describe "regex search" do + before do + File.write(File.join(worktree_path, "data.txt"), "foo123bar\nbaz456qux\nhello789\n") + end + + it "finds matches using a regular expression" do + result = tool.call({ "query" => "\\d{3}", "is_regex" => true }, context:) + + expect(result.success?).to be true + expect(result.output).to include("foo123bar") + expect(result.output).to include("baz456qux") + expect(result.output).to include("hello789") + end + + it "returns failure for an invalid regex" do + result = tool.call({ "query" => "[unclosed", "is_regex" => true }, context:) + + expect(result.failure?).to be true + expect(result.error).to match(/invalid|regex|regexp/i) + end + end + + describe "scoped search with path" do + before do + FileUtils.mkdir_p(File.join(worktree_path, "src")) + FileUtils.mkdir_p(File.join(worktree_path, "test")) + File.write(File.join(worktree_path, "src", "main.rb"), "target line\n") + File.write(File.join(worktree_path, "test", "main_test.rb"), "target line\n") + end + + it "searches only within the specified path" do + result = tool.call({ "query" => "target", "path" => "src" }, context:) + + expect(result.success?).to be true + expect(result.output).to include("src/main.rb") + expect(result.output).not_to include("test/main_test.rb") + end + end + + describe "file pattern filtering" do + before do + FileUtils.mkdir_p(File.join(worktree_path, "src")) + File.write(File.join(worktree_path, "src", "app.rb"), "target\n") + File.write(File.join(worktree_path, "src", "style.css"), "target\n") + end + + it "filters search results by file glob pattern" do + result = tool.call({ "query" => "target", "pattern" => "**/*.rb" }, context:) + + expect(result.success?).to be true + expect(result.output).to include("app.rb") + expect(result.output).not_to include("style.css") + end + end + + describe "result limiting" do + before do + content = (1..200).map { |i| "match_target line #{i}" }.join("\n") + "\n" + File.write(File.join(worktree_path, "big_file.txt"), content) + end + + it "limits results to a reasonable maximum" do + result = tool.call({ "query" => "match_target" }, context:) + + expect(result.success?).to be true + match_count = result.output.scan(/match_target/).size + expect(match_count).to be <= 100 + end + end + + describe "error cases" do + it "returns failure when the path escapes the sandbox" do + result = tool.call({ "query" => "root", "path" => "../../../etc" }, context:) + + expect(result.failure?).to be true + expect(result.error).to match(/sandbox|outside/i) + end + end + + describe "parameter validation" do + it "requires the query parameter" do + result = tool.call({}, context:) + + expect(result.failure?).to be true + end + end +end diff --git a/spec/dispatch/tool/files/write_file_spec.rb b/spec/dispatch/tool/files/write_file_spec.rb new file mode 100644 index 0000000..8b7bacb --- /dev/null +++ b/spec/dispatch/tool/files/write_file_spec.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +RSpec.describe "write_file tool" do + let(:worktree_path) { Dir.mktmpdir("write-file-test") } + let(:context) { { worktree_path: } } + let(:registry) { Dispatch::Tools::Registry.new } + + before { Dispatch::Tool::Files.register(registry) } + + after { FileUtils.remove_entry(worktree_path) } + + subject(:tool) { registry.get("write_file") } + + describe "writing a new file" do + it "creates a new file with the given content" do + result = tool.call({ "path" => "new_file.txt", "content" => "Hello, World!" }, context:) + + expect(result.success?).to be true + expect(File.read(File.join(worktree_path, "new_file.txt"))).to eq("Hello, World!") + end + + it "returns a confirmation message with path and byte count" do + result = tool.call({ "path" => "output.txt", "content" => "12345" }, context:) + + expect(result.success?).to be true + expect(result.output).to include("output.txt") + expect(result.output).to match(/5\b.*bytes?/i) + end + end + + describe "overwriting an existing file" do + it "replaces the entire content of an existing file" do + file_path = File.join(worktree_path, "existing.txt") + File.write(file_path, "old content") + + result = tool.call({ "path" => "existing.txt", "content" => "new content" }, context:) + + expect(result.success?).to be true + expect(File.read(file_path)).to eq("new content") + end + end + + describe "creating parent directories" do + it "creates intermediate directories as needed" do + result = tool.call({ "path" => "a/b/c/deep.txt", "content" => "deep" }, context:) + + expect(result.success?).to be true + expect(File.read(File.join(worktree_path, "a/b/c/deep.txt"))).to eq("deep") + end + end + + describe "error cases" do + it "returns failure when the path escapes the sandbox" do + result = tool.call({ "path" => "../../../tmp/evil.txt", "content" => "bad" }, context:) + + expect(result.failure?).to be true + expect(result.error).to match(/sandbox|outside/i) + end + end + + describe "parameter validation" do + it "requires the path parameter" do + result = tool.call({ "content" => "data" }, context:) + + expect(result.failure?).to be true + end + + it "requires the content parameter" do + result = tool.call({ "path" => "file.txt" }, context:) + + expect(result.failure?).to be true + end + end +end diff --git a/spec/dispatch/tool/files_spec.rb b/spec/dispatch/tool/files_spec.rb index 5352b1e..0db1ea6 100644 --- a/spec/dispatch/tool/files_spec.rb +++ b/spec/dispatch/tool/files_spec.rb @@ -2,10 +2,6 @@ RSpec.describe Dispatch::Tool::Files do it "has a version number" do - expect(Dispatch::Tool::Files::VERSION).not_to be nil - end - - it "does something useful" do - expect(false).to eq(true) + expect(Dispatch::Tool::Files::VERSION).not_to be_nil end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 298f383..3cd8053 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,6 +1,9 @@ # frozen_string_literal: true +require "dispatch/tools/interface" require "dispatch/tool/files" +require "tmpdir" +require "fileutils" RSpec.configure do |config| # Enable flags like --only-failures and --next-failure -- cgit v1.2.3