summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--.rules/changelog/2026-03/31/01.md21
-rw-r--r--.rules/plan/dispatch-tool-files-plan.md154
-rw-r--r--Gemfile2
-rw-r--r--Gemfile.lock146
-rw-r--r--dispatch-tool-files.gemspec12
-rw-r--r--lib/dispatch/tool/files.rb15
-rw-r--r--lib/dispatch/tool/files/create_file.rb46
-rw-r--r--lib/dispatch/tool/files/edit_file.rb91
-rw-r--r--lib/dispatch/tool/files/list_files.rb60
-rw-r--r--lib/dispatch/tool/files/read_file.rb68
-rw-r--r--lib/dispatch/tool/files/register.rb21
-rw-r--r--lib/dispatch/tool/files/sandbox.rb69
-rw-r--r--lib/dispatch/tool/files/search_files.rb89
-rw-r--r--lib/dispatch/tool/files/write_file.rb43
-rw-r--r--spec/dispatch/tool/files/create_file_spec.rb70
-rw-r--r--spec/dispatch/tool/files/edit_file_spec.rb142
-rw-r--r--spec/dispatch/tool/files/list_files_spec.rb125
-rw-r--r--spec/dispatch/tool/files/read_file_spec.rb108
-rw-r--r--spec/dispatch/tool/files/sandbox_spec.rb163
-rw-r--r--spec/dispatch/tool/files/search_files_spec.rb132
-rw-r--r--spec/dispatch/tool/files/write_file_spec.rb74
-rw-r--r--spec/dispatch/tool/files_spec.rb6
-rw-r--r--spec/spec_helper.rb3
23 files changed, 1648 insertions, 12 deletions
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 = ["[email protected]"]
- 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