Add built-in glob tool for file pattern matching
* feat(tools): add built-in glob tool for file pattern matching
Introduce a built-in glob tool to allow agents to find files matching wildcard and glob patterns (e.g. **/*.go, *.json, src/**/*.ts).
Key additions and improvements:
- Implement GlobTool with support for ** globstar, wildcards, character classes, and brace expansion ({a,b}).
- Honor root and nested .gitignore rules during directory traversal using ignore.Stack.
- Support sandbox restrictions (Sandbox.CheckReadPath) and depth/match bounding.
- Integrate glob tool into tool registry, TUI short argument views, and documentation.
- Add comprehensive unit tests covering pattern matching, gitignore filtering, sandbox enforcement, and edge cases.
* Fix glob path handling
---------
Co-authored-by: patriceckhart <mail@patriceckhart.com>- commit
- 356583dde0d7546b01293d9d8fbb47a29f3f0fce
- author
- Vivaan Dhawan · · 2026-08-31 05:40 UTC
- committer
- GitHub · · 2026-08-31 05:40 UTC
- parent
- cad135d
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown - refs
- tag: v0.3.53
10 files · +818 -48
M README.md +3 -3
| @@ -19,7 +19,7 @@ Yet another coding agent harness, lightweight and written (vibe-slopped) in go. | |||
| 19 | 19 | ||
| 20 | 20 | - one static binary. | |
| 21 | 21 | - built-in providers for Anthropic, OpenAI/Codex/Responses, Kimi, DeepSeek, Google Gemini/Vertex, GitHub Copilot, Bedrock, Azure OpenAI, OpenRouter, Groq, Cerebras, xAI, Together, Hugging Face, Mistral, Moonshot, Z.AI, Xiaomi, MiniMax, Fireworks, Vercel AI Gateway, OpenCode, Cloudflare AI, and Ollama/local models. | |
| 22 | - | - four tools (read, write, edit, bash). | |
| 22 | + | - built-in tools (read, write, edit, bash, glob). | |
| 23 | 23 | - three run modes (interactive tui, print, json). | |
| 24 | 24 | - built-in telegram bot. | |
| 25 | 25 | - extensions in any language via subprocess + json-rpc. None installed by default; opt in with `zot ext install` or `zot --ext`. See [docs/extensions.md](docs/extensions.md). | |
| @@ -888,7 +888,7 @@ Slash commands also work while the agent is busy. Non-destructive ones (`/help`, | |||
| 888 | 888 | | `ctrl+d` | Exit on empty input. | | |
| 889 | 889 | | `ctrl+l` | Redraw the screen. | | |
| 890 | 890 | | `ctrl+v` | Paste clipboard text into the focused chat, side chat, dialog, filter, or credential input. On Linux this uses `wl-paste`, `xclip`, or `xsel`; terminal-native bracketed paste remains available without those commands. In the main chat on macOS, image-only clipboard content is saved as a temporary PNG and attached to the next prompt. | | |
| 891 | - | | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash outputs over ~12 lines). | | |
| 891 | + | | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash, glob outputs over ~12 lines). | | |
| 892 | 892 | | `ctrl+1` ... `ctrl+9` | Switch to the model bound to that quick-model slot (configured in `/settings` -> model shortcuts). No-op while a turn is running. | | |
| 893 | 893 | | `@` | Open the file picker. Browse files and directories in the working directory. | | |
| 894 | 894 | ||
| @@ -1055,7 +1055,7 @@ packages/agent/extproto/ extension wire-format types | |||
| 1055 | 1055 | packages/agent/modes/ interactive tui, print, json, dialogs | |
| 1056 | 1056 | packages/agent/modes/bot/ protocol-agnostic bot runner (BotAdapter interface) | |
| 1057 | 1057 | packages/agent/modes/telegram/ telegram adapter, api client, daemon | |
| 1058 | - | packages/agent/tools/ read, write, edit, bash, sandbox | |
| 1058 | + | packages/agent/tools/ read, write, edit, bash, glob, sandbox | |
| 1059 | 1059 | packages/agent/skills/ skill discovery, frontmatter parser, skill tool | |
| 1060 | 1060 | packages/agent/swarm/ background subagent runtime | |
| 1061 | 1061 | packages/agent/sdk/ public Go SDK for embedding zot in-process (package sdk) | |
| @@ -19,7 +19,7 @@ Yet another coding agent harness, lightweight and written (vibe-slopped) in go. | |||||
| 19 | 19 | ||||
| 20 | - one static binary. | 20 | - one static binary. | ||
| 21 | - built-in providers for Anthropic, OpenAI/Codex/Responses, Kimi, DeepSeek, Google Gemini/Vertex, GitHub Copilot, Bedrock, Azure OpenAI, OpenRouter, Groq, Cerebras, xAI, Together, Hugging Face, Mistral, Moonshot, Z.AI, Xiaomi, MiniMax, Fireworks, Vercel AI Gateway, OpenCode, Cloudflare AI, and Ollama/local models. | 21 | - built-in providers for Anthropic, OpenAI/Codex/Responses, Kimi, DeepSeek, Google Gemini/Vertex, GitHub Copilot, Bedrock, Azure OpenAI, OpenRouter, Groq, Cerebras, xAI, Together, Hugging Face, Mistral, Moonshot, Z.AI, Xiaomi, MiniMax, Fireworks, Vercel AI Gateway, OpenCode, Cloudflare AI, and Ollama/local models. | ||
| 22 | - | - four tools (read, write, edit, bash). | 22 | + | - built-in tools (read, write, edit, bash, glob). |
| 23 | - three run modes (interactive tui, print, json). | 23 | - three run modes (interactive tui, print, json). | ||
| 24 | - built-in telegram bot. | 24 | - built-in telegram bot. | ||
| 25 | - extensions in any language via subprocess + json-rpc. None installed by default; opt in with `zot ext install` or `zot --ext`. See [docs/extensions.md](docs/extensions.md). | 25 | - extensions in any language via subprocess + json-rpc. None installed by default; opt in with `zot ext install` or `zot --ext`. See [docs/extensions.md](docs/extensions.md). | ||
| @@ -888,7 +888,7 @@ Slash commands also work while the agent is busy. Non-destructive ones (`/help`, | |||||
| 888 | | `ctrl+d` | Exit on empty input. | | 888 | | `ctrl+d` | Exit on empty input. | | ||
| 889 | | `ctrl+l` | Redraw the screen. | | 889 | | `ctrl+l` | Redraw the screen. | | ||
| 890 | | `ctrl+v` | Paste clipboard text into the focused chat, side chat, dialog, filter, or credential input. On Linux this uses `wl-paste`, `xclip`, or `xsel`; terminal-native bracketed paste remains available without those commands. In the main chat on macOS, image-only clipboard content is saved as a temporary PNG and attached to the next prompt. | | 890 | | `ctrl+v` | Paste clipboard text into the focused chat, side chat, dialog, filter, or credential input. On Linux this uses `wl-paste`, `xclip`, or `xsel`; terminal-native bracketed paste remains available without those commands. In the main chat on macOS, image-only clipboard content is saved as a temporary PNG and attached to the next prompt. | | ||
| 891 | - | | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash outputs over ~12 lines). | | 891 | + | | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash, glob outputs over ~12 lines). | |
| 892 | | `ctrl+1` ... `ctrl+9` | Switch to the model bound to that quick-model slot (configured in `/settings` -> model shortcuts). No-op while a turn is running. | | 892 | | `ctrl+1` ... `ctrl+9` | Switch to the model bound to that quick-model slot (configured in `/settings` -> model shortcuts). No-op while a turn is running. | | ||
| 893 | | `@` | Open the file picker. Browse files and directories in the working directory. | | 893 | | `@` | Open the file picker. Browse files and directories in the working directory. | | ||
| 894 | 894 | ||||
| @@ -1055,7 +1055,7 @@ packages/agent/extproto/ extension wire-format types | |||||
| 1055 | packages/agent/modes/ interactive tui, print, json, dialogs | 1055 | packages/agent/modes/ interactive tui, print, json, dialogs | ||
| 1056 | packages/agent/modes/bot/ protocol-agnostic bot runner (BotAdapter interface) | 1056 | packages/agent/modes/bot/ protocol-agnostic bot runner (BotAdapter interface) | ||
| 1057 | packages/agent/modes/telegram/ telegram adapter, api client, daemon | 1057 | packages/agent/modes/telegram/ telegram adapter, api client, daemon | ||
| 1058 | - | packages/agent/tools/ read, write, edit, bash, sandbox | 1058 | + | packages/agent/tools/ read, write, edit, bash, glob, sandbox |
| 1059 | packages/agent/skills/ skill discovery, frontmatter parser, skill tool | 1059 | packages/agent/skills/ skill discovery, frontmatter parser, skill tool | ||
| 1060 | packages/agent/swarm/ background subagent runtime | 1060 | packages/agent/swarm/ background subagent runtime | ||
| 1061 | packages/agent/sdk/ public Go SDK for embedding zot in-process (package sdk) | 1061 | packages/agent/sdk/ public Go SDK for embedding zot in-process (package sdk) | ||
M packages/agent/build.go +4 -1
| @@ -970,6 +970,8 @@ func (r *Resolved) UseSandbox(s *tools.Sandbox) { | |||
| 970 | 970 | v.Sandbox = s | |
| 971 | 971 | case *tools.BashTool: | |
| 972 | 972 | v.Sandbox = s | |
| 973 | + | case *tools.GlobTool: | |
| 974 | + | v.Sandbox = s | |
| 973 | 975 | } | |
| 974 | 976 | _ = name | |
| 975 | 977 | } | |
| @@ -994,6 +996,7 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox) core.Regis | |||
| 994 | 996 | "write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox}, | |
| 995 | 997 | "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox}, | |
| 996 | 998 | "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox}, | |
| 999 | + | "glob": &tools.GlobTool{CWD: cwd, Sandbox: sandbox}, | |
| 997 | 1000 | } | |
| 998 | 1001 | reg := core.Registry{} | |
| 999 | 1002 | if len(args.Tools) == 0 { | |
| @@ -1011,7 +1014,7 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox) core.Regis | |||
| 1011 | 1014 | } | |
| 1012 | 1015 | ||
| 1013 | 1016 | func toolSummaries(reg core.Registry, args Args) []ToolSummary { | |
| 1014 | - | order := []string{"read", "write", "edit", "bash"} | |
| 1017 | + | order := []string{"read", "write", "edit", "bash", "glob"} | |
| 1015 | 1018 | var out []ToolSummary | |
| 1016 | 1019 | for _, name := range order { | |
| 1017 | 1020 | if t, ok := reg[name]; ok { | |
| @@ -970,6 +970,8 @@ func (r *Resolved) UseSandbox(s *tools.Sandbox) { | |||||
| 970 | v.Sandbox = s | 970 | v.Sandbox = s | ||
| 971 | case *tools.BashTool: | 971 | case *tools.BashTool: | ||
| 972 | v.Sandbox = s | 972 | v.Sandbox = s | ||
| 973 | + | case *tools.GlobTool: | |||
| 974 | + | v.Sandbox = s | |||
| 973 | } | 975 | } | ||
| 974 | _ = name | 976 | _ = name | ||
| 975 | } | 977 | } | ||
| @@ -994,6 +996,7 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox) core.Regis | |||||
| 994 | "write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox}, | 996 | "write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox}, | ||
| 995 | "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox}, | 997 | "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox}, | ||
| 996 | "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox}, | 998 | "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox}, | ||
| 999 | + | "glob": &tools.GlobTool{CWD: cwd, Sandbox: sandbox}, | |||
| 997 | } | 1000 | } | ||
| 998 | reg := core.Registry{} | 1001 | reg := core.Registry{} | ||
| 999 | if len(args.Tools) == 0 { | 1002 | if len(args.Tools) == 0 { | ||
| @@ -1011,7 +1014,7 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox) core.Regis | |||||
| 1011 | } | 1014 | } | ||
| 1012 | 1015 | ||||
| 1013 | func toolSummaries(reg core.Registry, args Args) []ToolSummary { | 1016 | func toolSummaries(reg core.Registry, args Args) []ToolSummary { | ||
| 1014 | - | order := []string{"read", "write", "edit", "bash"} | 1017 | + | order := []string{"read", "write", "edit", "bash", "glob"} |
| 1015 | var out []ToolSummary | 1018 | var out []ToolSummary | ||
| 1016 | for _, name := range order { | 1019 | for _, name := range order { | ||
| 1017 | if t, ok := reg[name]; ok { | 1020 | if t, ok := reg[name]; ok { | ||
M packages/agent/build_test.go +35 -0
| @@ -605,3 +605,38 @@ func assertDefaultTransportStillSecure(t *testing.T) { | |||
| 605 | 605 | t.Fatal("http.DefaultTransport must not be made insecure") | |
| 606 | 606 | } | |
| 607 | 607 | } | |
| 608 | + | ||
| 609 | + | func TestBuildToolRegistryIncludesGlob(t *testing.T) { | |
| 610 | + | cwd := t.TempDir() | |
| 611 | + | reg := buildToolRegistry(Args{}, cwd, nil) | |
| 612 | + | expected := []string{"read", "write", "edit", "bash", "glob"} | |
| 613 | + | for _, name := range expected { | |
| 614 | + | if _, ok := reg[name]; !ok { | |
| 615 | + | t.Errorf("expected tool %q in registry, missing", name) | |
| 616 | + | } | |
| 617 | + | } | |
| 618 | + | ||
| 619 | + | summaries := toolSummaries(reg, Args{}) | |
| 620 | + | var summaryNames []string | |
| 621 | + | for _, s := range summaries { | |
| 622 | + | summaryNames = append(summaryNames, s.Name) | |
| 623 | + | } | |
| 624 | + | for _, name := range expected { | |
| 625 | + | found := false | |
| 626 | + | for _, sn := range summaryNames { | |
| 627 | + | if sn == name { | |
| 628 | + | found = true | |
| 629 | + | break | |
| 630 | + | } | |
| 631 | + | } | |
| 632 | + | if !found { | |
| 633 | + | t.Errorf("expected tool %q in toolSummaries, missing", name) | |
| 634 | + | } | |
| 635 | + | } | |
| 636 | + | ||
| 637 | + | // Filtered tools | |
| 638 | + | filtered := buildToolRegistry(Args{Tools: []string{"glob", "read"}}, cwd, nil) | |
| 639 | + | if len(filtered) != 2 || filtered["glob"] == nil || filtered["read"] == nil { | |
| 640 | + | t.Errorf("expected filtered registry with glob and read, got: %#v", filtered) | |
| 641 | + | } | |
| 642 | + | } | |
| @@ -605,3 +605,38 @@ func assertDefaultTransportStillSecure(t *testing.T) { | |||||
| 605 | t.Fatal("http.DefaultTransport must not be made insecure") | 605 | t.Fatal("http.DefaultTransport must not be made insecure") | ||
| 606 | } | 606 | } | ||
| 607 | } | 607 | } | ||
| 608 | + | ||||
| 609 | + | func TestBuildToolRegistryIncludesGlob(t *testing.T) { | |||
| 610 | + | cwd := t.TempDir() | |||
| 611 | + | reg := buildToolRegistry(Args{}, cwd, nil) | |||
| 612 | + | expected := []string{"read", "write", "edit", "bash", "glob"} | |||
| 613 | + | for _, name := range expected { | |||
| 614 | + | if _, ok := reg[name]; !ok { | |||
| 615 | + | t.Errorf("expected tool %q in registry, missing", name) | |||
| 616 | + | } | |||
| 617 | + | } | |||
| 618 | + | ||||
| 619 | + | summaries := toolSummaries(reg, Args{}) | |||
| 620 | + | var summaryNames []string | |||
| 621 | + | for _, s := range summaries { | |||
| 622 | + | summaryNames = append(summaryNames, s.Name) | |||
| 623 | + | } | |||
| 624 | + | for _, name := range expected { | |||
| 625 | + | found := false | |||
| 626 | + | for _, sn := range summaryNames { | |||
| 627 | + | if sn == name { | |||
| 628 | + | found = true | |||
| 629 | + | break | |||
| 630 | + | } | |||
| 631 | + | } | |||
| 632 | + | if !found { | |||
| 633 | + | t.Errorf("expected tool %q in toolSummaries, missing", name) | |||
| 634 | + | } | |||
| 635 | + | } | |||
| 636 | + | ||||
| 637 | + | // Filtered tools | |||
| 638 | + | filtered := buildToolRegistry(Args{Tools: []string{"glob", "read"}}, cwd, nil) | |||
| 639 | + | if len(filtered) != 2 || filtered["glob"] == nil || filtered["read"] == nil { | |||
| 640 | + | t.Errorf("expected filtered registry with glob and read, got: %#v", filtered) | |||
| 641 | + | } | |||
| 642 | + | } | |||
M packages/agent/ext/ext.go +1 -1
| @@ -390,7 +390,7 @@ func (e *Extension) Command(name, description string, fn CommandHandler) { | |||
| 390 | 390 | // into the agent's registry once the extension's ready frame fires. | |
| 391 | 391 | // | |
| 392 | 392 | // Naming conflicts with built-in tools (read, write, edit, bash, | |
| 393 | - | // skill) are silently shadowed by the built-in. | |
| 393 | + | // glob, skill) are silently shadowed by the built-in. | |
| 394 | 394 | func (e *Extension) Tool(name, description string, schema json.RawMessage, fn ToolHandler) { | |
| 395 | 395 | e.registerTool(name, description, schema, false, fn) | |
| 396 | 396 | } | |
| @@ -390,7 +390,7 @@ func (e *Extension) Command(name, description string, fn CommandHandler) { | |||||
| 390 | // into the agent's registry once the extension's ready frame fires. | 390 | // into the agent's registry once the extension's ready frame fires. | ||
| 391 | // | 391 | // | ||
| 392 | // Naming conflicts with built-in tools (read, write, edit, bash, | 392 | // Naming conflicts with built-in tools (read, write, edit, bash, | ||
| 393 | - | // skill) are silently shadowed by the built-in. | 393 | + | // glob, skill) are silently shadowed by the built-in. |
| 394 | func (e *Extension) Tool(name, description string, schema json.RawMessage, fn ToolHandler) { | 394 | func (e *Extension) Tool(name, description string, schema json.RawMessage, fn ToolHandler) { | ||
| 395 | e.registerTool(name, description, schema, false, fn) | 395 | e.registerTool(name, description, schema, false, fn) | ||
| 396 | } | 396 | } | ||
M packages/agent/extensions/intercept_test.go +44 -37
| @@ -23,43 +23,50 @@ func TestInterceptAllThreeEvents(t *testing.T) { | |||
| 23 | 23 | } | |
| 24 | 24 | ||
| 25 | 25 | extDir := t.TempDir() | |
| 26 | - | script := `#!/bin/bash | |
| 27 | - | emit() { printf '%s\n' "$1"; } | |
| 28 | - | emit '{"type":"hello","name":"itest","version":"0.1.0","capabilities":["events"]}' | |
| 29 | - | emit '{"type":"subscribe","events":[],"intercept":["tool_call","turn_start","assistant_message"]}' | |
| 30 | - | emit '{"type":"ready"}' | |
| 31 | - | while IFS= read -r line; do | |
| 32 | - | t=$(printf '%s' "$line" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("type",""))') | |
| 33 | - | id=$(printf '%s' "$line" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("id",""))') | |
| 34 | - | ev=$(printf '%s' "$line" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("event",""))') | |
| 35 | - | if [[ "$t" == "shutdown" ]]; then emit '{"type":"shutdown_ack"}'; exit 0; fi | |
| 36 | - | if [[ "$t" != "event_intercept" ]]; then continue; fi | |
| 37 | - | case "$ev" in | |
| 38 | - | tool_call) | |
| 39 | - | name=$(printf '%s' "$line" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("tool_name",""))') | |
| 40 | - | args=$(printf '%s' "$line" | python3 -c 'import sys,json;print(json.dumps(json.load(sys.stdin).get("tool_args",{})))') | |
| 41 | - | cmd=$(printf '%s' "$args" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("command",""))') | |
| 42 | - | if [[ "$name" == "bash" && "$cmd" == *"rm -rf"* ]]; then | |
| 43 | - | emit "{\"type\":\"event_intercept_response\",\"id\":\"$id\",\"block\":true,\"reason\":\"refused: rm -rf\"}" | |
| 44 | - | elif [[ "$name" == "bash" && -n "$cmd" ]]; then | |
| 45 | - | new=$(python3 -c "import json,sys;print(json.dumps({'command':'echo GUARDED: '+sys.argv[1]}))" "$cmd") | |
| 46 | - | emit "{\"type\":\"event_intercept_response\",\"id\":\"$id\",\"modified_args\":$new}" | |
| 47 | - | else | |
| 48 | - | emit "{\"type\":\"event_intercept_response\",\"id\":\"$id\"}" | |
| 49 | - | fi ;; | |
| 50 | - | turn_start) | |
| 51 | - | emit "{\"type\":\"event_intercept_response\",\"id\":\"$id\"}" ;; | |
| 52 | - | assistant_message) | |
| 53 | - | text=$(printf '%s' "$line" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("text",""))') | |
| 54 | - | if [[ "$text" == *"SECRET"* ]]; then | |
| 55 | - | new=$(python3 -c "import sys;print(sys.argv[1].replace('SECRET','[redacted]'))" "$text") | |
| 56 | - | rt=$(python3 -c 'import json,sys;print(json.dumps(sys.argv[1]))' "$new") | |
| 57 | - | emit "{\"type\":\"event_intercept_response\",\"id\":\"$id\",\"replace_text\":$rt}" | |
| 58 | - | else | |
| 59 | - | emit "{\"type\":\"event_intercept_response\",\"id\":\"$id\"}" | |
| 60 | - | fi ;; | |
| 61 | - | esac | |
| 62 | - | done | |
| 26 | + | script := `#!/usr/bin/env python3 | |
| 27 | + | import json, sys | |
| 28 | + | ||
| 29 | + | def emit(d): | |
| 30 | + | print(json.dumps(d), flush=True) | |
| 31 | + | ||
| 32 | + | emit({"type":"hello","name":"itest","version":"0.1.0","capabilities":["events"]}) | |
| 33 | + | emit({"type":"subscribe","events":[],"intercept":["tool_call","turn_start","assistant_message"]}) | |
| 34 | + | emit({"type":"ready"}) | |
| 35 | + | ||
| 36 | + | for line in sys.stdin: | |
| 37 | + | line = line.strip() | |
| 38 | + | if not line: | |
| 39 | + | continue | |
| 40 | + | try: | |
| 41 | + | msg = json.loads(line) | |
| 42 | + | except Exception: | |
| 43 | + | continue | |
| 44 | + | t = msg.get("type", "") | |
| 45 | + | id_ = msg.get("id", "") | |
| 46 | + | ev = msg.get("event", "") | |
| 47 | + | if t == "shutdown": | |
| 48 | + | emit({"type": "shutdown_ack"}) | |
| 49 | + | sys.exit(0) | |
| 50 | + | if t != "event_intercept": | |
| 51 | + | continue | |
| 52 | + | if ev == "tool_call": | |
| 53 | + | name = msg.get("tool_name", "") | |
| 54 | + | args = msg.get("tool_args", {}) | |
| 55 | + | cmd = args.get("command", "") if isinstance(args, dict) else "" | |
| 56 | + | if name == "bash" and "rm -rf" in cmd: | |
| 57 | + | emit({"type": "event_intercept_response", "id": id_, "block": True, "reason": "refused: rm -rf"}) | |
| 58 | + | elif name == "bash" and cmd: | |
| 59 | + | emit({"type": "event_intercept_response", "id": id_, "modified_args": {"command": "echo GUARDED: " + cmd}}) | |
| 60 | + | else: | |
| 61 | + | emit({"type": "event_intercept_response", "id": id_}) | |
| 62 | + | elif ev == "turn_start": | |
| 63 | + | emit({"type": "event_intercept_response", "id": id_}) | |
| 64 | + | elif ev == "assistant_message": | |
| 65 | + | text = msg.get("text", "") | |
| 66 | + | if "SECRET" in text: | |
| 67 | + | emit({"type": "event_intercept_response", "id": id_, "replace_text": text.replace("SECRET", "[redacted]")}) | |
| 68 | + | else: | |
| 69 | + | emit({"type": "event_intercept_response", "id": id_}) | |
| 63 | 70 | ` | |
| 64 | 71 | if err := os.WriteFile(filepath.Join(extDir, "ext.sh"), []byte(script), 0o755); err != nil { | |
| 65 | 72 | t.Fatal(err) | |
| @@ -23,43 +23,50 @@ func TestInterceptAllThreeEvents(t *testing.T) { | |||||
| 23 | } | 23 | } | ||
| 24 | 24 | ||||
| 25 | extDir := t.TempDir() | 25 | extDir := t.TempDir() | ||
| 26 | - | script := `#!/bin/bash | 26 | + | script := `#!/usr/bin/env python3 |
| 27 | - | emit() { printf '%s\n' "$1"; } | 27 | + | import json, sys |
| 28 | - | emit '{"type":"hello","name":"itest","version":"0.1.0","capabilities":["events"]}' | 28 | + | |
| 29 | - | emit '{"type":"subscribe","events":[],"intercept":["tool_call","turn_start","assistant_message"]}' | 29 | + | def emit(d): |
| 30 | - | emit '{"type":"ready"}' | 30 | + | print(json.dumps(d), flush=True) |
| 31 | - | while IFS= read -r line; do | 31 | + | |
| 32 | - | t=$(printf '%s' "$line" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("type",""))') | 32 | + | emit({"type":"hello","name":"itest","version":"0.1.0","capabilities":["events"]}) |
| 33 | - | id=$(printf '%s' "$line" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("id",""))') | 33 | + | emit({"type":"subscribe","events":[],"intercept":["tool_call","turn_start","assistant_message"]}) |
| 34 | - | ev=$(printf '%s' "$line" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("event",""))') | 34 | + | emit({"type":"ready"}) |
| 35 | - | if [[ "$t" == "shutdown" ]]; then emit '{"type":"shutdown_ack"}'; exit 0; fi | 35 | + | |
| 36 | - | if [[ "$t" != "event_intercept" ]]; then continue; fi | 36 | + | for line in sys.stdin: |
| 37 | - | case "$ev" in | 37 | + | line = line.strip() |
| 38 | - | tool_call) | 38 | + | if not line: |
| 39 | - | name=$(printf '%s' "$line" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("tool_name",""))') | 39 | + | continue |
| 40 | - | args=$(printf '%s' "$line" | python3 -c 'import sys,json;print(json.dumps(json.load(sys.stdin).get("tool_args",{})))') | 40 | + | try: |
| 41 | - | cmd=$(printf '%s' "$args" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("command",""))') | 41 | + | msg = json.loads(line) |
| 42 | - | if [[ "$name" == "bash" && "$cmd" == *"rm -rf"* ]]; then | 42 | + | except Exception: |
| 43 | - | emit "{\"type\":\"event_intercept_response\",\"id\":\"$id\",\"block\":true,\"reason\":\"refused: rm -rf\"}" | 43 | + | continue |
| 44 | - | elif [[ "$name" == "bash" && -n "$cmd" ]]; then | 44 | + | t = msg.get("type", "") |
| 45 | - | new=$(python3 -c "import json,sys;print(json.dumps({'command':'echo GUARDED: '+sys.argv[1]}))" "$cmd") | 45 | + | id_ = msg.get("id", "") |
| 46 | - | emit "{\"type\":\"event_intercept_response\",\"id\":\"$id\",\"modified_args\":$new}" | 46 | + | ev = msg.get("event", "") |
| 47 | - | else | 47 | + | if t == "shutdown": |
| 48 | - | emit "{\"type\":\"event_intercept_response\",\"id\":\"$id\"}" | 48 | + | emit({"type": "shutdown_ack"}) |
| 49 | - | fi ;; | 49 | + | sys.exit(0) |
| 50 | - | turn_start) | 50 | + | if t != "event_intercept": |
| 51 | - | emit "{\"type\":\"event_intercept_response\",\"id\":\"$id\"}" ;; | 51 | + | continue |
| 52 | - | assistant_message) | 52 | + | if ev == "tool_call": |
| 53 | - | text=$(printf '%s' "$line" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("text",""))') | 53 | + | name = msg.get("tool_name", "") |
| 54 | - | if [[ "$text" == *"SECRET"* ]]; then | 54 | + | args = msg.get("tool_args", {}) |
| 55 | - | new=$(python3 -c "import sys;print(sys.argv[1].replace('SECRET','[redacted]'))" "$text") | 55 | + | cmd = args.get("command", "") if isinstance(args, dict) else "" |
| 56 | - | rt=$(python3 -c 'import json,sys;print(json.dumps(sys.argv[1]))' "$new") | 56 | + | if name == "bash" and "rm -rf" in cmd: |
| 57 | - | emit "{\"type\":\"event_intercept_response\",\"id\":\"$id\",\"replace_text\":$rt}" | 57 | + | emit({"type": "event_intercept_response", "id": id_, "block": True, "reason": "refused: rm -rf"}) |
| 58 | - | else | 58 | + | elif name == "bash" and cmd: |
| 59 | - | emit "{\"type\":\"event_intercept_response\",\"id\":\"$id\"}" | 59 | + | emit({"type": "event_intercept_response", "id": id_, "modified_args": {"command": "echo GUARDED: " + cmd}}) |
| 60 | - | fi ;; | 60 | + | else: |
| 61 | - | esac | 61 | + | emit({"type": "event_intercept_response", "id": id_}) |
| 62 | - | done | 62 | + | elif ev == "turn_start": |
| 63 | + | emit({"type": "event_intercept_response", "id": id_}) | |||
| 64 | + | elif ev == "assistant_message": | |||
| 65 | + | text = msg.get("text", "") | |||
| 66 | + | if "SECRET" in text: | |||
| 67 | + | emit({"type": "event_intercept_response", "id": id_, "replace_text": text.replace("SECRET", "[redacted]")}) | |||
| 68 | + | else: | |||
| 69 | + | emit({"type": "event_intercept_response", "id": id_}) | |||
| 63 | ` | 70 | ` | ||
| 64 | if err := os.WriteFile(filepath.Join(extDir, "ext.sh"), []byte(script), 0o755); err != nil { | 71 | if err := os.WriteFile(filepath.Join(extDir, "ext.sh"), []byte(script), 0o755); err != nil { | ||
| 65 | t.Fatal(err) | 72 | t.Fatal(err) | ||
M packages/agent/sdk/sdk.go +2 -2
| @@ -79,8 +79,8 @@ type Config struct { | |||
| 79 | 79 | // BaseURL overrides the provider base url (for tests / proxies). | |
| 80 | 80 | BaseURL string | |
| 81 | 81 | ||
| 82 | - | // Tools is the list of tools to enable. Nil/empty = all four | |
| 83 | - | // (read, write, edit, bash). Pass an empty-but-non-nil slice | |
| 82 | + | // Tools is the list of tools to enable. Nil/empty = all built-ins | |
| 83 | + | // (read, write, edit, bash, glob). Pass an empty-but-non-nil slice | |
| 84 | 84 | // (e.g. []string{}) plus NoTools=true to disable everything. | |
| 85 | 85 | Tools []string | |
| 86 | 86 | ||
| @@ -79,8 +79,8 @@ type Config struct { | |||||
| 79 | // BaseURL overrides the provider base url (for tests / proxies). | 79 | // BaseURL overrides the provider base url (for tests / proxies). | ||
| 80 | BaseURL string | 80 | BaseURL string | ||
| 81 | 81 | ||||
| 82 | - | // Tools is the list of tools to enable. Nil/empty = all four | 82 | + | // Tools is the list of tools to enable. Nil/empty = all built-ins |
| 83 | - | // (read, write, edit, bash). Pass an empty-but-non-nil slice | 83 | + | // (read, write, edit, bash, glob). Pass an empty-but-non-nil slice |
| 84 | // (e.g. []string{}) plus NoTools=true to disable everything. | 84 | // (e.g. []string{}) plus NoTools=true to disable everything. | ||
| 85 | Tools []string | 85 | Tools []string | ||
| 86 | 86 | ||||
A packages/agent/tools/glob.go +355 -0
| @@ -0,0 +1,355 @@ | |||
| 1 | + | package tools | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "fmt" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 9 | + | "regexp" | |
| 10 | + | "sort" | |
| 11 | + | "strings" | |
| 12 | + | ||
| 13 | + | "github.com/patriceckhart/zot/packages/core" | |
| 14 | + | "github.com/patriceckhart/zot/packages/ignore" | |
| 15 | + | "github.com/patriceckhart/zot/packages/provider" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | const ( | |
| 19 | + | maxGlobMatches = 500 | |
| 20 | + | maxGlobDepth = 24 | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | // GlobTool finds files matching a pattern within a directory tree. | |
| 24 | + | type GlobTool struct { | |
| 25 | + | CWD string | |
| 26 | + | Sandbox *Sandbox | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | type globArgs struct { | |
| 30 | + | Pattern string `json:"pattern"` | |
| 31 | + | Path string `json:"path,omitempty"` | |
| 32 | + | Hidden bool `json:"hidden,omitempty"` | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | const globSchema = `{"type":"object","properties":{"pattern":{"type":"string","description":"Glob pattern to match files against (e.g. \"**/*.go\", \"*.json\", \"src/**/*.ts\")"},"path":{"type":"string","description":"Directory to search within, relative to CWD (defaults to \".\")"},"hidden":{"type":"boolean","description":"Whether to include hidden files and directories (default false)"}},"required":["pattern"]}` | |
| 36 | + | ||
| 37 | + | func (t *GlobTool) Name() string { return "glob" } | |
| 38 | + | func (t *GlobTool) Description() string { | |
| 39 | + | return "Find files matching a glob pattern (e.g. \"**/*.go\", \"*.json\", \"src/**/*.ts\"). Honors .gitignore rules." | |
| 40 | + | } | |
| 41 | + | func (t *GlobTool) Schema() json.RawMessage { return json.RawMessage(globSchema) } | |
| 42 | + | ||
| 43 | + | func (t *GlobTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) { | |
| 44 | + | var a globArgs | |
| 45 | + | if err := json.Unmarshal(raw, &a); err != nil { | |
| 46 | + | return core.ToolResult{}, fmt.Errorf("invalid args: %w", err) | |
| 47 | + | } | |
| 48 | + | if strings.TrimSpace(a.Pattern) == "" { | |
| 49 | + | return core.ToolResult{}, fmt.Errorf("pattern is required") | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | re, hasSlash, err := compileGlob(a.Pattern) | |
| 53 | + | if err != nil { | |
| 54 | + | return core.ToolResult{}, err | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | givenSearchDir := resolvePath(t.CWD, a.Path) | |
| 58 | + | if err := t.Sandbox.CheckReadPath(givenSearchDir); err != nil { | |
| 59 | + | return core.ToolResult{}, err | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | info, err := os.Stat(givenSearchDir) | |
| 63 | + | if err != nil { | |
| 64 | + | return core.ToolResult{}, err | |
| 65 | + | } | |
| 66 | + | if !info.IsDir() { | |
| 67 | + | shown := t.Sandbox.DisplayPath(givenSearchDir, a.Path) | |
| 68 | + | return core.ToolResult{}, fmt.Errorf("%s is not a directory", shown) | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | // WalkDir does not follow a symlink used as its root. Resolve the explicit | |
| 72 | + | // search directory so a path that Stat identified as a directory is | |
| 73 | + | // actually traversed, then re-check the resolved target before reading it. | |
| 74 | + | searchDir, err := filepath.EvalSymlinks(givenSearchDir) | |
| 75 | + | if err != nil { | |
| 76 | + | return core.ToolResult{}, err | |
| 77 | + | } | |
| 78 | + | if err := t.Sandbox.CheckReadPath(searchDir); err != nil { | |
| 79 | + | return core.ToolResult{}, err | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | stack, ignoreRoot, searchIgnored := t.globIgnoreStack(searchDir) | |
| 83 | + | rootSep := strings.Count(searchDir, string(os.PathSeparator)) | |
| 84 | + | var pushed []string | |
| 85 | + | var matches []string | |
| 86 | + | truncated := false | |
| 87 | + | ||
| 88 | + | var walkErr error | |
| 89 | + | if !searchIgnored { | |
| 90 | + | walkErr = filepath.WalkDir(searchDir, func(path string, d os.DirEntry, err error) error { | |
| 91 | + | if ctxErr := ctx.Err(); ctxErr != nil { | |
| 92 | + | return ctxErr | |
| 93 | + | } | |
| 94 | + | if err != nil { | |
| 95 | + | if d != nil && d.IsDir() { | |
| 96 | + | return filepath.SkipDir | |
| 97 | + | } | |
| 98 | + | return nil | |
| 99 | + | } | |
| 100 | + | if path == searchDir { | |
| 101 | + | return nil | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | rel, relErr := filepath.Rel(searchDir, path) | |
| 105 | + | if relErr != nil { | |
| 106 | + | return nil | |
| 107 | + | } | |
| 108 | + | relSlash := filepath.ToSlash(rel) | |
| 109 | + | ||
| 110 | + | // Check hidden files/directories unless explicitly requested. | |
| 111 | + | // .git is always skipped. | |
| 112 | + | if !a.Hidden { | |
| 113 | + | if strings.HasPrefix(d.Name(), ".") { | |
| 114 | + | if d.IsDir() { | |
| 115 | + | return filepath.SkipDir | |
| 116 | + | } | |
| 117 | + | return nil | |
| 118 | + | } | |
| 119 | + | } else if d.IsDir() && d.Name() == ".git" { | |
| 120 | + | return filepath.SkipDir | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | ignoreRel, ignoreRelErr := filepath.Rel(ignoreRoot, path) | |
| 124 | + | if ignoreRelErr != nil { | |
| 125 | + | return nil | |
| 126 | + | } | |
| 127 | + | ignoreRelSlash := filepath.ToSlash(ignoreRel) | |
| 128 | + | ||
| 129 | + | // .gitignore filtering: pop stack frames for directories no longer in scope. | |
| 130 | + | dirSlash := relSlash | |
| 131 | + | if !d.IsDir() { | |
| 132 | + | if idx := strings.LastIndex(relSlash, "/"); idx >= 0 { | |
| 133 | + | dirSlash = relSlash[:idx] | |
| 134 | + | } else { | |
| 135 | + | dirSlash = "" | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | for len(pushed) > 0 { | |
| 139 | + | top := pushed[len(pushed)-1] | |
| 140 | + | if top == dirSlash || strings.HasPrefix(dirSlash, top+"/") { | |
| 141 | + | break | |
| 142 | + | } | |
| 143 | + | pushed = pushed[:len(pushed)-1] | |
| 144 | + | stack.Pop() | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | if stack.Match(ignoreRelSlash, d.IsDir()) { | |
| 148 | + | if d.IsDir() { | |
| 149 | + | return filepath.SkipDir | |
| 150 | + | } | |
| 151 | + | return nil | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | if d.IsDir() { | |
| 155 | + | if strings.Count(path, string(os.PathSeparator))-rootSep >= maxGlobDepth { | |
| 156 | + | return filepath.SkipDir | |
| 157 | + | } | |
| 158 | + | stack.Push(path, ignoreRel) | |
| 159 | + | pushed = append(pushed, relSlash) | |
| 160 | + | return nil | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | // Check if file matches the glob pattern. | |
| 164 | + | matched := false | |
| 165 | + | if hasSlash { | |
| 166 | + | matched = re.MatchString(relSlash) | |
| 167 | + | } else { | |
| 168 | + | matched = re.MatchString(d.Name()) | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | if matched { | |
| 172 | + | matches = append(matches, t.globDisplayPath(givenSearchDir, a.Path, relSlash)) | |
| 173 | + | if len(matches) >= maxGlobMatches { | |
| 174 | + | truncated = true | |
| 175 | + | return filepath.SkipAll | |
| 176 | + | } | |
| 177 | + | } | |
| 178 | + | return nil | |
| 179 | + | }) | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | if walkErr != nil && walkErr != filepath.SkipAll { | |
| 183 | + | return core.ToolResult{}, walkErr | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | sort.Strings(matches) | |
| 187 | + | ||
| 188 | + | var text string | |
| 189 | + | if len(matches) == 0 { | |
| 190 | + | text = "No files matched the pattern." | |
| 191 | + | } else { | |
| 192 | + | text = strings.Join(matches, "\n") | |
| 193 | + | if truncated { | |
| 194 | + | text += fmt.Sprintf("\n\n(Truncated: showing first %d matches)", maxGlobMatches) | |
| 195 | + | } | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | return core.ToolResult{ | |
| 199 | + | Content: []provider.Content{provider.TextBlock{Text: text}}, | |
| 200 | + | Details: map[string]any{ | |
| 201 | + | "matches": len(matches), | |
| 202 | + | "truncated": truncated, | |
| 203 | + | "pattern": a.Pattern, | |
| 204 | + | }, | |
| 205 | + | }, nil | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | // globIgnoreStack builds an ignore stack rooted at CWD when the search path is | |
| 209 | + | // below it, preloading the .gitignore files between CWD and the search root. | |
| 210 | + | // This preserves inherited rules when the caller narrows a search with path. | |
| 211 | + | func (t *GlobTool) globIgnoreStack(searchDir string) (*ignore.Stack, string, bool) { | |
| 212 | + | cwd := resolvePath(t.CWD, ".") | |
| 213 | + | cwd, err := filepath.EvalSymlinks(cwd) | |
| 214 | + | if err != nil || t.Sandbox.CheckReadPath(cwd) != nil { | |
| 215 | + | return ignore.NewStack(searchDir), searchDir, false | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | rel, err := filepath.Rel(cwd, searchDir) | |
| 219 | + | if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) || filepath.IsAbs(rel) { | |
| 220 | + | return ignore.NewStack(searchDir), searchDir, false | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | stack := ignore.NewStack(cwd) | |
| 224 | + | if rel == "." { | |
| 225 | + | return stack, cwd, false | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | current := cwd | |
| 229 | + | var relParts []string | |
| 230 | + | for _, part := range strings.Split(rel, string(os.PathSeparator)) { | |
| 231 | + | current = filepath.Join(current, part) | |
| 232 | + | relParts = append(relParts, part) | |
| 233 | + | currentRel := filepath.Join(relParts...) | |
| 234 | + | if stack.Match(filepath.ToSlash(currentRel), true) { | |
| 235 | + | return stack, cwd, true | |
| 236 | + | } | |
| 237 | + | stack.Push(current, currentRel) | |
| 238 | + | } | |
| 239 | + | return stack, cwd, false | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | func (t *GlobTool) globDisplayPath(searchDir, given, relSlash string) string { | |
| 243 | + | if given == "" || given == "." { | |
| 244 | + | return relSlash | |
| 245 | + | } | |
| 246 | + | prefix := filepath.ToSlash(t.Sandbox.DisplayPath(searchDir, given)) | |
| 247 | + | if prefix == "" || prefix == "." { | |
| 248 | + | return relSlash | |
| 249 | + | } | |
| 250 | + | return strings.TrimSuffix(prefix, "/") + "/" + relSlash | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | // compileGlob converts a glob pattern into a regular expression. | |
| 254 | + | // It supports '*', '**', '?', character classes '[...]', and brace expansion '{a,b}'. | |
| 255 | + | func compileGlob(pattern string) (*regexp.Regexp, bool, error) { | |
| 256 | + | pattern = filepath.ToSlash(strings.TrimSpace(pattern)) | |
| 257 | + | pattern = strings.TrimPrefix(pattern, "./") | |
| 258 | + | if pattern == "" { | |
| 259 | + | return nil, false, fmt.Errorf("empty pattern") | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | hasSlash := strings.Contains(pattern, "/") | |
| 263 | + | ||
| 264 | + | var sb strings.Builder | |
| 265 | + | sb.WriteString("^") | |
| 266 | + | ||
| 267 | + | inBrace := false | |
| 268 | + | i := 0 | |
| 269 | + | n := len(pattern) | |
| 270 | + | for i < n { | |
| 271 | + | c := pattern[i] | |
| 272 | + | switch c { | |
| 273 | + | case '*': | |
| 274 | + | if i+1 < n && pattern[i+1] == '*' { | |
| 275 | + | i += 2 | |
| 276 | + | if i < n && pattern[i] == '/' { | |
| 277 | + | i++ | |
| 278 | + | sb.WriteString("(?:.*/)?") | |
| 279 | + | } else { | |
| 280 | + | sb.WriteString(".*") | |
| 281 | + | } | |
| 282 | + | } else { | |
| 283 | + | i++ | |
| 284 | + | if hasSlash { | |
| 285 | + | sb.WriteString("[^/]*") | |
| 286 | + | } else { | |
| 287 | + | sb.WriteString(".*") | |
| 288 | + | } | |
| 289 | + | } | |
| 290 | + | case '?': | |
| 291 | + | i++ | |
| 292 | + | if hasSlash { | |
| 293 | + | sb.WriteString("[^/]") | |
| 294 | + | } else { | |
| 295 | + | sb.WriteString(".") | |
| 296 | + | } | |
| 297 | + | case '{': | |
| 298 | + | closeIdx := strings.IndexByte(pattern[i:], '}') | |
| 299 | + | if closeIdx > 0 && strings.Contains(pattern[i:i+closeIdx], ",") { | |
| 300 | + | inBrace = true | |
| 301 | + | sb.WriteString("(?:") | |
| 302 | + | i++ | |
| 303 | + | } else { | |
| 304 | + | sb.WriteString("\\{") | |
| 305 | + | i++ | |
| 306 | + | } | |
| 307 | + | case ',': | |
| 308 | + | if inBrace { | |
| 309 | + | sb.WriteString("|") | |
| 310 | + | i++ | |
| 311 | + | } else { | |
| 312 | + | sb.WriteString(",") | |
| 313 | + | i++ | |
| 314 | + | } | |
| 315 | + | case '}': | |
| 316 | + | if inBrace { | |
| 317 | + | sb.WriteString(")") | |
| 318 | + | inBrace = false | |
| 319 | + | i++ | |
| 320 | + | } else { | |
| 321 | + | sb.WriteString("\\}") | |
| 322 | + | i++ | |
| 323 | + | } | |
| 324 | + | case '[': | |
| 325 | + | j := i + 1 | |
| 326 | + | if j < n && pattern[j] == ']' { | |
| 327 | + | j++ | |
| 328 | + | } | |
| 329 | + | for j < n && pattern[j] != ']' { | |
| 330 | + | j++ | |
| 331 | + | } | |
| 332 | + | if j < n { | |
| 333 | + | sb.WriteString(pattern[i : j+1]) | |
| 334 | + | i = j + 1 | |
| 335 | + | } else { | |
| 336 | + | sb.WriteString("\\[") | |
| 337 | + | i++ | |
| 338 | + | } | |
| 339 | + | case '.', '+', '(', ')', '^', '$', '|', '\\': | |
| 340 | + | sb.WriteByte('\\') | |
| 341 | + | sb.WriteByte(c) | |
| 342 | + | i++ | |
| 343 | + | default: | |
| 344 | + | sb.WriteByte(c) | |
| 345 | + | i++ | |
| 346 | + | } | |
| 347 | + | } | |
| 348 | + | sb.WriteString("$") | |
| 349 | + | ||
| 350 | + | re, err := regexp.Compile(sb.String()) | |
| 351 | + | if err != nil { | |
| 352 | + | return nil, false, fmt.Errorf("invalid glob pattern %q: %w", pattern, err) | |
| 353 | + | } | |
| 354 | + | return re, hasSlash, nil | |
| 355 | + | } | |
| @@ -0,0 +1,355 @@ | |||||
| 1 | + | package tools | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "fmt" | |||
| 7 | + | "os" | |||
| 8 | + | "path/filepath" | |||
| 9 | + | "regexp" | |||
| 10 | + | "sort" | |||
| 11 | + | "strings" | |||
| 12 | + | ||||
| 13 | + | "github.com/patriceckhart/zot/packages/core" | |||
| 14 | + | "github.com/patriceckhart/zot/packages/ignore" | |||
| 15 | + | "github.com/patriceckhart/zot/packages/provider" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | const ( | |||
| 19 | + | maxGlobMatches = 500 | |||
| 20 | + | maxGlobDepth = 24 | |||
| 21 | + | ) | |||
| 22 | + | ||||
| 23 | + | // GlobTool finds files matching a pattern within a directory tree. | |||
| 24 | + | type GlobTool struct { | |||
| 25 | + | CWD string | |||
| 26 | + | Sandbox *Sandbox | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | type globArgs struct { | |||
| 30 | + | Pattern string `json:"pattern"` | |||
| 31 | + | Path string `json:"path,omitempty"` | |||
| 32 | + | Hidden bool `json:"hidden,omitempty"` | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | const globSchema = `{"type":"object","properties":{"pattern":{"type":"string","description":"Glob pattern to match files against (e.g. \"**/*.go\", \"*.json\", \"src/**/*.ts\")"},"path":{"type":"string","description":"Directory to search within, relative to CWD (defaults to \".\")"},"hidden":{"type":"boolean","description":"Whether to include hidden files and directories (default false)"}},"required":["pattern"]}` | |||
| 36 | + | ||||
| 37 | + | func (t *GlobTool) Name() string { return "glob" } | |||
| 38 | + | func (t *GlobTool) Description() string { | |||
| 39 | + | return "Find files matching a glob pattern (e.g. \"**/*.go\", \"*.json\", \"src/**/*.ts\"). Honors .gitignore rules." | |||
| 40 | + | } | |||
| 41 | + | func (t *GlobTool) Schema() json.RawMessage { return json.RawMessage(globSchema) } | |||
| 42 | + | ||||
| 43 | + | func (t *GlobTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) { | |||
| 44 | + | var a globArgs | |||
| 45 | + | if err := json.Unmarshal(raw, &a); err != nil { | |||
| 46 | + | return core.ToolResult{}, fmt.Errorf("invalid args: %w", err) | |||
| 47 | + | } | |||
| 48 | + | if strings.TrimSpace(a.Pattern) == "" { | |||
| 49 | + | return core.ToolResult{}, fmt.Errorf("pattern is required") | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | re, hasSlash, err := compileGlob(a.Pattern) | |||
| 53 | + | if err != nil { | |||
| 54 | + | return core.ToolResult{}, err | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | givenSearchDir := resolvePath(t.CWD, a.Path) | |||
| 58 | + | if err := t.Sandbox.CheckReadPath(givenSearchDir); err != nil { | |||
| 59 | + | return core.ToolResult{}, err | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | info, err := os.Stat(givenSearchDir) | |||
| 63 | + | if err != nil { | |||
| 64 | + | return core.ToolResult{}, err | |||
| 65 | + | } | |||
| 66 | + | if !info.IsDir() { | |||
| 67 | + | shown := t.Sandbox.DisplayPath(givenSearchDir, a.Path) | |||
| 68 | + | return core.ToolResult{}, fmt.Errorf("%s is not a directory", shown) | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | // WalkDir does not follow a symlink used as its root. Resolve the explicit | |||
| 72 | + | // search directory so a path that Stat identified as a directory is | |||
| 73 | + | // actually traversed, then re-check the resolved target before reading it. | |||
| 74 | + | searchDir, err := filepath.EvalSymlinks(givenSearchDir) | |||
| 75 | + | if err != nil { | |||
| 76 | + | return core.ToolResult{}, err | |||
| 77 | + | } | |||
| 78 | + | if err := t.Sandbox.CheckReadPath(searchDir); err != nil { | |||
| 79 | + | return core.ToolResult{}, err | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | stack, ignoreRoot, searchIgnored := t.globIgnoreStack(searchDir) | |||
| 83 | + | rootSep := strings.Count(searchDir, string(os.PathSeparator)) | |||
| 84 | + | var pushed []string | |||
| 85 | + | var matches []string | |||
| 86 | + | truncated := false | |||
| 87 | + | ||||
| 88 | + | var walkErr error | |||
| 89 | + | if !searchIgnored { | |||
| 90 | + | walkErr = filepath.WalkDir(searchDir, func(path string, d os.DirEntry, err error) error { | |||
| 91 | + | if ctxErr := ctx.Err(); ctxErr != nil { | |||
| 92 | + | return ctxErr | |||
| 93 | + | } | |||
| 94 | + | if err != nil { | |||
| 95 | + | if d != nil && d.IsDir() { | |||
| 96 | + | return filepath.SkipDir | |||
| 97 | + | } | |||
| 98 | + | return nil | |||
| 99 | + | } | |||
| 100 | + | if path == searchDir { | |||
| 101 | + | return nil | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | rel, relErr := filepath.Rel(searchDir, path) | |||
| 105 | + | if relErr != nil { | |||
| 106 | + | return nil | |||
| 107 | + | } | |||
| 108 | + | relSlash := filepath.ToSlash(rel) | |||
| 109 | + | ||||
| 110 | + | // Check hidden files/directories unless explicitly requested. | |||
| 111 | + | // .git is always skipped. | |||
| 112 | + | if !a.Hidden { | |||
| 113 | + | if strings.HasPrefix(d.Name(), ".") { | |||
| 114 | + | if d.IsDir() { | |||
| 115 | + | return filepath.SkipDir | |||
| 116 | + | } | |||
| 117 | + | return nil | |||
| 118 | + | } | |||
| 119 | + | } else if d.IsDir() && d.Name() == ".git" { | |||
| 120 | + | return filepath.SkipDir | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | ignoreRel, ignoreRelErr := filepath.Rel(ignoreRoot, path) | |||
| 124 | + | if ignoreRelErr != nil { | |||
| 125 | + | return nil | |||
| 126 | + | } | |||
| 127 | + | ignoreRelSlash := filepath.ToSlash(ignoreRel) | |||
| 128 | + | ||||
| 129 | + | // .gitignore filtering: pop stack frames for directories no longer in scope. | |||
| 130 | + | dirSlash := relSlash | |||
| 131 | + | if !d.IsDir() { | |||
| 132 | + | if idx := strings.LastIndex(relSlash, "/"); idx >= 0 { | |||
| 133 | + | dirSlash = relSlash[:idx] | |||
| 134 | + | } else { | |||
| 135 | + | dirSlash = "" | |||
| 136 | + | } | |||
| 137 | + | } | |||
| 138 | + | for len(pushed) > 0 { | |||
| 139 | + | top := pushed[len(pushed)-1] | |||
| 140 | + | if top == dirSlash || strings.HasPrefix(dirSlash, top+"/") { | |||
| 141 | + | break | |||
| 142 | + | } | |||
| 143 | + | pushed = pushed[:len(pushed)-1] | |||
| 144 | + | stack.Pop() | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | if stack.Match(ignoreRelSlash, d.IsDir()) { | |||
| 148 | + | if d.IsDir() { | |||
| 149 | + | return filepath.SkipDir | |||
| 150 | + | } | |||
| 151 | + | return nil | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | if d.IsDir() { | |||
| 155 | + | if strings.Count(path, string(os.PathSeparator))-rootSep >= maxGlobDepth { | |||
| 156 | + | return filepath.SkipDir | |||
| 157 | + | } | |||
| 158 | + | stack.Push(path, ignoreRel) | |||
| 159 | + | pushed = append(pushed, relSlash) | |||
| 160 | + | return nil | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | // Check if file matches the glob pattern. | |||
| 164 | + | matched := false | |||
| 165 | + | if hasSlash { | |||
| 166 | + | matched = re.MatchString(relSlash) | |||
| 167 | + | } else { | |||
| 168 | + | matched = re.MatchString(d.Name()) | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | if matched { | |||
| 172 | + | matches = append(matches, t.globDisplayPath(givenSearchDir, a.Path, relSlash)) | |||
| 173 | + | if len(matches) >= maxGlobMatches { | |||
| 174 | + | truncated = true | |||
| 175 | + | return filepath.SkipAll | |||
| 176 | + | } | |||
| 177 | + | } | |||
| 178 | + | return nil | |||
| 179 | + | }) | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | if walkErr != nil && walkErr != filepath.SkipAll { | |||
| 183 | + | return core.ToolResult{}, walkErr | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | sort.Strings(matches) | |||
| 187 | + | ||||
| 188 | + | var text string | |||
| 189 | + | if len(matches) == 0 { | |||
| 190 | + | text = "No files matched the pattern." | |||
| 191 | + | } else { | |||
| 192 | + | text = strings.Join(matches, "\n") | |||
| 193 | + | if truncated { | |||
| 194 | + | text += fmt.Sprintf("\n\n(Truncated: showing first %d matches)", maxGlobMatches) | |||
| 195 | + | } | |||
| 196 | + | } | |||
| 197 | + | ||||
| 198 | + | return core.ToolResult{ | |||
| 199 | + | Content: []provider.Content{provider.TextBlock{Text: text}}, | |||
| 200 | + | Details: map[string]any{ | |||
| 201 | + | "matches": len(matches), | |||
| 202 | + | "truncated": truncated, | |||
| 203 | + | "pattern": a.Pattern, | |||
| 204 | + | }, | |||
| 205 | + | }, nil | |||
| 206 | + | } | |||
| 207 | + | ||||
| 208 | + | // globIgnoreStack builds an ignore stack rooted at CWD when the search path is | |||
| 209 | + | // below it, preloading the .gitignore files between CWD and the search root. | |||
| 210 | + | // This preserves inherited rules when the caller narrows a search with path. | |||
| 211 | + | func (t *GlobTool) globIgnoreStack(searchDir string) (*ignore.Stack, string, bool) { | |||
| 212 | + | cwd := resolvePath(t.CWD, ".") | |||
| 213 | + | cwd, err := filepath.EvalSymlinks(cwd) | |||
| 214 | + | if err != nil || t.Sandbox.CheckReadPath(cwd) != nil { | |||
| 215 | + | return ignore.NewStack(searchDir), searchDir, false | |||
| 216 | + | } | |||
| 217 | + | ||||
| 218 | + | rel, err := filepath.Rel(cwd, searchDir) | |||
| 219 | + | if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) || filepath.IsAbs(rel) { | |||
| 220 | + | return ignore.NewStack(searchDir), searchDir, false | |||
| 221 | + | } | |||
| 222 | + | ||||
| 223 | + | stack := ignore.NewStack(cwd) | |||
| 224 | + | if rel == "." { | |||
| 225 | + | return stack, cwd, false | |||
| 226 | + | } | |||
| 227 | + | ||||
| 228 | + | current := cwd | |||
| 229 | + | var relParts []string | |||
| 230 | + | for _, part := range strings.Split(rel, string(os.PathSeparator)) { | |||
| 231 | + | current = filepath.Join(current, part) | |||
| 232 | + | relParts = append(relParts, part) | |||
| 233 | + | currentRel := filepath.Join(relParts...) | |||
| 234 | + | if stack.Match(filepath.ToSlash(currentRel), true) { | |||
| 235 | + | return stack, cwd, true | |||
| 236 | + | } | |||
| 237 | + | stack.Push(current, currentRel) | |||
| 238 | + | } | |||
| 239 | + | return stack, cwd, false | |||
| 240 | + | } | |||
| 241 | + | ||||
| 242 | + | func (t *GlobTool) globDisplayPath(searchDir, given, relSlash string) string { | |||
| 243 | + | if given == "" || given == "." { | |||
| 244 | + | return relSlash | |||
| 245 | + | } | |||
| 246 | + | prefix := filepath.ToSlash(t.Sandbox.DisplayPath(searchDir, given)) | |||
| 247 | + | if prefix == "" || prefix == "." { | |||
| 248 | + | return relSlash | |||
| 249 | + | } | |||
| 250 | + | return strings.TrimSuffix(prefix, "/") + "/" + relSlash | |||
| 251 | + | } | |||
| 252 | + | ||||
| 253 | + | // compileGlob converts a glob pattern into a regular expression. | |||
| 254 | + | // It supports '*', '**', '?', character classes '[...]', and brace expansion '{a,b}'. | |||
| 255 | + | func compileGlob(pattern string) (*regexp.Regexp, bool, error) { | |||
| 256 | + | pattern = filepath.ToSlash(strings.TrimSpace(pattern)) | |||
| 257 | + | pattern = strings.TrimPrefix(pattern, "./") | |||
| 258 | + | if pattern == "" { | |||
| 259 | + | return nil, false, fmt.Errorf("empty pattern") | |||
| 260 | + | } | |||
| 261 | + | ||||
| 262 | + | hasSlash := strings.Contains(pattern, "/") | |||
| 263 | + | ||||
| 264 | + | var sb strings.Builder | |||
| 265 | + | sb.WriteString("^") | |||
| 266 | + | ||||
| 267 | + | inBrace := false | |||
| 268 | + | i := 0 | |||
| 269 | + | n := len(pattern) | |||
| 270 | + | for i < n { | |||
| 271 | + | c := pattern[i] | |||
| 272 | + | switch c { | |||
| 273 | + | case '*': | |||
| 274 | + | if i+1 < n && pattern[i+1] == '*' { | |||
| 275 | + | i += 2 | |||
| 276 | + | if i < n && pattern[i] == '/' { | |||
| 277 | + | i++ | |||
| 278 | + | sb.WriteString("(?:.*/)?") | |||
| 279 | + | } else { | |||
| 280 | + | sb.WriteString(".*") | |||
| 281 | + | } | |||
| 282 | + | } else { | |||
| 283 | + | i++ | |||
| 284 | + | if hasSlash { | |||
| 285 | + | sb.WriteString("[^/]*") | |||
| 286 | + | } else { | |||
| 287 | + | sb.WriteString(".*") | |||
| 288 | + | } | |||
| 289 | + | } | |||
| 290 | + | case '?': | |||
| 291 | + | i++ | |||
| 292 | + | if hasSlash { | |||
| 293 | + | sb.WriteString("[^/]") | |||
| 294 | + | } else { | |||
| 295 | + | sb.WriteString(".") | |||
| 296 | + | } | |||
| 297 | + | case '{': | |||
| 298 | + | closeIdx := strings.IndexByte(pattern[i:], '}') | |||
| 299 | + | if closeIdx > 0 && strings.Contains(pattern[i:i+closeIdx], ",") { | |||
| 300 | + | inBrace = true | |||
| 301 | + | sb.WriteString("(?:") | |||
| 302 | + | i++ | |||
| 303 | + | } else { | |||
| 304 | + | sb.WriteString("\\{") | |||
| 305 | + | i++ | |||
| 306 | + | } | |||
| 307 | + | case ',': | |||
| 308 | + | if inBrace { | |||
| 309 | + | sb.WriteString("|") | |||
| 310 | + | i++ | |||
| 311 | + | } else { | |||
| 312 | + | sb.WriteString(",") | |||
| 313 | + | i++ | |||
| 314 | + | } | |||
| 315 | + | case '}': | |||
| 316 | + | if inBrace { | |||
| 317 | + | sb.WriteString(")") | |||
| 318 | + | inBrace = false | |||
| 319 | + | i++ | |||
| 320 | + | } else { | |||
| 321 | + | sb.WriteString("\\}") | |||
| 322 | + | i++ | |||
| 323 | + | } | |||
| 324 | + | case '[': | |||
| 325 | + | j := i + 1 | |||
| 326 | + | if j < n && pattern[j] == ']' { | |||
| 327 | + | j++ | |||
| 328 | + | } | |||
| 329 | + | for j < n && pattern[j] != ']' { | |||
| 330 | + | j++ | |||
| 331 | + | } | |||
| 332 | + | if j < n { | |||
| 333 | + | sb.WriteString(pattern[i : j+1]) | |||
| 334 | + | i = j + 1 | |||
| 335 | + | } else { | |||
| 336 | + | sb.WriteString("\\[") | |||
| 337 | + | i++ | |||
| 338 | + | } | |||
| 339 | + | case '.', '+', '(', ')', '^', '$', '|', '\\': | |||
| 340 | + | sb.WriteByte('\\') | |||
| 341 | + | sb.WriteByte(c) | |||
| 342 | + | i++ | |||
| 343 | + | default: | |||
| 344 | + | sb.WriteByte(c) | |||
| 345 | + | i++ | |||
| 346 | + | } | |||
| 347 | + | } | |||
| 348 | + | sb.WriteString("$") | |||
| 349 | + | ||||
| 350 | + | re, err := regexp.Compile(sb.String()) | |||
| 351 | + | if err != nil { | |||
| 352 | + | return nil, false, fmt.Errorf("invalid glob pattern %q: %w", pattern, err) | |||
| 353 | + | } | |||
| 354 | + | return re, hasSlash, nil | |||
| 355 | + | } | |||
A packages/agent/tools/glob_test.go +367 -0
| @@ -0,0 +1,367 @@ | |||
| 1 | + | package tools | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "os" | |
| 6 | + | "path/filepath" | |
| 7 | + | "strings" | |
| 8 | + | "testing" | |
| 9 | + | ||
| 10 | + | "github.com/patriceckhart/zot/packages/provider" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestGlobBasic(t *testing.T) { | |
| 14 | + | dir := t.TempDir() | |
| 15 | + | files := []string{ | |
| 16 | + | "main.go", | |
| 17 | + | "README.md", | |
| 18 | + | "pkg/util.go", | |
| 19 | + | "pkg/util_test.go", | |
| 20 | + | "pkg/sub/deep.go", | |
| 21 | + | "docs/index.html", | |
| 22 | + | } | |
| 23 | + | for _, f := range files { | |
| 24 | + | p := filepath.Join(dir, f) | |
| 25 | + | if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { | |
| 26 | + | t.Fatal(err) | |
| 27 | + | } | |
| 28 | + | if err := os.WriteFile(p, []byte("content"), 0o644); err != nil { | |
| 29 | + | t.Fatal(err) | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | tool := &GlobTool{CWD: dir} | |
| 34 | + | ||
| 35 | + | // 1. Simple extension match across tree (no slash in pattern) | |
| 36 | + | res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"pattern": "*.go"}), nil) | |
| 37 | + | if err != nil { | |
| 38 | + | t.Fatalf("execute error: %v", err) | |
| 39 | + | } | |
| 40 | + | text := res.Content[0].(provider.TextBlock).Text | |
| 41 | + | for _, want := range []string{"main.go", "pkg/util.go", "pkg/util_test.go", "pkg/sub/deep.go"} { | |
| 42 | + | if !strings.Contains(text, want) { | |
| 43 | + | t.Errorf("expected %q in result, got:\n%s", want, text) | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | if strings.Contains(text, "README.md") || strings.Contains(text, "docs/index.html") { | |
| 47 | + | t.Errorf("unexpected file in result:\n%s", text) | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // 2. Globstar match with path prefix | |
| 51 | + | res, err = tool.Execute(context.Background(), mustJSON(t, map[string]any{"pattern": "pkg/**/*.go"}), nil) | |
| 52 | + | if err != nil { | |
| 53 | + | t.Fatalf("execute error: %v", err) | |
| 54 | + | } | |
| 55 | + | text = res.Content[0].(provider.TextBlock).Text | |
| 56 | + | if strings.Contains(text, "main.go") { | |
| 57 | + | t.Errorf("did not expect main.go in pkg/**/*.go, got:\n%s", text) | |
| 58 | + | } | |
| 59 | + | for _, want := range []string{"pkg/util.go", "pkg/util_test.go", "pkg/sub/deep.go"} { | |
| 60 | + | if !strings.Contains(text, want) { | |
| 61 | + | t.Errorf("expected %q in result, got:\n%s", want, text) | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // 3. Specific path segment | |
| 66 | + | res, err = tool.Execute(context.Background(), mustJSON(t, map[string]any{"pattern": "pkg/*.go"}), nil) | |
| 67 | + | if err != nil { | |
| 68 | + | t.Fatalf("execute error: %v", err) | |
| 69 | + | } | |
| 70 | + | text = res.Content[0].(provider.TextBlock).Text | |
| 71 | + | if strings.Contains(text, "pkg/sub/deep.go") { | |
| 72 | + | t.Errorf("did not expect deep.go in single-star pkg/*.go, got:\n%s", text) | |
| 73 | + | } | |
| 74 | + | if !strings.Contains(text, "pkg/util.go") || !strings.Contains(text, "pkg/util_test.go") { | |
| 75 | + | t.Errorf("missing pkg/util.go in pkg/*.go, got:\n%s", text) | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func TestGlobBraceExpansion(t *testing.T) { | |
| 80 | + | dir := t.TempDir() | |
| 81 | + | files := []string{ | |
| 82 | + | "a.go", | |
| 83 | + | "b.json", | |
| 84 | + | "c.ts", | |
| 85 | + | "d.txt", | |
| 86 | + | } | |
| 87 | + | for _, f := range files { | |
| 88 | + | p := filepath.Join(dir, f) | |
| 89 | + | os.WriteFile(p, []byte("content"), 0o644) | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | tool := &GlobTool{CWD: dir} | |
| 93 | + | res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"pattern": "*.{go,json}"}), nil) | |
| 94 | + | if err != nil { | |
| 95 | + | t.Fatalf("execute error: %v", err) | |
| 96 | + | } | |
| 97 | + | text := res.Content[0].(provider.TextBlock).Text | |
| 98 | + | if !strings.Contains(text, "a.go") || !strings.Contains(text, "b.json") { | |
| 99 | + | t.Errorf("expected a.go and b.json, got:\n%s", text) | |
| 100 | + | } | |
| 101 | + | if strings.Contains(text, "c.ts") || strings.Contains(text, "d.txt") { | |
| 102 | + | t.Errorf("unexpected files in result:\n%s", text) | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func TestGlobGitignore(t *testing.T) { | |
| 107 | + | dir := t.TempDir() | |
| 108 | + | if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("ignored/\n*.log\n"), 0o644); err != nil { | |
| 109 | + | t.Fatal(err) | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | files := []string{ | |
| 113 | + | "app.go", | |
| 114 | + | "error.log", | |
| 115 | + | "ignored/secret.go", | |
| 116 | + | "sub/normal.go", | |
| 117 | + | "sub/test.log", | |
| 118 | + | } | |
| 119 | + | for _, f := range files { | |
| 120 | + | p := filepath.Join(dir, f) | |
| 121 | + | os.MkdirAll(filepath.Dir(p), 0o755) | |
| 122 | + | os.WriteFile(p, []byte("content"), 0o644) | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | tool := &GlobTool{CWD: dir} | |
| 126 | + | res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"pattern": "**/*"}), nil) | |
| 127 | + | if err != nil { | |
| 128 | + | t.Fatalf("execute error: %v", err) | |
| 129 | + | } | |
| 130 | + | text := res.Content[0].(provider.TextBlock).Text | |
| 131 | + | if strings.Contains(text, "secret.go") || strings.Contains(text, "error.log") || strings.Contains(text, "test.log") { | |
| 132 | + | t.Errorf("result contains gitignored files:\n%s", text) | |
| 133 | + | } | |
| 134 | + | if !strings.Contains(text, "app.go") || !strings.Contains(text, "sub/normal.go") { | |
| 135 | + | t.Errorf("missing tracked files in:\n%s", text) | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | func TestGlobNestedGitignore(t *testing.T) { | |
| 140 | + | dir := t.TempDir() | |
| 141 | + | nested := filepath.Join(dir, "vendor") | |
| 142 | + | os.MkdirAll(nested, 0o755) | |
| 143 | + | os.WriteFile(filepath.Join(nested, ".gitignore"), []byte("cached/\n"), 0o644) | |
| 144 | + | ||
| 145 | + | files := []string{ | |
| 146 | + | "root.go", | |
| 147 | + | "vendor/keep.go", | |
| 148 | + | "vendor/cached/temp.go", | |
| 149 | + | } | |
| 150 | + | for _, f := range files { | |
| 151 | + | p := filepath.Join(dir, f) | |
| 152 | + | os.MkdirAll(filepath.Dir(p), 0o755) | |
| 153 | + | os.WriteFile(p, []byte("content"), 0o644) | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | tool := &GlobTool{CWD: dir} | |
| 157 | + | res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"pattern": "**/*.go"}), nil) | |
| 158 | + | if err != nil { | |
| 159 | + | t.Fatal(err) | |
| 160 | + | } | |
| 161 | + | text := res.Content[0].(provider.TextBlock).Text | |
| 162 | + | if strings.Contains(text, "temp.go") { | |
| 163 | + | t.Errorf("nested gitignore rule was not respected: %s", text) | |
| 164 | + | } | |
| 165 | + | if !strings.Contains(text, "root.go") || !strings.Contains(text, "vendor/keep.go") { | |
| 166 | + | t.Errorf("expected files missing: %s", text) | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | func TestGlobHiddenFiles(t *testing.T) { | |
| 171 | + | dir := t.TempDir() | |
| 172 | + | files := []string{ | |
| 173 | + | "visible.txt", | |
| 174 | + | ".hidden.txt", | |
| 175 | + | ".config/settings.json", | |
| 176 | + | } | |
| 177 | + | for _, f := range files { | |
| 178 | + | p := filepath.Join(dir, f) | |
| 179 | + | os.MkdirAll(filepath.Dir(p), 0o755) | |
| 180 | + | os.WriteFile(p, []byte("content"), 0o644) | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | tool := &GlobTool{CWD: dir} | |
| 184 | + | ||
| 185 | + | // Default: skip hidden | |
| 186 | + | res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"pattern": "*.*"}), nil) | |
| 187 | + | if err != nil { | |
| 188 | + | t.Fatal(err) | |
| 189 | + | } | |
| 190 | + | text := res.Content[0].(provider.TextBlock).Text | |
| 191 | + | if strings.Contains(text, ".hidden.txt") || strings.Contains(text, "settings.json") { | |
| 192 | + | t.Errorf("hidden files should be skipped by default: %s", text) | |
| 193 | + | } | |
| 194 | + | if !strings.Contains(text, "visible.txt") { | |
| 195 | + | t.Errorf("visible.txt missing: %s", text) | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | // Hidden: true | |
| 199 | + | res, err = tool.Execute(context.Background(), mustJSON(t, map[string]any{"pattern": "**/*", "hidden": true}), nil) | |
| 200 | + | if err != nil { | |
| 201 | + | t.Fatal(err) | |
| 202 | + | } | |
| 203 | + | text = res.Content[0].(provider.TextBlock).Text | |
| 204 | + | if !strings.Contains(text, ".hidden.txt") || !strings.Contains(text, ".config/settings.json") { | |
| 205 | + | t.Errorf("hidden files missing when hidden=true: %s", text) | |
| 206 | + | } | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | func TestGlobSubdirectoryPath(t *testing.T) { | |
| 210 | + | dir := t.TempDir() | |
| 211 | + | files := []string{ | |
| 212 | + | "root.txt", | |
| 213 | + | "sub/a.txt", | |
| 214 | + | "sub/b.txt", | |
| 215 | + | "other/c.txt", | |
| 216 | + | } | |
| 217 | + | for _, f := range files { | |
| 218 | + | p := filepath.Join(dir, f) | |
| 219 | + | os.MkdirAll(filepath.Dir(p), 0o755) | |
| 220 | + | os.WriteFile(p, []byte("content"), 0o644) | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | tool := &GlobTool{CWD: dir} | |
| 224 | + | res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"pattern": "*.txt", "path": "sub"}), nil) | |
| 225 | + | if err != nil { | |
| 226 | + | t.Fatal(err) | |
| 227 | + | } | |
| 228 | + | text := res.Content[0].(provider.TextBlock).Text | |
| 229 | + | if strings.Contains(text, "root.txt") || strings.Contains(text, "other/c.txt") { | |
| 230 | + | t.Errorf("result should only contain files from sub: %s", text) | |
| 231 | + | } | |
| 232 | + | if !strings.Contains(text, "sub/a.txt") || !strings.Contains(text, "sub/b.txt") { | |
| 233 | + | t.Errorf("sub files missing: %s", text) | |
| 234 | + | } | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | func TestGlobSubdirectoryHonorsParentGitignore(t *testing.T) { | |
| 238 | + | dir := t.TempDir() | |
| 239 | + | if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("sub/ignored.go\n"), 0o644); err != nil { | |
| 240 | + | t.Fatal(err) | |
| 241 | + | } | |
| 242 | + | if err := os.Mkdir(filepath.Join(dir, "sub"), 0o755); err != nil { | |
| 243 | + | t.Fatal(err) | |
| 244 | + | } | |
| 245 | + | for _, name := range []string{"ignored.go", "kept.go"} { | |
| 246 | + | if err := os.WriteFile(filepath.Join(dir, "sub", name), []byte("content"), 0o644); err != nil { | |
| 247 | + | t.Fatal(err) | |
| 248 | + | } | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | tool := &GlobTool{CWD: dir} | |
| 252 | + | res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"pattern": "*.go", "path": "sub"}), nil) | |
| 253 | + | if err != nil { | |
| 254 | + | t.Fatal(err) | |
| 255 | + | } | |
| 256 | + | text := res.Content[0].(provider.TextBlock).Text | |
| 257 | + | if strings.Contains(text, "ignored.go") { | |
| 258 | + | t.Fatalf("parent .gitignore rule was not respected: %s", text) | |
| 259 | + | } | |
| 260 | + | if !strings.Contains(text, "sub/kept.go") { | |
| 261 | + | t.Fatalf("expected kept file in result: %s", text) | |
| 262 | + | } | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | func TestGlobSymlinkSearchRoot(t *testing.T) { | |
| 266 | + | dir := t.TempDir() | |
| 267 | + | target := filepath.Join(dir, "target") | |
| 268 | + | if err := os.Mkdir(target, 0o755); err != nil { | |
| 269 | + | t.Fatal(err) | |
| 270 | + | } | |
| 271 | + | if err := os.WriteFile(filepath.Join(target, "found.go"), []byte("content"), 0o644); err != nil { | |
| 272 | + | t.Fatal(err) | |
| 273 | + | } | |
| 274 | + | if err := os.Symlink(target, filepath.Join(dir, "link")); err != nil { | |
| 275 | + | t.Skipf("symlinks unavailable: %v", err) | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | tool := &GlobTool{CWD: dir} | |
| 279 | + | res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"pattern": "*.go", "path": "link"}), nil) | |
| 280 | + | if err != nil { | |
| 281 | + | t.Fatal(err) | |
| 282 | + | } | |
| 283 | + | if text := res.Content[0].(provider.TextBlock).Text; text != "link/found.go" { | |
| 284 | + | t.Fatalf("unexpected symlink search result: %q", text) | |
| 285 | + | } | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | func TestGlobNoMatches(t *testing.T) { | |
| 289 | + | dir := t.TempDir() | |
| 290 | + | os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main"), 0o644) | |
| 291 | + | ||
| 292 | + | tool := &GlobTool{CWD: dir} | |
| 293 | + | res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"pattern": "*.rs"}), nil) | |
| 294 | + | if err != nil { | |
| 295 | + | t.Fatal(err) | |
| 296 | + | } | |
| 297 | + | text := res.Content[0].(provider.TextBlock).Text | |
| 298 | + | if text != "No files matched the pattern." { | |
| 299 | + | t.Fatalf("expected 'No files matched the pattern.', got: %q", text) | |
| 300 | + | } | |
| 301 | + | details := res.Details.(map[string]any) | |
| 302 | + | if details["matches"] != 0 { | |
| 303 | + | t.Fatalf("matches detail want 0, got %v", details["matches"]) | |
| 304 | + | } | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | func TestGlobValidationErrors(t *testing.T) { | |
| 308 | + | dir := t.TempDir() | |
| 309 | + | tool := &GlobTool{CWD: dir} | |
| 310 | + | ||
| 311 | + | // Empty pattern | |
| 312 | + | if _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"pattern": ""}), nil); err == nil { | |
| 313 | + | t.Fatal("expected error for empty pattern") | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | // Non-existent directory | |
| 317 | + | if _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"pattern": "*.go", "path": "nonexistent"}), nil); err == nil { | |
| 318 | + | t.Fatal("expected error for non-existent path") | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | // File instead of directory | |
| 322 | + | filePath := filepath.Join(dir, "file.txt") | |
| 323 | + | os.WriteFile(filePath, []byte("text"), 0o644) | |
| 324 | + | if _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"pattern": "*.txt", "path": "file.txt"}), nil); err == nil { | |
| 325 | + | t.Fatal("expected error for file passed as path") | |
| 326 | + | } | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | func TestGlobSandboxing(t *testing.T) { | |
| 330 | + | dir := t.TempDir() | |
| 331 | + | sandboxDir := filepath.Join(dir, "sandbox") | |
| 332 | + | outsideDir := filepath.Join(dir, "outside") | |
| 333 | + | os.MkdirAll(sandboxDir, 0o755) | |
| 334 | + | os.MkdirAll(outsideDir, 0o755) | |
| 335 | + | ||
| 336 | + | s := NewSandbox(sandboxDir) | |
| 337 | + | s.Lock() | |
| 338 | + | ||
| 339 | + | tool := &GlobTool{CWD: sandboxDir, Sandbox: s} | |
| 340 | + | ||
| 341 | + | // Inside sandbox should succeed | |
| 342 | + | res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"pattern": "*.go"}), nil) | |
| 343 | + | if err != nil { | |
| 344 | + | t.Fatalf("expected success inside sandbox, got %v", err) | |
| 345 | + | } | |
| 346 | + | if res.Content[0].(provider.TextBlock).Text != "No files matched the pattern." { | |
| 347 | + | t.Fatalf("unexpected content: %v", res.Content[0]) | |
| 348 | + | } | |
| 349 | + | ||
| 350 | + | // Absolute paths inside the sandbox should not leak the sandbox root. | |
| 351 | + | insideFile := filepath.Join(sandboxDir, "inside.go") | |
| 352 | + | if err := os.WriteFile(insideFile, []byte("content"), 0o644); err != nil { | |
| 353 | + | t.Fatal(err) | |
| 354 | + | } | |
| 355 | + | res, err = tool.Execute(context.Background(), mustJSON(t, map[string]any{"pattern": "*.go", "path": sandboxDir}), nil) | |
| 356 | + | if err != nil { | |
| 357 | + | t.Fatalf("expected absolute path inside sandbox to succeed, got %v", err) | |
| 358 | + | } | |
| 359 | + | if text := res.Content[0].(provider.TextBlock).Text; text != "inside.go" { | |
| 360 | + | t.Fatalf("jailed result should be relative, got %q", text) | |
| 361 | + | } | |
| 362 | + | ||
| 363 | + | // Outside sandbox should fail. | |
| 364 | + | if _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"pattern": "*.go", "path": outsideDir}), nil); err == nil { | |
| 365 | + | t.Fatal("expected sandboxing error for path outside sandbox") | |
| 366 | + | } | |
| 367 | + | } | |
| @@ -0,0 +1,367 @@ | |||||
| 1 | + | package tools | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "os" | |||
| 6 | + | "path/filepath" | |||
| 7 | + | "strings" | |||
| 8 | + | "testing" | |||
| 9 | + | ||||
| 10 | + | "github.com/patriceckhart/zot/packages/provider" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestGlobBasic(t *testing.T) { | |||
| 14 | + | dir := t.TempDir() | |||
| 15 | + | files := []string{ | |||
| 16 | + | "main.go", | |||
| 17 | + | "README.md", | |||
| 18 | + | "pkg/util.go", | |||
| 19 | + | "pkg/util_test.go", | |||
| 20 | + | "pkg/sub/deep.go", | |||
| 21 | + | "docs/index.html", | |||
| 22 | + | } | |||
| 23 | + | for _, f := range files { | |||
| 24 | + | p := filepath.Join(dir, f) | |||
| 25 | + | if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { | |||
| 26 | + | t.Fatal(err) | |||
| 27 | + | } | |||
| 28 | + | if err := os.WriteFile(p, []byte("content"), 0o644); err != nil { | |||
| 29 | + | t.Fatal(err) | |||
| 30 | + | } | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | tool := &GlobTool{CWD: dir} | |||
| 34 | + | ||||
| 35 | + | // 1. Simple extension match across tree (no slash in pattern) | |||
| 36 | + | res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"pattern": "*.go"}), nil) | |||
| 37 | + | if err != nil { | |||
| 38 | + | t.Fatalf("execute error: %v", err) | |||
| 39 | + | } | |||
| 40 | + | text := res.Content[0].(provider.TextBlock).Text | |||
| 41 | + | for _, want := range []string{"main.go", "pkg/util.go", "pkg/util_test.go", "pkg/sub/deep.go"} { | |||
| 42 | + | if !strings.Contains(text, want) { | |||
| 43 | + | t.Errorf("expected %q in result, got:\n%s", want, text) | |||
| 44 | + | } | |||
| 45 | + | } | |||
| 46 | + | if strings.Contains(text, "README.md") || strings.Contains(text, "docs/index.html") { | |||
| 47 | + | t.Errorf("unexpected file in result:\n%s", text) | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // 2. Globstar match with path prefix | |||
| 51 | + | res, err = tool.Execute(context.Background(), mustJSON(t, map[string]any{"pattern": "pkg/**/*.go"}), nil) | |||
| 52 | + | if err != nil { | |||
| 53 | + | t.Fatalf("execute error: %v", err) | |||
| 54 | + | } | |||
| 55 | + | text = res.Content[0].(provider.TextBlock).Text | |||
| 56 | + | if strings.Contains(text, "main.go") { | |||
| 57 | + | t.Errorf("did not expect main.go in pkg/**/*.go, got:\n%s", text) | |||
| 58 | + | } | |||
| 59 | + | for _, want := range []string{"pkg/util.go", "pkg/util_test.go", "pkg/sub/deep.go"} { | |||
| 60 | + | if !strings.Contains(text, want) { | |||
| 61 | + | t.Errorf("expected %q in result, got:\n%s", want, text) | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | // 3. Specific path segment | |||
| 66 | + | res, err = tool.Execute(context.Background(), mustJSON(t, map[string]any{"pattern": "pkg/*.go"}), nil) | |||
| 67 | + | if err != nil { | |||
| 68 | + | t.Fatalf("execute error: %v", err) | |||
| 69 | + | } | |||
| 70 | + | text = res.Content[0].(provider.TextBlock).Text | |||
| 71 | + | if strings.Contains(text, "pkg/sub/deep.go") { | |||
| 72 | + | t.Errorf("did not expect deep.go in single-star pkg/*.go, got:\n%s", text) | |||
| 73 | + | } | |||
| 74 | + | if !strings.Contains(text, "pkg/util.go") || !strings.Contains(text, "pkg/util_test.go") { | |||
| 75 | + | t.Errorf("missing pkg/util.go in pkg/*.go, got:\n%s", text) | |||
| 76 | + | } | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func TestGlobBraceExpansion(t *testing.T) { | |||
| 80 | + | dir := t.TempDir() | |||
| 81 | + | files := []string{ | |||
| 82 | + | "a.go", | |||
| 83 | + | "b.json", | |||
| 84 | + | "c.ts", | |||
| 85 | + | "d.txt", | |||
| 86 | + | } | |||
| 87 | + | for _, f := range files { | |||
| 88 | + | p := filepath.Join(dir, f) | |||
| 89 | + | os.WriteFile(p, []byte("content"), 0o644) | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | tool := &GlobTool{CWD: dir} | |||
| 93 | + | res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"pattern": "*.{go,json}"}), nil) | |||
| 94 | + | if err != nil { | |||
| 95 | + | t.Fatalf("execute error: %v", err) | |||
| 96 | + | } | |||
| 97 | + | text := res.Content[0].(provider.TextBlock).Text | |||
| 98 | + | if !strings.Contains(text, "a.go") || !strings.Contains(text, "b.json") { | |||
| 99 | + | t.Errorf("expected a.go and b.json, got:\n%s", text) | |||
| 100 | + | } | |||
| 101 | + | if strings.Contains(text, "c.ts") || strings.Contains(text, "d.txt") { | |||
| 102 | + | t.Errorf("unexpected files in result:\n%s", text) | |||
| 103 | + | } | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | func TestGlobGitignore(t *testing.T) { | |||
| 107 | + | dir := t.TempDir() | |||
| 108 | + | if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("ignored/\n*.log\n"), 0o644); err != nil { | |||
| 109 | + | t.Fatal(err) | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | files := []string{ | |||
| 113 | + | "app.go", | |||
| 114 | + | "error.log", | |||
| 115 | + | "ignored/secret.go", | |||
| 116 | + | "sub/normal.go", | |||
| 117 | + | "sub/test.log", | |||
| 118 | + | } | |||
| 119 | + | for _, f := range files { | |||
| 120 | + | p := filepath.Join(dir, f) | |||
| 121 | + | os.MkdirAll(filepath.Dir(p), 0o755) | |||
| 122 | + | os.WriteFile(p, []byte("content"), 0o644) | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | tool := &GlobTool{CWD: dir} | |||
| 126 | + | res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"pattern": "**/*"}), nil) | |||
| 127 | + | if err != nil { | |||
| 128 | + | t.Fatalf("execute error: %v", err) | |||
| 129 | + | } | |||
| 130 | + | text := res.Content[0].(provider.TextBlock).Text | |||
| 131 | + | if strings.Contains(text, "secret.go") || strings.Contains(text, "error.log") || strings.Contains(text, "test.log") { | |||
| 132 | + | t.Errorf("result contains gitignored files:\n%s", text) | |||
| 133 | + | } | |||
| 134 | + | if !strings.Contains(text, "app.go") || !strings.Contains(text, "sub/normal.go") { | |||
| 135 | + | t.Errorf("missing tracked files in:\n%s", text) | |||
| 136 | + | } | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | func TestGlobNestedGitignore(t *testing.T) { | |||
| 140 | + | dir := t.TempDir() | |||
| 141 | + | nested := filepath.Join(dir, "vendor") | |||
| 142 | + | os.MkdirAll(nested, 0o755) | |||
| 143 | + | os.WriteFile(filepath.Join(nested, ".gitignore"), []byte("cached/\n"), 0o644) | |||
| 144 | + | ||||
| 145 | + | files := []string{ | |||
| 146 | + | "root.go", | |||
| 147 | + | "vendor/keep.go", | |||
| 148 | + | "vendor/cached/temp.go", | |||
| 149 | + | } | |||
| 150 | + | for _, f := range files { | |||
| 151 | + | p := filepath.Join(dir, f) | |||
| 152 | + | os.MkdirAll(filepath.Dir(p), 0o755) | |||
| 153 | + | os.WriteFile(p, []byte("content"), 0o644) | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | tool := &GlobTool{CWD: dir} | |||
| 157 | + | res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"pattern": "**/*.go"}), nil) | |||
| 158 | + | if err != nil { | |||
| 159 | + | t.Fatal(err) | |||
| 160 | + | } | |||
| 161 | + | text := res.Content[0].(provider.TextBlock).Text | |||
| 162 | + | if strings.Contains(text, "temp.go") { | |||
| 163 | + | t.Errorf("nested gitignore rule was not respected: %s", text) | |||
| 164 | + | } | |||
| 165 | + | if !strings.Contains(text, "root.go") || !strings.Contains(text, "vendor/keep.go") { | |||
| 166 | + | t.Errorf("expected files missing: %s", text) | |||
| 167 | + | } | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | func TestGlobHiddenFiles(t *testing.T) { | |||
| 171 | + | dir := t.TempDir() | |||
| 172 | + | files := []string{ | |||
| 173 | + | "visible.txt", | |||
| 174 | + | ".hidden.txt", | |||
| 175 | + | ".config/settings.json", | |||
| 176 | + | } | |||
| 177 | + | for _, f := range files { | |||
| 178 | + | p := filepath.Join(dir, f) | |||
| 179 | + | os.MkdirAll(filepath.Dir(p), 0o755) | |||
| 180 | + | os.WriteFile(p, []byte("content"), 0o644) | |||
| 181 | + | } | |||
| 182 | + | ||||
| 183 | + | tool := &GlobTool{CWD: dir} | |||
| 184 | + | ||||
| 185 | + | // Default: skip hidden | |||
| 186 | + | res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"pattern": "*.*"}), nil) | |||
| 187 | + | if err != nil { | |||
| 188 | + | t.Fatal(err) | |||
| 189 | + | } | |||
| 190 | + | text := res.Content[0].(provider.TextBlock).Text | |||
| 191 | + | if strings.Contains(text, ".hidden.txt") || strings.Contains(text, "settings.json") { | |||
| 192 | + | t.Errorf("hidden files should be skipped by default: %s", text) | |||
| 193 | + | } | |||
| 194 | + | if !strings.Contains(text, "visible.txt") { | |||
| 195 | + | t.Errorf("visible.txt missing: %s", text) | |||
| 196 | + | } | |||
| 197 | + | ||||
| 198 | + | // Hidden: true | |||
| 199 | + | res, err = tool.Execute(context.Background(), mustJSON(t, map[string]any{"pattern": "**/*", "hidden": true}), nil) | |||
| 200 | + | if err != nil { | |||
| 201 | + | t.Fatal(err) | |||
| 202 | + | } | |||
| 203 | + | text = res.Content[0].(provider.TextBlock).Text | |||
| 204 | + | if !strings.Contains(text, ".hidden.txt") || !strings.Contains(text, ".config/settings.json") { | |||
| 205 | + | t.Errorf("hidden files missing when hidden=true: %s", text) | |||
| 206 | + | } | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | func TestGlobSubdirectoryPath(t *testing.T) { | |||
| 210 | + | dir := t.TempDir() | |||
| 211 | + | files := []string{ | |||
| 212 | + | "root.txt", | |||
| 213 | + | "sub/a.txt", | |||
| 214 | + | "sub/b.txt", | |||
| 215 | + | "other/c.txt", | |||
| 216 | + | } | |||
| 217 | + | for _, f := range files { | |||
| 218 | + | p := filepath.Join(dir, f) | |||
| 219 | + | os.MkdirAll(filepath.Dir(p), 0o755) | |||
| 220 | + | os.WriteFile(p, []byte("content"), 0o644) | |||
| 221 | + | } | |||
| 222 | + | ||||
| 223 | + | tool := &GlobTool{CWD: dir} | |||
| 224 | + | res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"pattern": "*.txt", "path": "sub"}), nil) | |||
| 225 | + | if err != nil { | |||
| 226 | + | t.Fatal(err) | |||
| 227 | + | } | |||
| 228 | + | text := res.Content[0].(provider.TextBlock).Text | |||
| 229 | + | if strings.Contains(text, "root.txt") || strings.Contains(text, "other/c.txt") { | |||
| 230 | + | t.Errorf("result should only contain files from sub: %s", text) | |||
| 231 | + | } | |||
| 232 | + | if !strings.Contains(text, "sub/a.txt") || !strings.Contains(text, "sub/b.txt") { | |||
| 233 | + | t.Errorf("sub files missing: %s", text) | |||
| 234 | + | } | |||
| 235 | + | } | |||
| 236 | + | ||||
| 237 | + | func TestGlobSubdirectoryHonorsParentGitignore(t *testing.T) { | |||
| 238 | + | dir := t.TempDir() | |||
| 239 | + | if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("sub/ignored.go\n"), 0o644); err != nil { | |||
| 240 | + | t.Fatal(err) | |||
| 241 | + | } | |||
| 242 | + | if err := os.Mkdir(filepath.Join(dir, "sub"), 0o755); err != nil { | |||
| 243 | + | t.Fatal(err) | |||
| 244 | + | } | |||
| 245 | + | for _, name := range []string{"ignored.go", "kept.go"} { | |||
| 246 | + | if err := os.WriteFile(filepath.Join(dir, "sub", name), []byte("content"), 0o644); err != nil { | |||
| 247 | + | t.Fatal(err) | |||
| 248 | + | } | |||
| 249 | + | } | |||
| 250 | + | ||||
| 251 | + | tool := &GlobTool{CWD: dir} | |||
| 252 | + | res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"pattern": "*.go", "path": "sub"}), nil) | |||
| 253 | + | if err != nil { | |||
| 254 | + | t.Fatal(err) | |||
| 255 | + | } | |||
| 256 | + | text := res.Content[0].(provider.TextBlock).Text | |||
| 257 | + | if strings.Contains(text, "ignored.go") { | |||
| 258 | + | t.Fatalf("parent .gitignore rule was not respected: %s", text) | |||
| 259 | + | } | |||
| 260 | + | if !strings.Contains(text, "sub/kept.go") { | |||
| 261 | + | t.Fatalf("expected kept file in result: %s", text) | |||
| 262 | + | } | |||
| 263 | + | } | |||
| 264 | + | ||||
| 265 | + | func TestGlobSymlinkSearchRoot(t *testing.T) { | |||
| 266 | + | dir := t.TempDir() | |||
| 267 | + | target := filepath.Join(dir, "target") | |||
| 268 | + | if err := os.Mkdir(target, 0o755); err != nil { | |||
| 269 | + | t.Fatal(err) | |||
| 270 | + | } | |||
| 271 | + | if err := os.WriteFile(filepath.Join(target, "found.go"), []byte("content"), 0o644); err != nil { | |||
| 272 | + | t.Fatal(err) | |||
| 273 | + | } | |||
| 274 | + | if err := os.Symlink(target, filepath.Join(dir, "link")); err != nil { | |||
| 275 | + | t.Skipf("symlinks unavailable: %v", err) | |||
| 276 | + | } | |||
| 277 | + | ||||
| 278 | + | tool := &GlobTool{CWD: dir} | |||
| 279 | + | res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"pattern": "*.go", "path": "link"}), nil) | |||
| 280 | + | if err != nil { | |||
| 281 | + | t.Fatal(err) | |||
| 282 | + | } | |||
| 283 | + | if text := res.Content[0].(provider.TextBlock).Text; text != "link/found.go" { | |||
| 284 | + | t.Fatalf("unexpected symlink search result: %q", text) | |||
| 285 | + | } | |||
| 286 | + | } | |||
| 287 | + | ||||
| 288 | + | func TestGlobNoMatches(t *testing.T) { | |||
| 289 | + | dir := t.TempDir() | |||
| 290 | + | os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main"), 0o644) | |||
| 291 | + | ||||
| 292 | + | tool := &GlobTool{CWD: dir} | |||
| 293 | + | res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"pattern": "*.rs"}), nil) | |||
| 294 | + | if err != nil { | |||
| 295 | + | t.Fatal(err) | |||
| 296 | + | } | |||
| 297 | + | text := res.Content[0].(provider.TextBlock).Text | |||
| 298 | + | if text != "No files matched the pattern." { | |||
| 299 | + | t.Fatalf("expected 'No files matched the pattern.', got: %q", text) | |||
| 300 | + | } | |||
| 301 | + | details := res.Details.(map[string]any) | |||
| 302 | + | if details["matches"] != 0 { | |||
| 303 | + | t.Fatalf("matches detail want 0, got %v", details["matches"]) | |||
| 304 | + | } | |||
| 305 | + | } | |||
| 306 | + | ||||
| 307 | + | func TestGlobValidationErrors(t *testing.T) { | |||
| 308 | + | dir := t.TempDir() | |||
| 309 | + | tool := &GlobTool{CWD: dir} | |||
| 310 | + | ||||
| 311 | + | // Empty pattern | |||
| 312 | + | if _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"pattern": ""}), nil); err == nil { | |||
| 313 | + | t.Fatal("expected error for empty pattern") | |||
| 314 | + | } | |||
| 315 | + | ||||
| 316 | + | // Non-existent directory | |||
| 317 | + | if _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"pattern": "*.go", "path": "nonexistent"}), nil); err == nil { | |||
| 318 | + | t.Fatal("expected error for non-existent path") | |||
| 319 | + | } | |||
| 320 | + | ||||
| 321 | + | // File instead of directory | |||
| 322 | + | filePath := filepath.Join(dir, "file.txt") | |||
| 323 | + | os.WriteFile(filePath, []byte("text"), 0o644) | |||
| 324 | + | if _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"pattern": "*.txt", "path": "file.txt"}), nil); err == nil { | |||
| 325 | + | t.Fatal("expected error for file passed as path") | |||
| 326 | + | } | |||
| 327 | + | } | |||
| 328 | + | ||||
| 329 | + | func TestGlobSandboxing(t *testing.T) { | |||
| 330 | + | dir := t.TempDir() | |||
| 331 | + | sandboxDir := filepath.Join(dir, "sandbox") | |||
| 332 | + | outsideDir := filepath.Join(dir, "outside") | |||
| 333 | + | os.MkdirAll(sandboxDir, 0o755) | |||
| 334 | + | os.MkdirAll(outsideDir, 0o755) | |||
| 335 | + | ||||
| 336 | + | s := NewSandbox(sandboxDir) | |||
| 337 | + | s.Lock() | |||
| 338 | + | ||||
| 339 | + | tool := &GlobTool{CWD: sandboxDir, Sandbox: s} | |||
| 340 | + | ||||
| 341 | + | // Inside sandbox should succeed | |||
| 342 | + | res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"pattern": "*.go"}), nil) | |||
| 343 | + | if err != nil { | |||
| 344 | + | t.Fatalf("expected success inside sandbox, got %v", err) | |||
| 345 | + | } | |||
| 346 | + | if res.Content[0].(provider.TextBlock).Text != "No files matched the pattern." { | |||
| 347 | + | t.Fatalf("unexpected content: %v", res.Content[0]) | |||
| 348 | + | } | |||
| 349 | + | ||||
| 350 | + | // Absolute paths inside the sandbox should not leak the sandbox root. | |||
| 351 | + | insideFile := filepath.Join(sandboxDir, "inside.go") | |||
| 352 | + | if err := os.WriteFile(insideFile, []byte("content"), 0o644); err != nil { | |||
| 353 | + | t.Fatal(err) | |||
| 354 | + | } | |||
| 355 | + | res, err = tool.Execute(context.Background(), mustJSON(t, map[string]any{"pattern": "*.go", "path": sandboxDir}), nil) | |||
| 356 | + | if err != nil { | |||
| 357 | + | t.Fatalf("expected absolute path inside sandbox to succeed, got %v", err) | |||
| 358 | + | } | |||
| 359 | + | if text := res.Content[0].(provider.TextBlock).Text; text != "inside.go" { | |||
| 360 | + | t.Fatalf("jailed result should be relative, got %q", text) | |||
| 361 | + | } | |||
| 362 | + | ||||
| 363 | + | // Outside sandbox should fail. | |||
| 364 | + | if _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"pattern": "*.go", "path": outsideDir}), nil); err == nil { | |||
| 365 | + | t.Fatal("expected sandboxing error for path outside sandbox") | |||
| 366 | + | } | |||
| 367 | + | } | |||
M packages/agent/tools/read.go +1 -1
| @@ -1,4 +1,4 @@ | |||
| 1 | - | // Package tools implements zot's built-in tools: read, write, edit, bash. | |
| 1 | + | // Package tools implements zot's built-in tools: read, write, edit, bash, glob. | |
| 2 | 2 | package tools | |
| 3 | 3 | ||
| 4 | 4 | import ( | |
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | // Package tools implements zot's built-in tools: read, write, edit, bash. | 1 | + | // Package tools implements zot's built-in tools: read, write, edit, bash, glob. |
| 2 | package tools | 2 | package tools | ||
| 3 | 3 | ||||
| 4 | import ( | 4 | import ( | ||
M packages/tui/view.go +6 -3
| @@ -2274,7 +2274,7 @@ func ShortArgs(tool string, raw json.RawMessage) string { | |||
| 2274 | 2274 | return s | |
| 2275 | 2275 | } | |
| 2276 | 2276 | var primary string | |
| 2277 | - | for _, k := range []string{"path", "file_path", "command"} { | |
| 2277 | + | for _, k := range []string{"pattern", "path", "file_path", "command"} { | |
| 2278 | 2278 | if s, ok := x[k].(string); ok { | |
| 2279 | 2279 | primary = s | |
| 2280 | 2280 | break | |
| @@ -2290,8 +2290,7 @@ func ShortArgs(tool string, raw json.RawMessage) string { | |||
| 2290 | 2290 | } | |
| 2291 | 2291 | primary = oneLineToolLabel(primary) | |
| 2292 | 2292 | ||
| 2293 | - | // Tool-specific decoration. Only the read tool gets a range | |
| 2294 | - | // suffix for now; other tools just truncate the primary arg. | |
| 2293 | + | // Tool-specific decoration. | |
| 2295 | 2294 | suffix := "" | |
| 2296 | 2295 | switch strings.ToLower(tool) { | |
| 2297 | 2296 | case "read": | |
| @@ -2305,6 +2304,10 @@ func ShortArgs(tool string, raw json.RawMessage) string { | |||
| 2305 | 2304 | } else if start > 1 { | |
| 2306 | 2305 | suffix = fmt.Sprintf(":%d-", start) | |
| 2307 | 2306 | } | |
| 2307 | + | case "glob": | |
| 2308 | + | if dir, ok := x["path"].(string); ok && dir != "" && dir != "." { | |
| 2309 | + | suffix = " in " + dir | |
| 2310 | + | } | |
| 2308 | 2311 | } | |
| 2309 | 2312 | ||
| 2310 | 2313 | // Truncate the primary arg leaving room for the suffix so the | |
| @@ -2274,7 +2274,7 @@ func ShortArgs(tool string, raw json.RawMessage) string { | |||||
| 2274 | return s | 2274 | return s | ||
| 2275 | } | 2275 | } | ||
| 2276 | var primary string | 2276 | var primary string | ||
| 2277 | - | for _, k := range []string{"path", "file_path", "command"} { | 2277 | + | for _, k := range []string{"pattern", "path", "file_path", "command"} { |
| 2278 | if s, ok := x[k].(string); ok { | 2278 | if s, ok := x[k].(string); ok { | ||
| 2279 | primary = s | 2279 | primary = s | ||
| 2280 | break | 2280 | break | ||
| @@ -2290,8 +2290,7 @@ func ShortArgs(tool string, raw json.RawMessage) string { | |||||
| 2290 | } | 2290 | } | ||
| 2291 | primary = oneLineToolLabel(primary) | 2291 | primary = oneLineToolLabel(primary) | ||
| 2292 | 2292 | ||||
| 2293 | - | // Tool-specific decoration. Only the read tool gets a range | 2293 | + | // Tool-specific decoration. |
| 2294 | - | // suffix for now; other tools just truncate the primary arg. | |||
| 2295 | suffix := "" | 2294 | suffix := "" | ||
| 2296 | switch strings.ToLower(tool) { | 2295 | switch strings.ToLower(tool) { | ||
| 2297 | case "read": | 2296 | case "read": | ||
| @@ -2305,6 +2304,10 @@ func ShortArgs(tool string, raw json.RawMessage) string { | |||||
| 2305 | } else if start > 1 { | 2304 | } else if start > 1 { | ||
| 2306 | suffix = fmt.Sprintf(":%d-", start) | 2305 | suffix = fmt.Sprintf(":%d-", start) | ||
| 2307 | } | 2306 | } | ||
| 2307 | + | case "glob": | |||
| 2308 | + | if dir, ok := x["path"].(string); ok && dir != "" && dir != "." { | |||
| 2309 | + | suffix = " in " + dir | |||
| 2310 | + | } | |||
| 2308 | } | 2311 | } | ||
| 2309 | 2312 | ||||
| 2310 | // Truncate the primary arg leaving room for the suffix so the | 2313 | // Truncate the primary arg leaving room for the suffix so the | ||