detached 356583d

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 UnverifiedB5690EEEBB952194 The 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.
1919
2020 - one static binary.
2121 - 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).
2323 - three run modes (interactive tui, print, json).
2424 - built-in telegram bot.
2525 - 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`,
888888 | `ctrl+d` | Exit on empty input. |
889889 | `ctrl+l` | Redraw the screen. |
890890 | `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). |
892892 | `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. |
893893 | `@` | Open the file picker. Browse files and directories in the working directory. |
894894
@@ -1055,7 +1055,7 @@ packages/agent/extproto/ extension wire-format types
10551055 packages/agent/modes/ interactive tui, print, json, dialogs
10561056 packages/agent/modes/bot/ protocol-agnostic bot runner (BotAdapter interface)
10571057 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
10591059 packages/agent/skills/ skill discovery, frontmatter parser, skill tool
10601060 packages/agent/swarm/ background subagent runtime
10611061 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, dialogs1055 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, daemon1057 packages/agent/modes/telegram/ telegram adapter, api client, daemon
1058-packages/agent/tools/ read, write, edit, bash, sandbox1058+packages/agent/tools/ read, write, edit, bash, glob, sandbox
1059 packages/agent/skills/ skill discovery, frontmatter parser, skill tool1059 packages/agent/skills/ skill discovery, frontmatter parser, skill tool
1060 packages/agent/swarm/ background subagent runtime1060 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) {
970970 v.Sandbox = s
971971 case *tools.BashTool:
972972 v.Sandbox = s
973+ case *tools.GlobTool:
974+ v.Sandbox = s
973975 }
974976 _ = name
975977 }
@@ -994,6 +996,7 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox) core.Regis
994996 "write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox},
995997 "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox},
996998 "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox},
999+ "glob": &tools.GlobTool{CWD: cwd, Sandbox: sandbox},
9971000 }
9981001 reg := core.Registry{}
9991002 if len(args.Tools) == 0 {
@@ -1011,7 +1014,7 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox) core.Regis
10111014 }
10121015
10131016 func toolSummaries(reg core.Registry, args Args) []ToolSummary {
1014- order := []string{"read", "write", "edit", "bash"}
1017+ order := []string{"read", "write", "edit", "bash", "glob"}
10151018 var out []ToolSummary
10161019 for _, name := range order {
10171020 if t, ok := reg[name]; ok {
@@ -970,6 +970,8 @@ func (r *Resolved) UseSandbox(s *tools.Sandbox) {
970 v.Sandbox = s970 v.Sandbox = s
971 case *tools.BashTool:971 case *tools.BashTool:
972 v.Sandbox = s972 v.Sandbox = s
973+ case *tools.GlobTool:
974+ v.Sandbox = s
973 }975 }
974 _ = name976 _ = 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 []ToolSummary1018 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) {
605605 t.Fatal("http.DefaultTransport must not be made insecure")
606606 }
607607 }
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) {
390390 // into the agent's registry once the extension's ready frame fires.
391391 //
392392 // 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.
394394 func (e *Extension) Tool(name, description string, schema json.RawMessage, fn ToolHandler) {
395395 e.registerTool(name, description, schema, false, fn)
396396 }
@@ -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) {
2323 }
2424
2525 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_})
6370 `
6471 if err := os.WriteFile(filepath.Join(extDir, "ext.sh"), []byte(script), 0o755); err != nil {
6572 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/bash26+ 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; do31+
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; fi35+
36- if [[ "$t" != "event_intercept" ]]; then continue; fi36+for line in sys.stdin:
37- case "$ev" in37+ 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"* ]]; then42+ except Exception:
43- emit "{\"type\":\"event_intercept_response\",\"id\":\"$id\",\"block\":true,\"reason\":\"refused: rm -rf\"}"43+ continue
44- elif [[ "$name" == "bash" && -n "$cmd" ]]; then44+ 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- else47+ 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"* ]]; then54+ 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- else58+ 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- esac61+ emit({"type": "event_intercept_response", "id": id_})
62-done62+ 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 {
7979 // BaseURL overrides the provider base url (for tests / proxies).
8080 BaseURL string
8181
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
8484 // (e.g. []string{}) plus NoTools=true to disable everything.
8585 Tools []string
8686
@@ -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 string80 BaseURL string
81 81
82- // Tools is the list of tools to enable. Nil/empty = all four82+ // Tools is the list of tools to enable. Nil/empty = all built-ins
83- // (read, write, edit, bash). Pass an empty-but-non-nil slice83+ // (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 []string85 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.
22 package tools
33
44 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 tools2 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 {
22742274 return s
22752275 }
22762276 var primary string
2277- for _, k := range []string{"path", "file_path", "command"} {
2277+ for _, k := range []string{"pattern", "path", "file_path", "command"} {
22782278 if s, ok := x[k].(string); ok {
22792279 primary = s
22802280 break
@@ -2290,8 +2290,7 @@ func ShortArgs(tool string, raw json.RawMessage) string {
22902290 }
22912291 primary = oneLineToolLabel(primary)
22922292
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.
22952294 suffix := ""
22962295 switch strings.ToLower(tool) {
22972296 case "read":
@@ -2305,6 +2304,10 @@ func ShortArgs(tool string, raw json.RawMessage) string {
23052304 } else if start > 1 {
23062305 suffix = fmt.Sprintf(":%d-", start)
23072306 }
2307+ case "glob":
2308+ if dir, ok := x["path"].(string); ok && dir != "" && dir != "." {
2309+ suffix = " in " + dir
2310+ }
23082311 }
23092312
23102313 // 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 s2274 return s
2275 }2275 }
2276 var primary string2276 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 = s2279 primary = s
2280 break2280 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 range2293+ // 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 the2313 // Truncate the primary arg leaving room for the suffix so the

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close