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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 10 additions & 39 deletions lib/jido_code/tools/bridge.ex
Original file line number Diff line number Diff line change
Expand Up @@ -393,17 +393,18 @@ defmodule JidoCode.Tools.Bridge do
{list(), :luerl.luerl_state()}
defp do_glob(pattern, base_path, state, project_root) do
with {:ok, safe_base} <- Security.validate_path(base_path, project_root),
true <- File.exists?(safe_base) do
{:ok, _} <- ensure_exists(safe_base) do
# Build full pattern path
full_pattern = Path.join(safe_base, pattern)

# Find matching files, filter to boundary, sort by mtime
# Uses GlobMatcher for consistent behavior with GlobSearch handler
matches =
full_pattern
|> Path.wildcard(match_dot: false)
|> filter_within_boundary(project_root)
|> sort_by_mtime_desc()
|> make_relative(project_root)
|> GlobMatcher.filter_within_boundary(project_root)
|> GlobMatcher.sort_by_mtime_desc()
|> GlobMatcher.make_relative(project_root)

# Convert to Lua array format
lua_array =
Expand All @@ -413,48 +414,18 @@ defmodule JidoCode.Tools.Bridge do

{[lua_array], state}
else
false ->
{:error, :enoent} ->
handle_operation_error(:enoent, base_path, state)

{:error, reason} ->
handle_operation_error(reason, base_path, state)
end
end

# Filter paths to only those within project boundary
@spec filter_within_boundary(list(String.t()), String.t()) :: list(String.t())
defp filter_within_boundary(paths, project_root) do
expanded_root = Path.expand(project_root)

Enum.filter(paths, fn path ->
expanded_path = Path.expand(path)
String.starts_with?(expanded_path, expanded_root <> "/") or expanded_path == expanded_root
end)
end

# Sort by modification time, newest first
@spec sort_by_mtime_desc(list(String.t())) :: list(String.t())
defp sort_by_mtime_desc(paths) do
Enum.sort_by(
paths,
fn path ->
case File.stat(path, time: :posix) do
{:ok, %{mtime: mtime}} -> -mtime
_ -> 0
end
end
)
end

# Convert absolute paths to relative paths from project root
@spec make_relative(list(String.t()), String.t()) :: list(String.t())
defp make_relative(paths, project_root) do
expanded_root = Path.expand(project_root)

Enum.map(paths, fn path ->
expanded_path = Path.expand(path)
Path.relative_to(expanded_path, expanded_root)
end)
# Helper to check file existence in a with-compatible format
@spec ensure_exists(String.t()) :: {:ok, String.t()} | {:error, :enoent}
defp ensure_exists(path) do
if File.exists?(path), do: {:ok, path}, else: {:error, :enoent}
end

@doc """
Expand Down
57 changes: 15 additions & 42 deletions lib/jido_code/tools/handlers/file_system.ex
Original file line number Diff line number Diff line change
Expand Up @@ -1828,14 +1828,17 @@ defmodule JidoCode.Tools.Handlers.FileSystem do

All matched paths are validated against the project boundary.
Paths outside the boundary are automatically filtered out.
Symlinks are followed to ensure their targets stay within the boundary.

## See Also

- `JidoCode.Tools.Definitions.GlobSearch` - Tool definition
- `JidoCode.Tools.Helpers.GlobMatcher` - Shared helper functions
- `Path.wildcard/2` - Underlying pattern matching
"""

alias JidoCode.Tools.Handlers.FileSystem
alias JidoCode.Tools.Helpers.GlobMatcher

@doc """
Finds files matching a glob pattern.
Expand Down Expand Up @@ -1864,14 +1867,15 @@ defmodule JidoCode.Tools.Handlers.FileSystem do
if File.exists?(safe_base) do
search_files(pattern, safe_base, context)
else
{:error, FileSystem.format_error(:file_not_found, base_path)}
{:error, FileSystem.format_error(:enoent, base_path)}
end

{:error, reason} ->
{:error, FileSystem.format_error(reason, base_path)}
end
end

# Fallback clause for missing or invalid pattern argument
def execute(_args, _context) do
{:error, "glob_search requires a pattern argument"}
end
Expand All @@ -1885,57 +1889,26 @@ defmodule JidoCode.Tools.Handlers.FileSystem do
full_pattern = Path.join(safe_base, pattern)

# Use Path.wildcard to find matching files
# Note: Path.wildcard may raise for invalid patterns, hence the rescue
matches =
full_pattern
|> Path.wildcard(match_dot: false)
|> filter_within_boundary(project_root)
|> sort_by_mtime_desc()
|> make_relative(project_root)
|> GlobMatcher.filter_within_boundary(project_root)
|> GlobMatcher.sort_by_mtime_desc()
|> GlobMatcher.make_relative(project_root)

{:ok, Jason.encode!(matches)}

{:error, reason} ->
{:error, "Glob search error: #{reason}"}
{:error, FileSystem.format_error(reason, "context")}
end
rescue
e ->
{:error, "Glob search error: #{Exception.message(e)}"}
end

# Filter paths to only those within project boundary
@spec filter_within_boundary(list(String.t()), String.t()) :: list(String.t())
defp filter_within_boundary(paths, project_root) do
expanded_root = Path.expand(project_root)

Enum.filter(paths, fn path ->
expanded_path = Path.expand(path)
String.starts_with?(expanded_path, expanded_root <> "/") or expanded_path == expanded_root
end)
end
# Path.wildcard/2 can raise for malformed patterns
e in ArgumentError ->
{:error, "Invalid glob pattern: #{Exception.message(e)}"}

# Sort by modification time, newest first
@spec sort_by_mtime_desc(list(String.t())) :: list(String.t())
defp sort_by_mtime_desc(paths) do
Enum.sort_by(
paths,
fn path ->
case File.stat(path, time: :posix) do
{:ok, %{mtime: mtime}} -> -mtime
_ -> 0
end
end
)
end

# Convert absolute paths to relative paths from project root
@spec make_relative(list(String.t()), String.t()) :: list(String.t())
defp make_relative(paths, project_root) do
expanded_root = Path.expand(project_root)

Enum.map(paths, fn path ->
expanded_path = Path.expand(path)
Path.relative_to(expanded_path, expanded_root)
end)
e in Jason.EncodeError ->
{:error, "Failed to encode results: #{Exception.message(e)}"}
end
end
end
189 changes: 174 additions & 15 deletions lib/jido_code/tools/helpers/glob_matcher.ex
Original file line number Diff line number Diff line change
@@ -1,34 +1,46 @@
defmodule JidoCode.Tools.Helpers.GlobMatcher do
@moduledoc """
Shared glob pattern matching utilities for file listing tools.
Shared glob pattern matching utilities for file listing and search tools.

This module provides glob pattern matching functionality used by both
the ListDir handler and the lua_list_dir bridge function, eliminating
code duplication and ensuring consistent behavior.
handlers and bridge functions, eliminating code duplication and ensuring
consistent behavior.

## Supported Glob Patterns
## Pattern Matching Functions

For ignore pattern filtering (used by ListDir):
- `matches_any?/2` - Check if entry matches any pattern in a list
- `matches_glob?/2` - Check if entry matches a single glob pattern
- `sort_directories_first/2` - Sort with directories first
- `entry_info/2` - Get entry metadata

## Glob Search Functions

For glob search result processing (used by GlobSearch):
- `filter_within_boundary/2` - Filter paths to project boundary (with symlink validation)
- `sort_by_mtime_desc/1` - Sort by modification time (newest first)
- `make_relative/2` - Convert absolute paths to relative

## Supported Glob Patterns (for matches_glob?)

- `*` - Match any sequence of characters (except path separator)
- `?` - Match any single character
- Literal characters are matched exactly

## Limitations

The following advanced glob features are NOT supported:
- `**` for recursive directory matching (treated as `*`)
- `[abc]` character classes
- `{a,b}` brace expansion
- `!pattern` negation
Note: `**`, `[abc]`, and `{a,b}` patterns are handled by `Path.wildcard/2`
in the GlobSearch tool, not by this module's pattern matching functions.

## Security

All regex metacharacters are properly escaped to prevent regex injection
attacks. Invalid patterns are logged and treated as non-matching.
- All regex metacharacters are properly escaped to prevent regex injection
- Symlinks are followed and validated in `filter_within_boundary/2`
- Invalid patterns are logged and treated as non-matching

## See Also

- `JidoCode.Tools.Handlers.FileSystem.ListDir` - Handler using this module
- `JidoCode.Tools.Bridge.lua_list_dir/3` - Bridge function using this module
- `JidoCode.Tools.Handlers.FileSystem.ListDir` - ListDir handler
- `JidoCode.Tools.Handlers.FileSystem.GlobSearch` - GlobSearch handler
- `JidoCode.Tools.Bridge` - Bridge functions for Lua sandbox
"""

require Logger
Expand Down Expand Up @@ -152,4 +164,151 @@ defmodule JidoCode.Tools.Helpers.GlobMatcher do
# We'll convert them back to regex wildcards in glob_to_regex
escaped
end

# ===========================================================================
# Glob Search Helpers
# ===========================================================================

@doc """
Filters paths to only those within the project boundary.

This function validates that each path is within the allowed project root,
including following symlinks to ensure they don't escape the boundary.

## Parameters

- `paths` - List of absolute file paths to filter
- `project_root` - The project root directory (will be expanded)

## Returns

A filtered list containing only paths within the boundary.

## Examples

iex> GlobMatcher.filter_within_boundary(["/project/file.ex", "/etc/passwd"], "/project")
["/project/file.ex"]

"""
@spec filter_within_boundary(list(String.t()), String.t()) :: list(String.t())
def filter_within_boundary(paths, project_root) when is_list(paths) and is_binary(project_root) do
expanded_root = Path.expand(project_root)

Enum.filter(paths, fn path ->
path_within_boundary?(path, expanded_root)
end)
end

@doc """
Sorts paths by modification time, newest first.

Files that cannot be stat'd (e.g., permission denied) are sorted to the end.

## Parameters

- `paths` - List of file paths to sort

## Returns

Paths sorted by modification time (newest first).

## Examples

iex> GlobMatcher.sort_by_mtime_desc(["/old/file.ex", "/new/file.ex"])
["/new/file.ex", "/old/file.ex"] # assuming new was modified more recently

"""
@spec sort_by_mtime_desc(list(String.t())) :: list(String.t())
def sort_by_mtime_desc(paths) when is_list(paths) do
Enum.sort_by(
paths,
fn path ->
case File.stat(path, time: :posix) do
{:ok, %{mtime: mtime}} -> -mtime
_ -> 0
end
end
)
end

@doc """
Converts absolute paths to relative paths from project root.

## Parameters

- `paths` - List of absolute file paths
- `project_root` - The project root directory (will be expanded)

## Returns

List of paths relative to the project root.

## Examples

iex> GlobMatcher.make_relative(["/project/lib/file.ex"], "/project")
["lib/file.ex"]

"""
@spec make_relative(list(String.t()), String.t()) :: list(String.t())
def make_relative(paths, project_root) when is_list(paths) and is_binary(project_root) do
expanded_root = Path.expand(project_root)

Enum.map(paths, fn path ->
expanded_path = Path.expand(path)
Path.relative_to(expanded_path, expanded_root)
end)
end

# Private: Check if a single path is within the boundary, including symlink resolution
@spec path_within_boundary?(String.t(), String.t()) :: boolean()
defp path_within_boundary?(path, expanded_root) do
expanded_path = Path.expand(path)

# First check: is the path itself within the boundary?
if String.starts_with?(expanded_path, expanded_root <> "/") or expanded_path == expanded_root do
# Second check: if it's a symlink, does its target stay within boundary?
case File.read_link(path) do
{:ok, _target} ->
# It's a symlink - get the real path and check
real_path = resolve_real_path(path)
String.starts_with?(real_path, expanded_root <> "/") or real_path == expanded_root

{:error, :einval} ->
# Not a symlink, path check passed
true

{:error, _} ->
# Other error (file doesn't exist, etc.), exclude
false
end
else
false
end
end

# Resolve the real path by following all symlinks
@spec resolve_real_path(String.t()) :: String.t()
defp resolve_real_path(path) do
case File.read_link(path) do
{:ok, target} ->
# Target might be relative to the symlink's directory
resolved =
if Path.type(target) == :relative do
path |> Path.dirname() |> Path.join(target) |> Path.expand()
else
Path.expand(target)
end

# Recursively follow if target is also a symlink
resolve_real_path(resolved)

{:error, :einval} ->
# Not a symlink, return expanded path
Path.expand(path)

{:error, _} ->
# Other error, return expanded path
Path.expand(path)
end
end
end
Loading
Loading