detached 180d726

Merge pull request #126 from bnema/feature/builtin-grep

feat(tools): add built-in grep search tool
commit
180d726b0f7710baf50df7856731becef689416d
author
bnema · · 2026-08-14 03:53 UTC
committer
GitHub · · 2026-08-14 03:53 UTC
merge of
2566549 + 6a87ac5 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
14 files · +812 -26
M README.md +7 -6
@@ -9,7 +9,7 @@ Yet another coding agent harness, lightweight and written (vibe-slopped) in go.
99
1010 - one static binary.
1111 - 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.
12-- eight core built-in tools (read, write, edit, bash, create_worktree, lsp, web_search, and update_goal); the conditional `skill` tool is available when skills are enabled. See [docs/web-search.md](docs/web-search.md) for web-search egress and availability boundaries.
12+- nine core built-in tools (read, write, edit, bash, create_worktree, grep, lsp, web_search, and update_goal); the conditional `skill` tool is available when skills are enabled. See [docs/web-search.md](docs/web-search.md) for web-search egress and availability boundaries.
1313 - three run modes (interactive tui, print, json).
1414 - built-in telegram bot.
1515 - extensions in any language via subprocess + json-rpc. None installed by default; opt in with `zut ext install` or `zut --ext`. See [docs/extensions.md](docs/extensions.md).
@@ -249,13 +249,14 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens
249249 - `read`: read text files, or inline images (PNG, JPEG, GIF, WebP).
250250 - `write`: create or overwrite files, making parent directories as needed.
251251 - `edit`: one or more exact-match replacements in an existing file.
252+- `grep`: search a file or directory for a regular expression using ripgrep when available, with a portable grep fallback. Search output is bounded and follows the active filesystem read scope; it never invokes a shell. The fallback's regular-expression syntax and ignore/hidden-file behavior can differ from ripgrep.
252253 - `bash`: run a command in the session cwd with merged stdout/stderr. Every call must include a positive integer `timeout` in seconds; calls without it or with `timeout <= 0` are rejected, and the deadline is always enforced. On Unix, zut uses `/bin/bash -c` when available, then `bash -c` from `PATH`, and falls back to POSIX `/bin/sh -c` when Bash is unavailable. On Windows, it uses `cmd /C`. macOS ships Bash 3.2 by default, so newer Bash features may be unavailable.
253254 - `create_worktree`: create a new branch from the current `HEAD` and check it out persistently. A repository with no configured root and no `.worktrees` directory first returns bootstrap guidance without making changes; the agent asks whether to use the repository default or an external root, then retries with `bootstrap_root`. The choice is saved privately in local Git config. Choosing `.worktrees` creates `<repository-root>/.worktrees/<branch>` and adds `/.worktrees/` to the root `.gitignore`; an absolute external root leaves tracked repository files and the root `.gitignore` unchanged. The tool never copies uncommitted or ignored files, and it refuses an existing branch or worktree path.
254255 - `lsp`: query configured language servers and linters for diagnostics, definitions, references, hover information, symbols, renames, code actions, capabilities, and raw protocol requests. LSP servers use stdio JSON-RPC; project `lsp.json` files can add or override servers and CLI linters. Diagnostics are bounded, sorted, deduplicated by path/severity/code/start position, and repeated issues are grouped before they reach the model. See [docs/lsp.md](docs/lsp.md).
255256 - `web_search`: search the public web through DuckDuckGo HTML and return bounded source titles, URLs, and snippets. Enabled by default for normal CLI sessions; it is unavailable to bot, default SDK, and packaged-agent runs. See [docs/web-search.md](docs/web-search.md).
256257 - `update_goal`: set the manager's next bounded goal within the active mission, or mark the active goal `done` or `blocked`. Starting, pausing, resuming, and clearing missions remain user-controlled through `/goal`.
257258
258-When the sandbox is on (see `/jail`), filesystem tools and LSP workspace edits refuse paths outside the session cwd. `create_worktree` also requires its repository root, configured worktree destination, optional `.gitignore`, and Git metadata to remain inside the jail. Jail does not sandbox web-search network egress.
259+When the sandbox is on (see `/jail`), filesystem tools, `grep`, and LSP workspace edits refuse paths outside the session cwd. `create_worktree` also requires its repository root, configured worktree destination, optional `.gitignore`, and Git metadata to remain inside the jail. Jail does not sandbox web-search network egress.
259260
260261 ## Modes
261262
@@ -452,7 +453,7 @@ To prevent tool-heavy runs from exhausting context, provider requests and compac
452453
453454 ### `/jail`
454455
455-Enforces a sandbox rooted at the cwd shown in the status bar. `read`, `write`, and `edit` resolve their target path (including through symlinks) and refuse anything outside the sandbox. `create_worktree` also requires its repository root, Git metadata, optional `.gitignore`, and configured worktree destination to be inside the sandbox. `bash` refuses obvious escape patterns (`sudo`, `rm -rf /`, leading `cd /`, `cd ..`, `cd ~`, `chmod -R`, `dd of=/`, and similar) and rejects shell arguments or redirections that point outside the sandbox. The status bar shows `jailed, ~/your/cwd` while active. Enable **jail new sessions by default** in `/settings` to persist this behavior across launches; `/unjail` then unlocks only the current session.
456+Enforces a sandbox rooted at the cwd shown in the status bar. `read`, `write`, `edit`, and `grep` resolve their target path (including through symlinks) and refuse anything outside the sandbox. `create_worktree` also requires its repository root, Git metadata, optional `.gitignore`, and configured worktree destination to be inside the sandbox. `bash` refuses obvious escape patterns (`sudo`, `rm -rf /`, leading `cd /`, `cd ..`, `cd ~`, `chmod -R`, `dd of=/`, and similar) and rejects shell arguments or redirections that point outside the sandbox. The status bar shows `jailed, ~/your/cwd` while active. Enable **jail new sessions by default** in `/settings` to persist this behavior across launches; `/unjail` then unlocks only the current session.
456457
457458 This is a guardrail against accidents, not a hard security boundary. If you need real isolation, run zut under docker or a proper sandbox.
458459
@@ -820,7 +821,7 @@ Frames containing images are full-repainted (no differential diff) to prevent st
820821
821822 ## Tool rendering
822823
823-By default each tool call (bash, read, write, edit, create_worktree, lsp) renders inside a bordered panel — a `┌─ header ─┐`, `│`-prefixed body rows, and a `└─┘` footer. On a screen with many calls the borders can read as busy, so zut also offers a **flat** mode: a single quiet header line per call (`▌ bash …`) with indented, border-free output. Same information — tool name, arg summary, streamed output, the `... (N more lines, ctrl+o to expand)` truncation — just no frame.
824+By default each tool call (bash, read, write, edit, create_worktree, grep, lsp) renders inside a bordered panel — a `┌─ header ─┐`, `│`-prefixed body rows, and a `└─┘` footer. On a screen with many calls the borders can read as busy, so zut also offers a **flat** mode: a single quiet header line per call (`▌ bash …`) with indented, border-free output. Same information — tool name, arg summary, streamed output, the `... (N more lines, ctrl+o to expand)` truncation — just no frame.
824825
825826 Set the `tool_render` key in `$ZUT_HOME/config.json`:
826827
@@ -909,7 +910,7 @@ Slash commands also work while the agent is busy. Non-destructive ones (`/help`,
909910 | `ctrl+b` | Toggle the right sidebar; hidden or narrow widgets use a bounded above-input fallback. |
910911 | `ctrl+l` | Redraw the screen. |
911912 | `ctrl+v` | Paste clipboard text into the focused chat, side chat, dialog, filter, or credential input. In the main chat, image clipboard content is attached to the next prompt when the platform exposes it (macOS pasteboard, Wayland `wl-paste`, or X11 `xclip`). On Linux, text uses `wl-paste`, `xclip`, or `xsel`; terminal-native bracketed paste remains available without those commands. |
912-| `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash, create_worktree, lsp, and web_search outputs over ~12 lines). |
913+| `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash, create_worktree, grep, lsp, and web_search outputs over ~12 lines). |
913914 | `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. |
914915 | `@` | Open the file picker. Browse files and directories in the working directory. |
915916
@@ -1092,7 +1093,7 @@ packages/agent/extproto/ extension wire-format types
10921093 packages/agent/modes/ interactive tui, print, json, dialogs
10931094 packages/agent/modes/bot/ protocol-agnostic bot runner (BotAdapter interface)
10941095 packages/agent/modes/telegram/ telegram adapter, api client, daemon
1095-packages/agent/tools/ read, write, edit, bash, create_worktree, lsp, web search, goals, sandbox
1096+packages/agent/tools/ read, write, edit, bash, create_worktree, grep, lsp, web search, goals, sandbox
10961097 packages/agent/skills/ skill discovery, frontmatter parser, skill tool
10971098 packages/agent/subagents/ named profiles and resident background runtime
10981099 packages/agent/sdk/ public Go SDK for embedding zut in-process (package sdk)
@@ -9,7 +9,7 @@ Yet another coding agent harness, lightweight and written (vibe-slopped) in go.
9 9
10 - one static binary.10 - one static binary.
11 - 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.11 - 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.
12-- eight core built-in tools (read, write, edit, bash, create_worktree, lsp, web_search, and update_goal); the conditional `skill` tool is available when skills are enabled. See [docs/web-search.md](docs/web-search.md) for web-search egress and availability boundaries.12+- nine core built-in tools (read, write, edit, bash, create_worktree, grep, lsp, web_search, and update_goal); the conditional `skill` tool is available when skills are enabled. See [docs/web-search.md](docs/web-search.md) for web-search egress and availability boundaries.
13 - three run modes (interactive tui, print, json).13 - three run modes (interactive tui, print, json).
14 - built-in telegram bot.14 - built-in telegram bot.
15 - extensions in any language via subprocess + json-rpc. None installed by default; opt in with `zut ext install` or `zut --ext`. See [docs/extensions.md](docs/extensions.md).15 - extensions in any language via subprocess + json-rpc. None installed by default; opt in with `zut ext install` or `zut --ext`. See [docs/extensions.md](docs/extensions.md).
@@ -249,13 +249,14 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens
249 - `read`: read text files, or inline images (PNG, JPEG, GIF, WebP).249 - `read`: read text files, or inline images (PNG, JPEG, GIF, WebP).
250 - `write`: create or overwrite files, making parent directories as needed.250 - `write`: create or overwrite files, making parent directories as needed.
251 - `edit`: one or more exact-match replacements in an existing file.251 - `edit`: one or more exact-match replacements in an existing file.
252+- `grep`: search a file or directory for a regular expression using ripgrep when available, with a portable grep fallback. Search output is bounded and follows the active filesystem read scope; it never invokes a shell. The fallback's regular-expression syntax and ignore/hidden-file behavior can differ from ripgrep.
252 - `bash`: run a command in the session cwd with merged stdout/stderr. Every call must include a positive integer `timeout` in seconds; calls without it or with `timeout <= 0` are rejected, and the deadline is always enforced. On Unix, zut uses `/bin/bash -c` when available, then `bash -c` from `PATH`, and falls back to POSIX `/bin/sh -c` when Bash is unavailable. On Windows, it uses `cmd /C`. macOS ships Bash 3.2 by default, so newer Bash features may be unavailable.253 - `bash`: run a command in the session cwd with merged stdout/stderr. Every call must include a positive integer `timeout` in seconds; calls without it or with `timeout <= 0` are rejected, and the deadline is always enforced. On Unix, zut uses `/bin/bash -c` when available, then `bash -c` from `PATH`, and falls back to POSIX `/bin/sh -c` when Bash is unavailable. On Windows, it uses `cmd /C`. macOS ships Bash 3.2 by default, so newer Bash features may be unavailable.
253 - `create_worktree`: create a new branch from the current `HEAD` and check it out persistently. A repository with no configured root and no `.worktrees` directory first returns bootstrap guidance without making changes; the agent asks whether to use the repository default or an external root, then retries with `bootstrap_root`. The choice is saved privately in local Git config. Choosing `.worktrees` creates `<repository-root>/.worktrees/<branch>` and adds `/.worktrees/` to the root `.gitignore`; an absolute external root leaves tracked repository files and the root `.gitignore` unchanged. The tool never copies uncommitted or ignored files, and it refuses an existing branch or worktree path.254 - `create_worktree`: create a new branch from the current `HEAD` and check it out persistently. A repository with no configured root and no `.worktrees` directory first returns bootstrap guidance without making changes; the agent asks whether to use the repository default or an external root, then retries with `bootstrap_root`. The choice is saved privately in local Git config. Choosing `.worktrees` creates `<repository-root>/.worktrees/<branch>` and adds `/.worktrees/` to the root `.gitignore`; an absolute external root leaves tracked repository files and the root `.gitignore` unchanged. The tool never copies uncommitted or ignored files, and it refuses an existing branch or worktree path.
254 - `lsp`: query configured language servers and linters for diagnostics, definitions, references, hover information, symbols, renames, code actions, capabilities, and raw protocol requests. LSP servers use stdio JSON-RPC; project `lsp.json` files can add or override servers and CLI linters. Diagnostics are bounded, sorted, deduplicated by path/severity/code/start position, and repeated issues are grouped before they reach the model. See [docs/lsp.md](docs/lsp.md).255 - `lsp`: query configured language servers and linters for diagnostics, definitions, references, hover information, symbols, renames, code actions, capabilities, and raw protocol requests. LSP servers use stdio JSON-RPC; project `lsp.json` files can add or override servers and CLI linters. Diagnostics are bounded, sorted, deduplicated by path/severity/code/start position, and repeated issues are grouped before they reach the model. See [docs/lsp.md](docs/lsp.md).
255 - `web_search`: search the public web through DuckDuckGo HTML and return bounded source titles, URLs, and snippets. Enabled by default for normal CLI sessions; it is unavailable to bot, default SDK, and packaged-agent runs. See [docs/web-search.md](docs/web-search.md).256 - `web_search`: search the public web through DuckDuckGo HTML and return bounded source titles, URLs, and snippets. Enabled by default for normal CLI sessions; it is unavailable to bot, default SDK, and packaged-agent runs. See [docs/web-search.md](docs/web-search.md).
256 - `update_goal`: set the manager's next bounded goal within the active mission, or mark the active goal `done` or `blocked`. Starting, pausing, resuming, and clearing missions remain user-controlled through `/goal`.257 - `update_goal`: set the manager's next bounded goal within the active mission, or mark the active goal `done` or `blocked`. Starting, pausing, resuming, and clearing missions remain user-controlled through `/goal`.
257 258
258-When the sandbox is on (see `/jail`), filesystem tools and LSP workspace edits refuse paths outside the session cwd. `create_worktree` also requires its repository root, configured worktree destination, optional `.gitignore`, and Git metadata to remain inside the jail. Jail does not sandbox web-search network egress.259+When the sandbox is on (see `/jail`), filesystem tools, `grep`, and LSP workspace edits refuse paths outside the session cwd. `create_worktree` also requires its repository root, configured worktree destination, optional `.gitignore`, and Git metadata to remain inside the jail. Jail does not sandbox web-search network egress.
259 260
260 ## Modes261 ## Modes
261 262
@@ -452,7 +453,7 @@ To prevent tool-heavy runs from exhausting context, provider requests and compac
452 453
453 ### `/jail`454 ### `/jail`
454 455
455-Enforces a sandbox rooted at the cwd shown in the status bar. `read`, `write`, and `edit` resolve their target path (including through symlinks) and refuse anything outside the sandbox. `create_worktree` also requires its repository root, Git metadata, optional `.gitignore`, and configured worktree destination to be inside the sandbox. `bash` refuses obvious escape patterns (`sudo`, `rm -rf /`, leading `cd /`, `cd ..`, `cd ~`, `chmod -R`, `dd of=/`, and similar) and rejects shell arguments or redirections that point outside the sandbox. The status bar shows `jailed, ~/your/cwd` while active. Enable **jail new sessions by default** in `/settings` to persist this behavior across launches; `/unjail` then unlocks only the current session.456+Enforces a sandbox rooted at the cwd shown in the status bar. `read`, `write`, `edit`, and `grep` resolve their target path (including through symlinks) and refuse anything outside the sandbox. `create_worktree` also requires its repository root, Git metadata, optional `.gitignore`, and configured worktree destination to be inside the sandbox. `bash` refuses obvious escape patterns (`sudo`, `rm -rf /`, leading `cd /`, `cd ..`, `cd ~`, `chmod -R`, `dd of=/`, and similar) and rejects shell arguments or redirections that point outside the sandbox. The status bar shows `jailed, ~/your/cwd` while active. Enable **jail new sessions by default** in `/settings` to persist this behavior across launches; `/unjail` then unlocks only the current session.
456 457
457 This is a guardrail against accidents, not a hard security boundary. If you need real isolation, run zut under docker or a proper sandbox.458 This is a guardrail against accidents, not a hard security boundary. If you need real isolation, run zut under docker or a proper sandbox.
458 459
@@ -820,7 +821,7 @@ Frames containing images are full-repainted (no differential diff) to prevent st
820 821
821 ## Tool rendering822 ## Tool rendering
822 823
823-By default each tool call (bash, read, write, edit, create_worktree, lsp) renders inside a bordered panel — a `┌─ header ─┐`, `│`-prefixed body rows, and a `└─┘` footer. On a screen with many calls the borders can read as busy, so zut also offers a **flat** mode: a single quiet header line per call (`▌ bash …`) with indented, border-free output. Same information — tool name, arg summary, streamed output, the `... (N more lines, ctrl+o to expand)` truncation — just no frame.824+By default each tool call (bash, read, write, edit, create_worktree, grep, lsp) renders inside a bordered panel — a `┌─ header ─┐`, `│`-prefixed body rows, and a `└─┘` footer. On a screen with many calls the borders can read as busy, so zut also offers a **flat** mode: a single quiet header line per call (`▌ bash …`) with indented, border-free output. Same information — tool name, arg summary, streamed output, the `... (N more lines, ctrl+o to expand)` truncation — just no frame.
824 825
825 Set the `tool_render` key in `$ZUT_HOME/config.json`:826 Set the `tool_render` key in `$ZUT_HOME/config.json`:
826 827
@@ -909,7 +910,7 @@ Slash commands also work while the agent is busy. Non-destructive ones (`/help`,
909 | `ctrl+b` | Toggle the right sidebar; hidden or narrow widgets use a bounded above-input fallback. |910 | `ctrl+b` | Toggle the right sidebar; hidden or narrow widgets use a bounded above-input fallback. |
910 | `ctrl+l` | Redraw the screen. |911 | `ctrl+l` | Redraw the screen. |
911 | `ctrl+v` | Paste clipboard text into the focused chat, side chat, dialog, filter, or credential input. In the main chat, image clipboard content is attached to the next prompt when the platform exposes it (macOS pasteboard, Wayland `wl-paste`, or X11 `xclip`). On Linux, text uses `wl-paste`, `xclip`, or `xsel`; terminal-native bracketed paste remains available without those commands. |912 | `ctrl+v` | Paste clipboard text into the focused chat, side chat, dialog, filter, or credential input. In the main chat, image clipboard content is attached to the next prompt when the platform exposes it (macOS pasteboard, Wayland `wl-paste`, or X11 `xclip`). On Linux, text uses `wl-paste`, `xclip`, or `xsel`; terminal-native bracketed paste remains available without those commands. |
912-| `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash, create_worktree, lsp, and web_search outputs over ~12 lines). |913+| `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash, create_worktree, grep, lsp, and web_search outputs over ~12 lines). |
913 | `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. |914 | `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. |
914 | `@` | Open the file picker. Browse files and directories in the working directory. |915 | `@` | Open the file picker. Browse files and directories in the working directory. |
915 916
@@ -1092,7 +1093,7 @@ packages/agent/extproto/ extension wire-format types
1092 packages/agent/modes/ interactive tui, print, json, dialogs1093 packages/agent/modes/ interactive tui, print, json, dialogs
1093 packages/agent/modes/bot/ protocol-agnostic bot runner (BotAdapter interface)1094 packages/agent/modes/bot/ protocol-agnostic bot runner (BotAdapter interface)
1094 packages/agent/modes/telegram/ telegram adapter, api client, daemon1095 packages/agent/modes/telegram/ telegram adapter, api client, daemon
1095-packages/agent/tools/ read, write, edit, bash, create_worktree, lsp, web search, goals, sandbox1096+packages/agent/tools/ read, write, edit, bash, create_worktree, grep, lsp, web search, goals, sandbox
1096 packages/agent/skills/ skill discovery, frontmatter parser, skill tool1097 packages/agent/skills/ skill discovery, frontmatter parser, skill tool
1097 packages/agent/subagents/ named profiles and resident background runtime1098 packages/agent/subagents/ named profiles and resident background runtime
1098 packages/agent/sdk/ public Go SDK for embedding zut in-process (package sdk)1099 packages/agent/sdk/ public Go SDK for embedding zut in-process (package sdk)
M docs/extensions.md +5 -2
@@ -225,10 +225,13 @@ describing the tool's args (the same shape Anthropic and OpenAI accept).
225225 ```
226226
227227 Tool names live in the same namespace as built-in tools (`read`,
228-`write`, `edit`, `bash`, `create_worktree`, `lsp`, `web_search`, `skill`).
228+`write`, `edit`, `bash`, `create_worktree`, `grep`, `lsp`, `web_search`, `skill`).
229229 Conflicts with active built-ins are silently shadowed by the built-in. The
230230 native `web_search` name is reserved even when the setting disables that
231-built-in, so an extension cannot claim or replace it.
231+built-in, so an extension cannot claim or replace it. The `grep` name is
232+reserved even when `Args.NoTools`/SDK `Config.NoTools` or an explicit
233+`Args.Tools`/SDK `Config.Tools` allowlist disables the native search tool, so
234+an extension cannot claim or replace it.
232235
233236 Set `"deferred": true` to register a tool without advertising its definition initially. A loader tool can activate registered deferred tools by returning their names in `activate_tools`:
234237
@@ -225,10 +225,13 @@ describing the tool's args (the same shape Anthropic and OpenAI accept).
225 ```225 ```
226 226
227 Tool names live in the same namespace as built-in tools (`read`,227 Tool names live in the same namespace as built-in tools (`read`,
228-`write`, `edit`, `bash`, `create_worktree`, `lsp`, `web_search`, `skill`).228+`write`, `edit`, `bash`, `create_worktree`, `grep`, `lsp`, `web_search`, `skill`).
229 Conflicts with active built-ins are silently shadowed by the built-in. The229 Conflicts with active built-ins are silently shadowed by the built-in. The
230 native `web_search` name is reserved even when the setting disables that230 native `web_search` name is reserved even when the setting disables that
231-built-in, so an extension cannot claim or replace it.231+built-in, so an extension cannot claim or replace it. The `grep` name is
232+reserved even when `Args.NoTools`/SDK `Config.NoTools` or an explicit
233+`Args.Tools`/SDK `Config.Tools` allowlist disables the native search tool, so
234+an extension cannot claim or replace it.
232 235
233 Set `"deferred": true` to register a tool without advertising its definition initially. A loader tool can activate registered deferred tools by returning their names in `activate_tools`:236 Set `"deferred": true` to register a tool without advertising its definition initially. A loader tool can activate registered deferred tools by returning their names in `activate_tools`:
234 237
M docs/web-search.md +1 -1
@@ -57,7 +57,7 @@ Only direct HTTP(S) result URLs are returned. Titles are limited to 300 Unicode
5757
5858 ## Transcript and integrations
5959
60-The query is stored in the normal tool-call arguments. Returned titles, URLs, snippets, and errors are normal tool-result content: they can be stored in session transcripts and exports and exposed through JSON and RPC events/transcript retrieval. SDK event consumers see the same tool result only when the SDK explicitly enables `web_search`. Extensions can intercept the normal tool call, but cannot claim the reserved `web_search` name or replace the built-in. Do not submit credentials, secrets, or sensitive local paths in a query: arbitrary query text is retained in those transcript and export surfaces.
60+The query is stored in the normal tool-call arguments. Returned titles, URLs, snippets, and errors are normal tool-result content: they can be stored in session transcripts and exports and exposed through JSON and RPC events/transcript retrieval. SDK event consumers see the same tool result only when the SDK explicitly enables `web_search`. Extensions can intercept the normal tool call, but cannot claim the reserved `web_search` or `grep` names or replace the built-ins. Do not submit credentials, secrets, or sensitive local paths in a query: arbitrary query text is retained in those transcript and export surfaces.
6161
6262 The untrusted-content label is not a privacy boundary. Raw HTML, response headers, cookies, proxy configuration, and internal request metadata are not returned or persisted by this tool. A configured environment proxy may observe the request, but proxy settings are never displayed or included in transcripts, JSON/RPC events, or extension content.
6363
@@ -57,7 +57,7 @@ Only direct HTTP(S) result URLs are returned. Titles are limited to 300 Unicode
57 57
58 ## Transcript and integrations58 ## Transcript and integrations
59 59
60-The query is stored in the normal tool-call arguments. Returned titles, URLs, snippets, and errors are normal tool-result content: they can be stored in session transcripts and exports and exposed through JSON and RPC events/transcript retrieval. SDK event consumers see the same tool result only when the SDK explicitly enables `web_search`. Extensions can intercept the normal tool call, but cannot claim the reserved `web_search` name or replace the built-in. Do not submit credentials, secrets, or sensitive local paths in a query: arbitrary query text is retained in those transcript and export surfaces.60+The query is stored in the normal tool-call arguments. Returned titles, URLs, snippets, and errors are normal tool-result content: they can be stored in session transcripts and exports and exposed through JSON and RPC events/transcript retrieval. SDK event consumers see the same tool result only when the SDK explicitly enables `web_search`. Extensions can intercept the normal tool call, but cannot claim the reserved `web_search` or `grep` names or replace the built-ins. Do not submit credentials, secrets, or sensitive local paths in a query: arbitrary query text is retained in those transcript and export surfaces.
61 61
62 The untrusted-content label is not a privacy boundary. Raw HTML, response headers, cookies, proxy configuration, and internal request metadata are not returned or persisted by this tool. A configured environment proxy may observe the request, but proxy settings are never displayed or included in transcripts, JSON/RPC events, or extension content.62 The untrusted-content label is not a privacy boundary. Raw HTML, response headers, cookies, proxy configuration, and internal request metadata are not returned or persisted by this tool. A configured environment proxy may observe the request, but proxy settings are never displayed or included in transcripts, JSON/RPC events, or extension content.
63 63
M docs/zutfiles.md +2 -2
@@ -81,7 +81,7 @@ zut verify ./code-reviewer.zut
8181 zut run ./code-reviewer.zut
8282 ```
8383
84-The first run displays the declared permissions and asks for consent before the agent starts.
84+The first run displays the declared permissions and asks for consent before the agent starts. A packaged agent with filesystem read permission can use the built-in `grep` tool without any Bash permission; `grep` checks the requested search root against the declared read scopes before running its direct read-only subprocess.
8585
8686 ## Directory layout
8787
@@ -303,7 +303,7 @@ In `--print` / `--stream` / `--json` modes, `pre` still runs first: shell escape
303303
304304 ## Filesystem permissions
305305
306-Filesystem permissions are enforced by the built-in `read`, `write`, `edit`, and `create_worktree` tools. Empty or omitted scopes deny the operation.
306+Filesystem permissions are enforced by the built-in `read`, `write`, `edit`, `grep`, and `create_worktree` tools. Empty or omitted scopes deny the operation.
307307
308308 Two variables are available in filesystem scopes:
309309
@@ -81,7 +81,7 @@ zut verify ./code-reviewer.zut
81 zut run ./code-reviewer.zut81 zut run ./code-reviewer.zut
82 ```82 ```
83 83
84-The first run displays the declared permissions and asks for consent before the agent starts.84+The first run displays the declared permissions and asks for consent before the agent starts. A packaged agent with filesystem read permission can use the built-in `grep` tool without any Bash permission; `grep` checks the requested search root against the declared read scopes before running its direct read-only subprocess.
85 85
86 ## Directory layout86 ## Directory layout
87 87
@@ -303,7 +303,7 @@ In `--print` / `--stream` / `--json` modes, `pre` still runs first: shell escape
303 303
304 ## Filesystem permissions304 ## Filesystem permissions
305 305
306-Filesystem permissions are enforced by the built-in `read`, `write`, `edit`, and `create_worktree` tools. Empty or omitted scopes deny the operation.306+Filesystem permissions are enforced by the built-in `read`, `write`, `edit`, `grep`, and `create_worktree` tools. Empty or omitted scopes deny the operation.
307 307
308 Two variables are available in filesystem scopes:308 Two variables are available in filesystem scopes:
309 309
M examples/extensions/mcp-bridge/bridge.go +2 -2
@@ -6,8 +6,8 @@
66 //
77 // The double underscore separates the server name from the tool name,
88 // avoiding collisions with zut's built-in tools (read, write, edit, bash,
9-// lsp, web_search, skill). The web_search name is reserved even when the
10-// built-in is disabled.
9+// create_worktree, grep, lsp, web_search, skill). The web_search and grep
10+// names are reserved even when those built-ins are disabled.
1111 package main
1212
1313 import (
@@ -6,8 +6,8 @@
6 //6 //
7 // The double underscore separates the server name from the tool name,7 // The double underscore separates the server name from the tool name,
8 // avoiding collisions with zut's built-in tools (read, write, edit, bash,8 // avoiding collisions with zut's built-in tools (read, write, edit, bash,
9-// lsp, web_search, skill). The web_search name is reserved even when the9+// create_worktree, grep, lsp, web_search, skill). The web_search and grep
10-// built-in is disabled.10+// names are reserved even when those built-ins are disabled.
11 package main11 package main
12 12
13 import (13 import (
M packages/agent/build.go +8 -5
@@ -97,11 +97,11 @@ func (r *Resolved) MergeExtensionTools(mgr ExtensionToolSource) {
9797 }
9898 changed := false
9999 for _, info := range mgr.Tools() {
100- // web_search remains a reserved native-tool name even when its
101- // capability policy excludes the current session. An extension must
102- // not turn a normal CLI opt-out into a differently implemented search
100+ // web_search and grep remain reserved native-tool names even when
101+ // their capability policy excludes the current session. An extension
102+ // must not turn a normal CLI opt-out into a differently implemented
103103 // capability with the same model-visible name.
104- if info.Name == "web_search" || info.Name == "update_goal" {
104+ if info.Name == "web_search" || info.Name == "grep" || info.Name == "update_goal" {
105105 continue
106106 }
107107 if _, exists := r.ToolRegistry[info.Name]; exists {
@@ -1194,6 +1194,8 @@ func (r *Resolved) UseSandbox(s *tools.Sandbox) {
11941194 v.Sandbox = s
11951195 case *tools.LSPTool:
11961196 v.Sandbox = s
1197+ case *tools.GrepTool:
1198+ v.Sandbox = s
11971199 }
11981200 _ = name
11991201 }
@@ -1229,6 +1231,7 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled
12291231 "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit},
12301232 "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox},
12311233 "create_worktree": &tools.CreateWorktreeTool{CWD: cwd, Sandbox: sandbox},
1234+ "grep": &tools.GrepTool{CWD: cwd, Sandbox: sandbox},
12321235 "update_goal": &tools.UpdateGoalTool{},
12331236 }
12341237 if webSearchAllowedForRegistry(args) {
@@ -1316,7 +1319,7 @@ func autoSubagentsToolAllowedFor(args Args, toolName string) bool {
13161319 return false
13171320 }
13181321
1319-var nativeToolSummaryOrder = []string{"read", "write", "edit", "bash", "create_worktree", "lsp", "web_search", "update_goal"}
1322+var nativeToolSummaryOrder = []string{"read", "write", "edit", "grep", "bash", "create_worktree", "lsp", "web_search", "update_goal"}
13201323
13211324 func toolSummaries(reg core.Registry, args Args) []ToolSummary {
13221325 var out []ToolSummary
@@ -97,11 +97,11 @@ func (r *Resolved) MergeExtensionTools(mgr ExtensionToolSource) {
97 }97 }
98 changed := false98 changed := false
99 for _, info := range mgr.Tools() {99 for _, info := range mgr.Tools() {
100- // web_search remains a reserved native-tool name even when its100+ // web_search and grep remain reserved native-tool names even when
101- // capability policy excludes the current session. An extension must101+ // their capability policy excludes the current session. An extension
102- // not turn a normal CLI opt-out into a differently implemented search102+ // must not turn a normal CLI opt-out into a differently implemented
103 // capability with the same model-visible name.103 // capability with the same model-visible name.
104- if info.Name == "web_search" || info.Name == "update_goal" {104+ if info.Name == "web_search" || info.Name == "grep" || info.Name == "update_goal" {
105 continue105 continue
106 }106 }
107 if _, exists := r.ToolRegistry[info.Name]; exists {107 if _, exists := r.ToolRegistry[info.Name]; exists {
@@ -1194,6 +1194,8 @@ func (r *Resolved) UseSandbox(s *tools.Sandbox) {
1194 v.Sandbox = s1194 v.Sandbox = s
1195 case *tools.LSPTool:1195 case *tools.LSPTool:
1196 v.Sandbox = s1196 v.Sandbox = s
1197+ case *tools.GrepTool:
1198+ v.Sandbox = s
1197 }1199 }
1198 _ = name1200 _ = name
1199 }1201 }
@@ -1229,6 +1231,7 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled
1229 "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit},1231 "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit},
1230 "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox},1232 "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox},
1231 "create_worktree": &tools.CreateWorktreeTool{CWD: cwd, Sandbox: sandbox},1233 "create_worktree": &tools.CreateWorktreeTool{CWD: cwd, Sandbox: sandbox},
1234+ "grep": &tools.GrepTool{CWD: cwd, Sandbox: sandbox},
1232 "update_goal": &tools.UpdateGoalTool{},1235 "update_goal": &tools.UpdateGoalTool{},
1233 }1236 }
1234 if webSearchAllowedForRegistry(args) {1237 if webSearchAllowedForRegistry(args) {
@@ -1316,7 +1319,7 @@ func autoSubagentsToolAllowedFor(args Args, toolName string) bool {
1316 return false1319 return false
1317 }1320 }
1318 1321
1319-var nativeToolSummaryOrder = []string{"read", "write", "edit", "bash", "create_worktree", "lsp", "web_search", "update_goal"}1322+var nativeToolSummaryOrder = []string{"read", "write", "edit", "grep", "bash", "create_worktree", "lsp", "web_search", "update_goal"}
1320 1323
1321 func toolSummaries(reg core.Registry, args Args) []ToolSummary {1324 func toolSummaries(reg core.Registry, args Args) []ToolSummary {
1322 var out []ToolSummary1325 var out []ToolSummary
A packages/agent/build_grep_test.go +49 -0
@@ -0,0 +1,49 @@
1+package agent
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/zut/packages/agent/tools"
7+)
8+
9+func TestBuildToolRegistryGrepSelectionAndDefaults(t *testing.T) {
10+ cwd := t.TempDir()
11+
12+ defaultRegistry := buildToolRegistry(Args{}, cwd, nil, false, false, false)
13+ if _, ok := defaultRegistry["grep"]; !ok {
14+ t.Fatal("default registry does not contain grep")
15+ }
16+ defaultSummaries := toolSummaries(defaultRegistry, Args{})
17+ grepIndex := -1
18+ for i, summary := range defaultSummaries {
19+ if summary.Name == "grep" {
20+ grepIndex = i
21+ break
22+ }
23+ }
24+ if grepIndex < 0 {
25+ t.Fatal("default tool summaries do not contain grep")
26+ }
27+ if grepIndex == 0 || defaultSummaries[grepIndex-1].Name != "edit" {
28+ t.Fatalf("grep summary order has predecessor %q, want edit", defaultSummaries[grepIndex-1].Name)
29+ }
30+
31+ selected := buildToolRegistry(Args{Tools: []string{"grep"}}, cwd, nil, false, false, false)
32+ if len(selected) != 1 {
33+ t.Fatalf("selected registry has %d tools, want one", len(selected))
34+ }
35+ if _, ok := selected["grep"].(*tools.GrepTool); !ok {
36+ t.Fatalf("selected grep has type %T", selected["grep"])
37+ }
38+ if summaries := toolSummaries(selected, Args{Tools: []string{"grep"}}); len(summaries) != 1 || summaries[0].Name != "grep" {
39+ t.Fatalf("selected summaries = %#v, want only grep", summaries)
40+ }
41+
42+ noTools := buildToolRegistry(Args{NoTools: true}, cwd, nil, false, false, false)
43+ if len(noTools) != 0 {
44+ t.Fatalf("no-tools registry has %d tools", len(noTools))
45+ }
46+ if summaries := toolSummaries(noTools, Args{NoTools: true}); len(summaries) != 0 {
47+ t.Fatalf("no-tools summaries = %#v", summaries)
48+ }
49+}
@@ -0,0 +1,49 @@
1+package agent
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/zut/packages/agent/tools"
7+)
8+
9+func TestBuildToolRegistryGrepSelectionAndDefaults(t *testing.T) {
10+ cwd := t.TempDir()
11+
12+ defaultRegistry := buildToolRegistry(Args{}, cwd, nil, false, false, false)
13+ if _, ok := defaultRegistry["grep"]; !ok {
14+ t.Fatal("default registry does not contain grep")
15+ }
16+ defaultSummaries := toolSummaries(defaultRegistry, Args{})
17+ grepIndex := -1
18+ for i, summary := range defaultSummaries {
19+ if summary.Name == "grep" {
20+ grepIndex = i
21+ break
22+ }
23+ }
24+ if grepIndex < 0 {
25+ t.Fatal("default tool summaries do not contain grep")
26+ }
27+ if grepIndex == 0 || defaultSummaries[grepIndex-1].Name != "edit" {
28+ t.Fatalf("grep summary order has predecessor %q, want edit", defaultSummaries[grepIndex-1].Name)
29+ }
30+
31+ selected := buildToolRegistry(Args{Tools: []string{"grep"}}, cwd, nil, false, false, false)
32+ if len(selected) != 1 {
33+ t.Fatalf("selected registry has %d tools, want one", len(selected))
34+ }
35+ if _, ok := selected["grep"].(*tools.GrepTool); !ok {
36+ t.Fatalf("selected grep has type %T", selected["grep"])
37+ }
38+ if summaries := toolSummaries(selected, Args{Tools: []string{"grep"}}); len(summaries) != 1 || summaries[0].Name != "grep" {
39+ t.Fatalf("selected summaries = %#v, want only grep", summaries)
40+ }
41+
42+ noTools := buildToolRegistry(Args{NoTools: true}, cwd, nil, false, false, false)
43+ if len(noTools) != 0 {
44+ t.Fatalf("no-tools registry has %d tools", len(noTools))
45+ }
46+ if summaries := toolSummaries(noTools, Args{NoTools: true}); len(summaries) != 0 {
47+ t.Fatalf("no-tools summaries = %#v", summaries)
48+ }
49+}
M packages/agent/ext/ext.go +3 -3
@@ -421,9 +421,9 @@ func (e *Extension) Command(name, description string, fn CommandHandler) {
421421 // into the agent's registry once the extension's ready frame fires.
422422 //
423423 // Conflicts with active built-in tool names (read, write, edit, bash,
424-// create_worktree, lsp, web_search, and skill) are silently shadowed by the
425-// built-in. The native web_search name is reserved even when that built-in is
426-// disabled, so an extension cannot claim or replace it.
424+// create_worktree, grep, lsp, web_search, and skill) are silently shadowed by
425+// the built-in. The native web_search and grep names are reserved even when
426+// those built-ins are disabled, so an extension cannot claim or replace them.
427427 func (e *Extension) Tool(name, description string, schema json.RawMessage, fn ToolHandler) {
428428 e.registerTool(name, description, schema, false, fn)
429429 }
@@ -421,9 +421,9 @@ func (e *Extension) Command(name, description string, fn CommandHandler) {
421 // into the agent's registry once the extension's ready frame fires.421 // into the agent's registry once the extension's ready frame fires.
422 //422 //
423 // Conflicts with active built-in tool names (read, write, edit, bash,423 // Conflicts with active built-in tool names (read, write, edit, bash,
424-// create_worktree, lsp, web_search, and skill) are silently shadowed by the424+// create_worktree, grep, lsp, web_search, and skill) are silently shadowed by
425-// built-in. The native web_search name is reserved even when that built-in is425+// the built-in. The native web_search and grep names are reserved even when
426-// disabled, so an extension cannot claim or replace it.426+// those built-ins are disabled, so an extension cannot claim or replace them.
427 func (e *Extension) Tool(name, description string, schema json.RawMessage, fn ToolHandler) {427 func (e *Extension) Tool(name, description string, schema json.RawMessage, fn ToolHandler) {
428 e.registerTool(name, description, schema, false, fn)428 e.registerTool(name, description, schema, false, fn)
429 }429 }
M packages/agent/sdk/sdk.go +1 -1
@@ -77,7 +77,7 @@ type Config struct {
7777 BaseURL string
7878
7979 // Tools is the list of tools to enable. Nil/empty = all ordinary built-in
80- // tools (read, write, edit, bash, create_worktree, lsp). web_search is
80+ // tools (read, write, edit, bash, create_worktree, grep, lsp). web_search is
8181 // excluded unless this list explicitly contains "web_search". Pass an
8282 // empty-but-non-nil slice
8383 // (e.g. []string{}) plus NoTools=true to disable everything.
@@ -77,7 +77,7 @@ type Config struct {
77 BaseURL string77 BaseURL string
78 78
79 // Tools is the list of tools to enable. Nil/empty = all ordinary built-in79 // Tools is the list of tools to enable. Nil/empty = all ordinary built-in
80- // tools (read, write, edit, bash, create_worktree, lsp). web_search is80+ // tools (read, write, edit, bash, create_worktree, grep, lsp). web_search is
81 // excluded unless this list explicitly contains "web_search". Pass an81 // excluded unless this list explicitly contains "web_search". Pass an
82 // empty-but-non-nil slice82 // empty-but-non-nil slice
83 // (e.g. []string{}) plus NoTools=true to disable everything.83 // (e.g. []string{}) plus NoTools=true to disable everything.
M packages/agent/skills/builtin/write-zut-extension/SKILL.md +3 -3
@@ -29,7 +29,7 @@ Three things an extension can do (any combination):
2929 2. **Tools** — register tools the LLM itself calls. Schema is
3030 JSON Schema; zut routes the model's `tool_call` to the
3131 extension's `tool_result`. Same lifecycle as built-in tools
32- (read/write/edit/bash/create_worktree/lsp/web_search/skill).
32+ (read/write/edit/bash/create_worktree/grep/lsp/web_search/skill).
3333
3434 3. **Lifecycle hooks** — subscribe to events
3535 (session_start, turn_start, tool_call, turn_end,
@@ -71,8 +71,8 @@ Field rules:
7171 - `name` (required, unique) — id zut uses internally; matches the
7272 hello frame. Slash commands & tools live in the same name space
7373 as built-ins; conflicts with active built-ins are ignored. The
74- native `web_search` name is reserved even when that built-in is
75- disabled, so an extension cannot claim or replace it.
74+ native `web_search` and `grep` names are reserved even when those
75+ built-ins are disabled, so an extension cannot claim or replace them.
7676 - `exec` (required) — the executable path. Resolution:
7777 - absolute: as-is
7878 - starts with `./` or `../`: relative to the manifest's directory
@@ -29,7 +29,7 @@ Three things an extension can do (any combination):
29 2. **Tools** — register tools the LLM itself calls. Schema is29 2. **Tools** — register tools the LLM itself calls. Schema is
30 JSON Schema; zut routes the model's `tool_call` to the30 JSON Schema; zut routes the model's `tool_call` to the
31 extension's `tool_result`. Same lifecycle as built-in tools31 extension's `tool_result`. Same lifecycle as built-in tools
32- (read/write/edit/bash/create_worktree/lsp/web_search/skill).32+ (read/write/edit/bash/create_worktree/grep/lsp/web_search/skill).
33 33
34 3. **Lifecycle hooks** — subscribe to events34 3. **Lifecycle hooks** — subscribe to events
35 (session_start, turn_start, tool_call, turn_end,35 (session_start, turn_start, tool_call, turn_end,
@@ -71,8 +71,8 @@ Field rules:
71 - `name` (required, unique) — id zut uses internally; matches the71 - `name` (required, unique) — id zut uses internally; matches the
72 hello frame. Slash commands & tools live in the same name space72 hello frame. Slash commands & tools live in the same name space
73 as built-ins; conflicts with active built-ins are ignored. The73 as built-ins; conflicts with active built-ins are ignored. The
74- native `web_search` name is reserved even when that built-in is74+ native `web_search` and `grep` names are reserved even when those
75- disabled, so an extension cannot claim or replace it.75+ built-ins are disabled, so an extension cannot claim or replace them.
76 - `exec` (required) — the executable path. Resolution:76 - `exec` (required) — the executable path. Resolution:
77 - absolute: as-is77 - absolute: as-is
78 - starts with `./` or `../`: relative to the manifest's directory78 - starts with `./` or `../`: relative to the manifest's directory
A packages/agent/tools/grep.go +329 -0
@@ -0,0 +1,329 @@
1+package tools
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "errors"
8+ "fmt"
9+ "os"
10+ "os/exec"
11+ "path/filepath"
12+ "strings"
13+ "sync"
14+ "time"
15+ "unicode/utf8"
16+
17+ "github.com/bnema/zut/packages/core"
18+ "github.com/bnema/zut/packages/provider"
19+)
20+
21+const (
22+ maxGrepInputBytes = 16 * 1024
23+ maxGrepOutputBytes = 50 * 1024
24+ maxGrepOutputLines = 2000
25+ grepTruncationReserve = 128
26+ grepInternalTimeout = 30 * time.Second
27+)
28+
29+// GrepTool searches text files below a requested path without invoking a
30+// shell. It prefers ripgrep and uses the system grep as a portable fallback.
31+// LookPath is injectable for deterministic tests; nil uses exec.LookPath.
32+type GrepTool struct {
33+ CWD string
34+ Sandbox *Sandbox
35+ LookPath func(string) (string, error)
36+}
37+
38+var _ core.Tool = (*GrepTool)(nil)
39+
40+type grepArgs struct {
41+ Pattern string `json:"pattern"`
42+ Path string `json:"path"`
43+}
44+
45+const grepSchema = `{"type":"object","properties":{"pattern":{"type":"string","description":"Regular expression to search for."},"path":{"type":"string","description":"File or directory to search recursively."}},"required":["pattern","path"],"additionalProperties":false}`
46+
47+func (t *GrepTool) Name() string { return "grep" }
48+
49+func (t *GrepTool) Description() string {
50+ return "Search files for a regular expression using ripgrep when available, with a portable grep fallback; fallback regex syntax and ignore/hidden-file behavior may differ."
51+}
52+
53+func (t *GrepTool) Schema() json.RawMessage { return json.RawMessage(grepSchema) }
54+
55+// Execute searches the requested file or directory. The search root is
56+// checked before the executable is selected or started, so a denied path
57+// cannot be used to probe or read through the subprocess.
58+func (t *GrepTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
59+ return t.executeWithTimeout(ctx, raw, progress, grepInternalTimeout)
60+}
61+
62+// grepTimeoutError distinguishes the search's hard deadline from a caller
63+// cancellation or deadline, so the caller gets actionable search guidance.
64+type grepTimeoutError struct {
65+ after time.Duration
66+}
67+
68+func (e *grepTimeoutError) Error() string {
69+ return fmt.Sprintf("grep: search timed out after %s; try narrowing the path or glob", e.after)
70+}
71+
72+func (e *grepTimeoutError) Timeout() bool { return true }
73+
74+// executeWithTimeout keeps the production deadline in Execute while allowing
75+// deadline behavior to be tested without waiting 30 seconds.
76+func (t *GrepTool) executeWithTimeout(ctx context.Context, raw json.RawMessage, progress func(string), timeout time.Duration) (core.ToolResult, error) {
77+ args, err := parseGrepArgs(raw)
78+ if err != nil {
79+ return core.ToolResult{}, err
80+ }
81+ if err := ctx.Err(); err != nil {
82+ return core.ToolResult{}, err
83+ }
84+
85+ root := filepath.Clean(resolvePath(t.CWD, args.Path))
86+ if !filepath.IsAbs(root) {
87+ root, err = filepath.Abs(root)
88+ if err != nil {
89+ return core.ToolResult{}, err
90+ }
91+ }
92+ if err := t.Sandbox.CheckReadPath(root); err != nil {
93+ return core.ToolResult{}, err
94+ }
95+ info, err := os.Stat(root)
96+ if err != nil {
97+ return core.ToolResult{}, err
98+ }
99+ if !info.IsDir() && !info.Mode().IsRegular() {
100+ return core.ToolResult{}, fmt.Errorf("grep: %s is not a regular file or directory", args.Path)
101+ }
102+
103+ engine, executable, err := t.searchExecutable()
104+ if err != nil {
105+ return core.ToolResult{}, err
106+ }
107+
108+ commandArgs := grepCommandArgs(engine, args.Pattern, root)
109+ // WithTimeout preserves a sooner deadline or cancellation from ctx; the
110+ // internal deadline can only add a limit when the caller has not already
111+ // supplied a stricter one.
112+ runCtx, cancel := context.WithTimeout(ctx, timeout)
113+ defer cancel()
114+ cmd := exec.CommandContext(runCtx, executable, commandArgs...)
115+ if info.IsDir() {
116+ cmd.Dir = root
117+ } else {
118+ cmd.Dir = filepath.Dir(root)
119+ }
120+ // Reuse the process-tree cancellation behavior used by the direct command
121+ // tools. This does not invoke a shell; executable is run directly above.
122+ configureBashProcess(cmd, nil)
123+
124+ output := &grepOutput{
125+ progress: progress,
126+ cancel: func() {
127+ if cmd.Cancel != nil {
128+ _ = cmd.Cancel()
129+ }
130+ },
131+ }
132+ cmd.Stdout = output
133+ cmd.Stderr = output
134+ runErr := cmd.Run()
135+ if err := ctx.Err(); err != nil {
136+ return core.ToolResult{}, err
137+ }
138+ if errors.Is(runCtx.Err(), context.DeadlineExceeded) {
139+ return core.ToolResult{}, &grepTimeoutError{after: timeout}
140+ }
141+
142+ actualExitCode := 0
143+ if runErr != nil {
144+ var exitErr *exec.ExitError
145+ if !errors.As(runErr, &exitErr) {
146+ return core.ToolResult{}, fmt.Errorf("grep: run %s: %w", engine, runErr)
147+ }
148+ actualExitCode = exitErr.ExitCode()
149+ }
150+
151+ outputCancelled := output.wasCancelled()
152+ matched := actualExitCode == 0 || outputCancelled
153+ noMatch := actualExitCode == 1
154+ if noMatch {
155+ // Both rg and grep use exit status 1 for a successful search with no
156+ // matches. Do not turn that normal result into a failed tool call.
157+ matched = false
158+ }
159+
160+ text, linesTruncated := output.text()
161+ if noMatch && strings.TrimSpace(text) == "" {
162+ text = "No matches found.\n"
163+ }
164+ if text == "" && actualExitCode != 0 {
165+ text = fmt.Sprintf("%s exited with status %d.\n", engine, actualExitCode)
166+ }
167+
168+ exitCode := actualExitCode
169+ if noMatch || outputCancelled {
170+ exitCode = 0
171+ }
172+ isError := actualExitCode > 1 || actualExitCode < 0
173+ if outputCancelled {
174+ // Reaching the output bound is an intentional, successful stop rather
175+ // than a failed search. Preserve the process status separately for
176+ // diagnostics while reporting the normalized tool exit code above.
177+ isError = false
178+ }
179+ return core.ToolResult{
180+ Content: []provider.Content{provider.TextBlock{Text: text}},
181+ IsError: isError,
182+ Details: map[string]any{
183+ "engine": engine,
184+ "path": t.Sandbox.DisplayPath(root, args.Path),
185+ "exit_code": exitCode,
186+ "process_exit_code": actualExitCode,
187+ "matched": matched,
188+ "bytes_truncated": output.bytesTruncated(),
189+ "lines_truncated": linesTruncated,
190+ "output_cancelled": outputCancelled,
191+ },
192+ }, nil
193+}
194+
195+func parseGrepArgs(raw json.RawMessage) (grepArgs, error) {
196+ if len(raw) == 0 || len(raw) > maxGrepInputBytes {
197+ return grepArgs{}, fmt.Errorf("grep: invalid arguments")
198+ }
199+ var fields map[string]json.RawMessage
200+ if err := json.Unmarshal(raw, &fields); err != nil || fields == nil {
201+ return grepArgs{}, fmt.Errorf("grep: invalid arguments")
202+ }
203+ for key := range fields {
204+ if key != "pattern" && key != "path" {
205+ return grepArgs{}, fmt.Errorf("grep: unknown argument %q", key)
206+ }
207+ }
208+ var args grepArgs
209+ if err := json.Unmarshal(raw, &args); err != nil {
210+ return grepArgs{}, fmt.Errorf("grep: invalid arguments: %w", err)
211+ }
212+ if _, ok := fields["pattern"]; !ok || args.Pattern == "" {
213+ return grepArgs{}, fmt.Errorf("grep: pattern is required")
214+ }
215+ if _, ok := fields["path"]; !ok || args.Path == "" {
216+ return grepArgs{}, fmt.Errorf("grep: path is required")
217+ }
218+ if !utf8.ValidString(args.Pattern) || !utf8.ValidString(args.Path) || strings.IndexByte(args.Pattern, 0) >= 0 || strings.IndexByte(args.Path, 0) >= 0 {
219+ return grepArgs{}, fmt.Errorf("grep: arguments contain invalid characters")
220+ }
221+ return args, nil
222+}
223+
224+func (t *GrepTool) searchExecutable() (string, string, error) {
225+ lookup := t.LookPath
226+ if lookup == nil {
227+ lookup = exec.LookPath
228+ }
229+ if path, err := lookup("rg"); err == nil && path != "" {
230+ return "rg", path, nil
231+ }
232+ if path, err := lookup("grep"); err == nil && path != "" {
233+ return "grep", path, nil
234+ }
235+ return "", "", fmt.Errorf("grep: neither rg nor grep is available")
236+}
237+
238+func grepCommandArgs(engine, pattern, root string) []string {
239+ if engine == "rg" {
240+ // These flags avoid headings and terminal color while retaining rg's
241+ // fast default ignore and binary-file filtering behavior.
242+ return []string{"--no-heading", "--line-number", "--color=never", "-e", pattern, root}
243+ }
244+ // -e keeps patterns beginning with '-' from being parsed as options. The
245+ // absolute resolved root also makes this portable across GNU and BSD grep.
246+ return []string{"-r", "-n", "-H", "-I", "-e", pattern, root}
247+}
248+
249+type grepOutput struct {
250+ mu sync.Mutex
251+ data bytes.Buffer
252+ truncated bool
253+ cancelled bool
254+ cancel func()
255+ progress func(string)
256+}
257+
258+func (w *grepOutput) Write(p []byte) (int, error) {
259+ w.mu.Lock()
260+ room := maxGrepOutputBytes - grepTruncationReserve - w.data.Len()
261+ var streamed string
262+ shouldCancel := false
263+ if room <= 0 {
264+ if !w.truncated {
265+ w.truncated = true
266+ w.cancelled = true
267+ shouldCancel = true
268+ }
269+ } else if len(p) > room {
270+ _, _ = w.data.Write(p[:room])
271+ w.truncated = true
272+ w.cancelled = true
273+ shouldCancel = true
274+ streamed = string(p[:room])
275+ } else {
276+ _, _ = w.data.Write(p)
277+ streamed = string(p)
278+ }
279+ cancel := w.cancel
280+ progress := w.progress
281+ w.mu.Unlock()
282+
283+ if shouldCancel && cancel != nil {
284+ cancel()
285+ }
286+ if progress != nil && streamed != "" {
287+ progress(streamed)
288+ }
289+ return len(p), nil
290+}
291+
292+func (w *grepOutput) bytesTruncated() bool {
293+ w.mu.Lock()
294+ defer w.mu.Unlock()
295+ return w.truncated
296+}
297+
298+func (w *grepOutput) wasCancelled() bool {
299+ w.mu.Lock()
300+ defer w.mu.Unlock()
301+ return w.cancelled
302+}
303+
304+func (w *grepOutput) text() (string, bool) {
305+ w.mu.Lock()
306+ defer w.mu.Unlock()
307+ text := w.data.String()
308+ lineCount := strings.Count(text, "\n")
309+ if text != "" && !strings.HasSuffix(text, "\n") {
310+ lineCount++
311+ }
312+ truncatedLines := lineCount > maxGrepOutputLines
313+ if truncatedLines {
314+ lines := strings.SplitAfter(text, "\n")
315+ text = strings.Join(lines[:maxGrepOutputLines], "")
316+ }
317+ if w.truncated || truncatedLines {
318+ if text != "" && !strings.HasSuffix(text, "\n") {
319+ text += "\n"
320+ }
321+ if w.truncated {
322+ text += fmt.Sprintf("... [truncated at %d bytes]\n", maxGrepOutputBytes)
323+ }
324+ if truncatedLines {
325+ text += fmt.Sprintf("... [truncated at %d lines]\n", maxGrepOutputLines)
326+ }
327+ }
328+ return text, truncatedLines
329+}
@@ -0,0 +1,329 @@
1+package tools
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "errors"
8+ "fmt"
9+ "os"
10+ "os/exec"
11+ "path/filepath"
12+ "strings"
13+ "sync"
14+ "time"
15+ "unicode/utf8"
16+
17+ "github.com/bnema/zut/packages/core"
18+ "github.com/bnema/zut/packages/provider"
19+)
20+
21+const (
22+ maxGrepInputBytes = 16 * 1024
23+ maxGrepOutputBytes = 50 * 1024
24+ maxGrepOutputLines = 2000
25+ grepTruncationReserve = 128
26+ grepInternalTimeout = 30 * time.Second
27+)
28+
29+// GrepTool searches text files below a requested path without invoking a
30+// shell. It prefers ripgrep and uses the system grep as a portable fallback.
31+// LookPath is injectable for deterministic tests; nil uses exec.LookPath.
32+type GrepTool struct {
33+ CWD string
34+ Sandbox *Sandbox
35+ LookPath func(string) (string, error)
36+}
37+
38+var _ core.Tool = (*GrepTool)(nil)
39+
40+type grepArgs struct {
41+ Pattern string `json:"pattern"`
42+ Path string `json:"path"`
43+}
44+
45+const grepSchema = `{"type":"object","properties":{"pattern":{"type":"string","description":"Regular expression to search for."},"path":{"type":"string","description":"File or directory to search recursively."}},"required":["pattern","path"],"additionalProperties":false}`
46+
47+func (t *GrepTool) Name() string { return "grep" }
48+
49+func (t *GrepTool) Description() string {
50+ return "Search files for a regular expression using ripgrep when available, with a portable grep fallback; fallback regex syntax and ignore/hidden-file behavior may differ."
51+}
52+
53+func (t *GrepTool) Schema() json.RawMessage { return json.RawMessage(grepSchema) }
54+
55+// Execute searches the requested file or directory. The search root is
56+// checked before the executable is selected or started, so a denied path
57+// cannot be used to probe or read through the subprocess.
58+func (t *GrepTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
59+ return t.executeWithTimeout(ctx, raw, progress, grepInternalTimeout)
60+}
61+
62+// grepTimeoutError distinguishes the search's hard deadline from a caller
63+// cancellation or deadline, so the caller gets actionable search guidance.
64+type grepTimeoutError struct {
65+ after time.Duration
66+}
67+
68+func (e *grepTimeoutError) Error() string {
69+ return fmt.Sprintf("grep: search timed out after %s; try narrowing the path or glob", e.after)
70+}
71+
72+func (e *grepTimeoutError) Timeout() bool { return true }
73+
74+// executeWithTimeout keeps the production deadline in Execute while allowing
75+// deadline behavior to be tested without waiting 30 seconds.
76+func (t *GrepTool) executeWithTimeout(ctx context.Context, raw json.RawMessage, progress func(string), timeout time.Duration) (core.ToolResult, error) {
77+ args, err := parseGrepArgs(raw)
78+ if err != nil {
79+ return core.ToolResult{}, err
80+ }
81+ if err := ctx.Err(); err != nil {
82+ return core.ToolResult{}, err
83+ }
84+
85+ root := filepath.Clean(resolvePath(t.CWD, args.Path))
86+ if !filepath.IsAbs(root) {
87+ root, err = filepath.Abs(root)
88+ if err != nil {
89+ return core.ToolResult{}, err
90+ }
91+ }
92+ if err := t.Sandbox.CheckReadPath(root); err != nil {
93+ return core.ToolResult{}, err
94+ }
95+ info, err := os.Stat(root)
96+ if err != nil {
97+ return core.ToolResult{}, err
98+ }
99+ if !info.IsDir() && !info.Mode().IsRegular() {
100+ return core.ToolResult{}, fmt.Errorf("grep: %s is not a regular file or directory", args.Path)
101+ }
102+
103+ engine, executable, err := t.searchExecutable()
104+ if err != nil {
105+ return core.ToolResult{}, err
106+ }
107+
108+ commandArgs := grepCommandArgs(engine, args.Pattern, root)
109+ // WithTimeout preserves a sooner deadline or cancellation from ctx; the
110+ // internal deadline can only add a limit when the caller has not already
111+ // supplied a stricter one.
112+ runCtx, cancel := context.WithTimeout(ctx, timeout)
113+ defer cancel()
114+ cmd := exec.CommandContext(runCtx, executable, commandArgs...)
115+ if info.IsDir() {
116+ cmd.Dir = root
117+ } else {
118+ cmd.Dir = filepath.Dir(root)
119+ }
120+ // Reuse the process-tree cancellation behavior used by the direct command
121+ // tools. This does not invoke a shell; executable is run directly above.
122+ configureBashProcess(cmd, nil)
123+
124+ output := &grepOutput{
125+ progress: progress,
126+ cancel: func() {
127+ if cmd.Cancel != nil {
128+ _ = cmd.Cancel()
129+ }
130+ },
131+ }
132+ cmd.Stdout = output
133+ cmd.Stderr = output
134+ runErr := cmd.Run()
135+ if err := ctx.Err(); err != nil {
136+ return core.ToolResult{}, err
137+ }
138+ if errors.Is(runCtx.Err(), context.DeadlineExceeded) {
139+ return core.ToolResult{}, &grepTimeoutError{after: timeout}
140+ }
141+
142+ actualExitCode := 0
143+ if runErr != nil {
144+ var exitErr *exec.ExitError
145+ if !errors.As(runErr, &exitErr) {
146+ return core.ToolResult{}, fmt.Errorf("grep: run %s: %w", engine, runErr)
147+ }
148+ actualExitCode = exitErr.ExitCode()
149+ }
150+
151+ outputCancelled := output.wasCancelled()
152+ matched := actualExitCode == 0 || outputCancelled
153+ noMatch := actualExitCode == 1
154+ if noMatch {
155+ // Both rg and grep use exit status 1 for a successful search with no
156+ // matches. Do not turn that normal result into a failed tool call.
157+ matched = false
158+ }
159+
160+ text, linesTruncated := output.text()
161+ if noMatch && strings.TrimSpace(text) == "" {
162+ text = "No matches found.\n"
163+ }
164+ if text == "" && actualExitCode != 0 {
165+ text = fmt.Sprintf("%s exited with status %d.\n", engine, actualExitCode)
166+ }
167+
168+ exitCode := actualExitCode
169+ if noMatch || outputCancelled {
170+ exitCode = 0
171+ }
172+ isError := actualExitCode > 1 || actualExitCode < 0
173+ if outputCancelled {
174+ // Reaching the output bound is an intentional, successful stop rather
175+ // than a failed search. Preserve the process status separately for
176+ // diagnostics while reporting the normalized tool exit code above.
177+ isError = false
178+ }
179+ return core.ToolResult{
180+ Content: []provider.Content{provider.TextBlock{Text: text}},
181+ IsError: isError,
182+ Details: map[string]any{
183+ "engine": engine,
184+ "path": t.Sandbox.DisplayPath(root, args.Path),
185+ "exit_code": exitCode,
186+ "process_exit_code": actualExitCode,
187+ "matched": matched,
188+ "bytes_truncated": output.bytesTruncated(),
189+ "lines_truncated": linesTruncated,
190+ "output_cancelled": outputCancelled,
191+ },
192+ }, nil
193+}
194+
195+func parseGrepArgs(raw json.RawMessage) (grepArgs, error) {
196+ if len(raw) == 0 || len(raw) > maxGrepInputBytes {
197+ return grepArgs{}, fmt.Errorf("grep: invalid arguments")
198+ }
199+ var fields map[string]json.RawMessage
200+ if err := json.Unmarshal(raw, &fields); err != nil || fields == nil {
201+ return grepArgs{}, fmt.Errorf("grep: invalid arguments")
202+ }
203+ for key := range fields {
204+ if key != "pattern" && key != "path" {
205+ return grepArgs{}, fmt.Errorf("grep: unknown argument %q", key)
206+ }
207+ }
208+ var args grepArgs
209+ if err := json.Unmarshal(raw, &args); err != nil {
210+ return grepArgs{}, fmt.Errorf("grep: invalid arguments: %w", err)
211+ }
212+ if _, ok := fields["pattern"]; !ok || args.Pattern == "" {
213+ return grepArgs{}, fmt.Errorf("grep: pattern is required")
214+ }
215+ if _, ok := fields["path"]; !ok || args.Path == "" {
216+ return grepArgs{}, fmt.Errorf("grep: path is required")
217+ }
218+ if !utf8.ValidString(args.Pattern) || !utf8.ValidString(args.Path) || strings.IndexByte(args.Pattern, 0) >= 0 || strings.IndexByte(args.Path, 0) >= 0 {
219+ return grepArgs{}, fmt.Errorf("grep: arguments contain invalid characters")
220+ }
221+ return args, nil
222+}
223+
224+func (t *GrepTool) searchExecutable() (string, string, error) {
225+ lookup := t.LookPath
226+ if lookup == nil {
227+ lookup = exec.LookPath
228+ }
229+ if path, err := lookup("rg"); err == nil && path != "" {
230+ return "rg", path, nil
231+ }
232+ if path, err := lookup("grep"); err == nil && path != "" {
233+ return "grep", path, nil
234+ }
235+ return "", "", fmt.Errorf("grep: neither rg nor grep is available")
236+}
237+
238+func grepCommandArgs(engine, pattern, root string) []string {
239+ if engine == "rg" {
240+ // These flags avoid headings and terminal color while retaining rg's
241+ // fast default ignore and binary-file filtering behavior.
242+ return []string{"--no-heading", "--line-number", "--color=never", "-e", pattern, root}
243+ }
244+ // -e keeps patterns beginning with '-' from being parsed as options. The
245+ // absolute resolved root also makes this portable across GNU and BSD grep.
246+ return []string{"-r", "-n", "-H", "-I", "-e", pattern, root}
247+}
248+
249+type grepOutput struct {
250+ mu sync.Mutex
251+ data bytes.Buffer
252+ truncated bool
253+ cancelled bool
254+ cancel func()
255+ progress func(string)
256+}
257+
258+func (w *grepOutput) Write(p []byte) (int, error) {
259+ w.mu.Lock()
260+ room := maxGrepOutputBytes - grepTruncationReserve - w.data.Len()
261+ var streamed string
262+ shouldCancel := false
263+ if room <= 0 {
264+ if !w.truncated {
265+ w.truncated = true
266+ w.cancelled = true
267+ shouldCancel = true
268+ }
269+ } else if len(p) > room {
270+ _, _ = w.data.Write(p[:room])
271+ w.truncated = true
272+ w.cancelled = true
273+ shouldCancel = true
274+ streamed = string(p[:room])
275+ } else {
276+ _, _ = w.data.Write(p)
277+ streamed = string(p)
278+ }
279+ cancel := w.cancel
280+ progress := w.progress
281+ w.mu.Unlock()
282+
283+ if shouldCancel && cancel != nil {
284+ cancel()
285+ }
286+ if progress != nil && streamed != "" {
287+ progress(streamed)
288+ }
289+ return len(p), nil
290+}
291+
292+func (w *grepOutput) bytesTruncated() bool {
293+ w.mu.Lock()
294+ defer w.mu.Unlock()
295+ return w.truncated
296+}
297+
298+func (w *grepOutput) wasCancelled() bool {
299+ w.mu.Lock()
300+ defer w.mu.Unlock()
301+ return w.cancelled
302+}
303+
304+func (w *grepOutput) text() (string, bool) {
305+ w.mu.Lock()
306+ defer w.mu.Unlock()
307+ text := w.data.String()
308+ lineCount := strings.Count(text, "\n")
309+ if text != "" && !strings.HasSuffix(text, "\n") {
310+ lineCount++
311+ }
312+ truncatedLines := lineCount > maxGrepOutputLines
313+ if truncatedLines {
314+ lines := strings.SplitAfter(text, "\n")
315+ text = strings.Join(lines[:maxGrepOutputLines], "")
316+ }
317+ if w.truncated || truncatedLines {
318+ if text != "" && !strings.HasSuffix(text, "\n") {
319+ text += "\n"
320+ }
321+ if w.truncated {
322+ text += fmt.Sprintf("... [truncated at %d bytes]\n", maxGrepOutputBytes)
323+ }
324+ if truncatedLines {
325+ text += fmt.Sprintf("... [truncated at %d lines]\n", maxGrepOutputLines)
326+ }
327+ }
328+ return text, truncatedLines
329+}
A packages/agent/tools/grep_permission_test.go +44 -0
@@ -0,0 +1,44 @@
1+package tools
2+
3+import (
4+ "context"
5+ "errors"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+)
11+
12+func TestGrepChecksDeclaredReadScopeBeforeRunning(t *testing.T) {
13+ skipGrepScriptTestsOnWindows(t)
14+ base := t.TempDir()
15+ root := filepath.Join(base, "workspace")
16+ allowed := filepath.Join(base, "allowed")
17+ if err := os.Mkdir(root, 0o755); err != nil {
18+ t.Fatal(err)
19+ }
20+ if err := os.Mkdir(allowed, 0o755); err != nil {
21+ t.Fatal(err)
22+ }
23+ sandbox := NewSandbox(base)
24+ sandbox.Lock()
25+ permissions := &PermissionSet{}
26+ permissions.FS.Read = []string{allowed}
27+ sandbox.SetPermissions(permissions)
28+ called := false
29+ tool := &GrepTool{
30+ CWD: base,
31+ Sandbox: sandbox,
32+ LookPath: func(string) (string, error) {
33+ called = true
34+ return "", errors.New("lookup should not run")
35+ },
36+ }
37+ _, err := tool.Execute(context.Background(), grepArgsJSON("needle", root), nil)
38+ if err == nil || !strings.Contains(err.Error(), "permission denied") {
39+ t.Fatalf("error = %v, want declared read-scope denial", err)
40+ }
41+ if called {
42+ t.Fatal("executable lookup ran before the read-scope check")
43+ }
44+}
@@ -0,0 +1,44 @@
1+package tools
2+
3+import (
4+ "context"
5+ "errors"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+)
11+
12+func TestGrepChecksDeclaredReadScopeBeforeRunning(t *testing.T) {
13+ skipGrepScriptTestsOnWindows(t)
14+ base := t.TempDir()
15+ root := filepath.Join(base, "workspace")
16+ allowed := filepath.Join(base, "allowed")
17+ if err := os.Mkdir(root, 0o755); err != nil {
18+ t.Fatal(err)
19+ }
20+ if err := os.Mkdir(allowed, 0o755); err != nil {
21+ t.Fatal(err)
22+ }
23+ sandbox := NewSandbox(base)
24+ sandbox.Lock()
25+ permissions := &PermissionSet{}
26+ permissions.FS.Read = []string{allowed}
27+ sandbox.SetPermissions(permissions)
28+ called := false
29+ tool := &GrepTool{
30+ CWD: base,
31+ Sandbox: sandbox,
32+ LookPath: func(string) (string, error) {
33+ called = true
34+ return "", errors.New("lookup should not run")
35+ },
36+ }
37+ _, err := tool.Execute(context.Background(), grepArgsJSON("needle", root), nil)
38+ if err == nil || !strings.Contains(err.Error(), "permission denied") {
39+ t.Fatalf("error = %v, want declared read-scope denial", err)
40+ }
41+ if called {
42+ t.Fatal("executable lookup ran before the read-scope check")
43+ }
44+}
A packages/agent/tools/grep_test.go +357 -0
@@ -0,0 +1,357 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "os"
8+ "path/filepath"
9+ "runtime"
10+ "strings"
11+ "testing"
12+ "time"
13+
14+ "github.com/bnema/zut/packages/core"
15+ "github.com/bnema/zut/packages/provider"
16+)
17+
18+func TestGrepPrefersRGAndFallsBackToGrep(t *testing.T) {
19+ skipGrepScriptTestsOnWindows(t)
20+ root := t.TempDir()
21+ rg := writeGrepScript(t, "#!/bin/sh\nprintf 'rg selected\\n'\n")
22+ grep := writeGrepScript(t, "#!/bin/sh\nprintf 'grep selected\\n'\n")
23+
24+ t.Run("rg", func(t *testing.T) {
25+ tool := &GrepTool{CWD: root, LookPath: func(name string) (string, error) {
26+ switch name {
27+ case "rg":
28+ return rg, nil
29+ case "grep":
30+ return grep, nil
31+ default:
32+ return "", os.ErrNotExist
33+ }
34+ }}
35+ result := executeGrepTest(t, tool, root)
36+ if got := grepText(result); got != "rg selected\n" {
37+ t.Fatalf("output = %q, want rg output", got)
38+ }
39+ if grepDetails(result)["engine"] != "rg" {
40+ t.Fatalf("engine = %v, want rg", grepDetails(result)["engine"])
41+ }
42+ })
43+
44+ t.Run("grep fallback", func(t *testing.T) {
45+ tool := &GrepTool{CWD: root, LookPath: func(name string) (string, error) {
46+ if name == "rg" {
47+ return "", os.ErrNotExist
48+ }
49+ return grep, nil
50+ }}
51+ result := executeGrepTest(t, tool, root)
52+ if got := grepText(result); got != "grep selected\n" {
53+ t.Fatalf("output = %q, want grep output", got)
54+ }
55+ if grepDetails(result)["engine"] != "grep" {
56+ t.Fatalf("engine = %v, want grep", grepDetails(result)["engine"])
57+ }
58+ })
59+}
60+
61+func TestGrepChecksSandboxReadScopeBeforeRunning(t *testing.T) {
62+ skipGrepScriptTestsOnWindows(t)
63+ base := t.TempDir()
64+ root := filepath.Join(base, "workspace")
65+ if err := os.Mkdir(root, 0o755); err != nil {
66+ t.Fatal(err)
67+ }
68+ outside := filepath.Join(base, "outside")
69+ if err := os.Mkdir(outside, 0o755); err != nil {
70+ t.Fatal(err)
71+ }
72+ sandbox := NewSandbox(root)
73+ sandbox.Lock()
74+ called := false
75+ tool := &GrepTool{
76+ CWD: root,
77+ Sandbox: sandbox,
78+ LookPath: func(string) (string, error) {
79+ called = true
80+ return "", errors.New("lookup should not run")
81+ },
82+ }
83+ _, err := tool.Execute(context.Background(), grepArgsJSON("needle", outside), nil)
84+ if err == nil || !strings.Contains(err.Error(), "jailed") {
85+ t.Fatalf("error = %v, want sandbox denial", err)
86+ }
87+ if called {
88+ t.Fatal("executable lookup ran before the sandbox check")
89+ }
90+}
91+
92+func TestGrepNoMatchIsSuccessful(t *testing.T) {
93+ skipGrepScriptTestsOnWindows(t)
94+ root := t.TempDir()
95+ noMatch := writeGrepScript(t, "#!/bin/sh\nexit 1\n")
96+ tool := &GrepTool{CWD: root, LookPath: lookupScripts(map[string]string{"rg": noMatch})}
97+ result := executeGrepTest(t, tool, root)
98+ if result.IsError {
99+ t.Fatal("no-match result is an error")
100+ }
101+ if got := grepDetails(result)["exit_code"]; got != 0 {
102+ t.Fatalf("reported exit code = %v, want 0", got)
103+ }
104+ if got := grepDetails(result)["process_exit_code"]; got != 1 {
105+ t.Fatalf("process exit code = %v, want 1", got)
106+ }
107+ if got := grepText(result); got != "No matches found.\n" {
108+ t.Fatalf("output = %q, want no-match message", got)
109+ }
110+}
111+
112+func TestGrepBoundsOutput(t *testing.T) {
113+ skipGrepScriptTestsOnWindows(t)
114+ root := t.TempDir()
115+ script := writeGrepScript(t, "#!/bin/sh\ni=0\nwhile [ $i -lt 4000 ]; do\n printf 'matching line %04d with enough padding to hit the cap\\n' $i\n i=$((i + 1))\ndone\n")
116+ tool := &GrepTool{CWD: root, LookPath: lookupScripts(map[string]string{"rg": script})}
117+ result := executeGrepTest(t, tool, root)
118+ text := grepText(result)
119+ if !grepDetails(result)["bytes_truncated"].(bool) {
120+ t.Fatal("bytes_truncated = false, want true")
121+ }
122+ if !strings.Contains(text, "truncated at") {
123+ t.Fatalf("bounded output has no truncation marker: %q", text[len(text)-100:])
124+ }
125+ if len(text) > maxGrepOutputBytes {
126+ t.Fatalf("bounded output length = %d, cap exceeded", len(text))
127+ }
128+}
129+
130+func TestGrepExactlyMaxOutputLinesIsNotTruncated(t *testing.T) {
131+ input := strings.Repeat("matching line\n", maxGrepOutputLines)
132+ output := &grepOutput{}
133+ if _, err := output.Write([]byte(input)); err != nil {
134+ t.Fatal(err)
135+ }
136+ got, truncated := output.text()
137+ if truncated {
138+ t.Fatal("exactly maxGrepOutputLines was marked truncated")
139+ }
140+ if got != input {
141+ t.Fatalf("output was altered: got %d bytes, want %d", len(got), len(input))
142+ }
143+}
144+
145+func TestGrepCancellationPropagates(t *testing.T) {
146+ skipGrepScriptTestsOnWindows(t)
147+ root := t.TempDir()
148+ started := filepath.Join(root, "started")
149+ script := writeGrepScript(t, "#!/bin/sh\nprintf started > started\nsleep 10\n")
150+ tool := &GrepTool{CWD: root, LookPath: lookupScripts(map[string]string{"rg": script})}
151+ ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
152+ defer cancel()
153+ resultCh := make(chan error, 1)
154+ go func() {
155+ _, err := tool.Execute(ctx, grepArgsJSON("needle", root), nil)
156+ resultCh <- err
157+ }()
158+
159+ startupDeadline := time.Now().Add(time.Second)
160+ for {
161+ if _, err := os.Stat(started); err == nil {
162+ break
163+ }
164+ if time.Now().After(startupDeadline) {
165+ t.Fatal("grep fixture did not start before the test deadline")
166+ }
167+ time.Sleep(5 * time.Millisecond)
168+ }
169+
170+ select {
171+ case err := <-resultCh:
172+ if !errors.Is(err, context.DeadlineExceeded) {
173+ t.Fatalf("error = %v, want deadline exceeded", err)
174+ }
175+ case <-time.After(time.Second):
176+ t.Fatal("grep did not stop after context cancellation")
177+ }
178+}
179+
180+func TestGrepInternalTimeoutStopsSearch(t *testing.T) {
181+ skipGrepScriptTestsOnWindows(t)
182+ root := t.TempDir()
183+ if err := os.WriteFile(filepath.Join(root, "hold"), nil, 0o644); err != nil {
184+ t.Fatal(err)
185+ }
186+ script := writeGrepScript(t, "#!/bin/sh\nprintf 'started\\n'\nexec tail -f hold\n")
187+ tool := &GrepTool{CWD: root, LookPath: lookupScripts(map[string]string{"rg": script})}
188+
189+ resultCh := make(chan error, 1)
190+ go func() {
191+ _, err := tool.executeWithTimeout(context.Background(), grepArgsJSON("needle", root), nil, 100*time.Millisecond)
192+ resultCh <- err
193+ }()
194+
195+ err := waitForGrepResult(t, resultCh)
196+ var timeoutErr *grepTimeoutError
197+ if !errors.As(err, &timeoutErr) {
198+ t.Fatalf("error = %v, want internal grep timeout", err)
199+ }
200+ if errors.Is(err, context.DeadlineExceeded) {
201+ t.Fatalf("internal timeout error = %v, want distinct caller deadline error", err)
202+ }
203+ if !strings.Contains(err.Error(), "try narrowing the path or glob") {
204+ t.Fatalf("error = %v, want path/glob narrowing guidance", err)
205+ }
206+}
207+
208+func TestGrepCallerDeadlineTakesPrecedenceOverInternalTimeout(t *testing.T) {
209+ skipGrepScriptTestsOnWindows(t)
210+ root := t.TempDir()
211+ if err := os.WriteFile(filepath.Join(root, "hold"), nil, 0o644); err != nil {
212+ t.Fatal(err)
213+ }
214+ script := writeGrepScript(t, "#!/bin/sh\nprintf 'started\\n'\nexec tail -f hold\n")
215+ tool := &GrepTool{CWD: root, LookPath: lookupScripts(map[string]string{"rg": script})}
216+ ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
217+ defer cancel()
218+
219+ resultCh := make(chan error, 1)
220+ go func() {
221+ _, err := tool.executeWithTimeout(ctx, grepArgsJSON("needle", root), nil, time.Second)
222+ resultCh <- err
223+ }()
224+
225+ err := waitForGrepResult(t, resultCh)
226+ if !errors.Is(err, context.DeadlineExceeded) {
227+ t.Fatalf("error = %v, want caller deadline exceeded", err)
228+ }
229+ var timeoutErr *grepTimeoutError
230+ if errors.As(err, &timeoutErr) {
231+ t.Fatalf("error = %v, want caller deadline rather than internal timeout", err)
232+ }
233+}
234+
235+func waitForGrepResult(t *testing.T, resultCh <-chan error) error {
236+ t.Helper()
237+ timer := time.NewTimer(2 * time.Second)
238+ defer timer.Stop()
239+ select {
240+ case err := <-resultCh:
241+ return err
242+ case <-timer.C:
243+ t.Fatal("grep fixture did not stop before the test deadline")
244+ return nil
245+ }
246+}
247+
248+func TestGrepDoesNotRequireBashPermission(t *testing.T) {
249+ skipGrepScriptTestsOnWindows(t)
250+ root := t.TempDir()
251+ script := writeGrepScript(t, "#!/bin/sh\nprintf 'read-only search\\n'\n")
252+ sandbox := NewSandbox(root)
253+ sandbox.Lock()
254+ permissions := &PermissionSet{}
255+ permissions.FS.Read = []string{root}
256+ permissions.Bash.Mode = "none"
257+ sandbox.SetPermissions(permissions)
258+ tool := &GrepTool{CWD: root, Sandbox: sandbox, LookPath: lookupScripts(map[string]string{"rg": script})}
259+ result := executeGrepTest(t, tool, root)
260+ if got := grepText(result); got != "read-only search\n" {
261+ t.Fatalf("output = %q", got)
262+ }
263+}
264+
265+func TestGrepSchemaValidation(t *testing.T) {
266+ tool := &GrepTool{LookPath: func(string) (string, error) {
267+ t.Fatal("schema-invalid input reached executable lookup")
268+ return "", nil
269+ }}
270+ for _, tc := range []struct {
271+ name string
272+ raw string
273+ }{
274+ {"not object", `[]`},
275+ {"missing pattern", `{"path":"."}`},
276+ {"missing path", `{"pattern":"needle"}`},
277+ {"empty pattern", `{"pattern":"","path":"."}`},
278+ {"wrong type", `{"pattern":true,"path":"."}`},
279+ {"unknown field", `{"pattern":"needle","path":".","shell":"echo"}`},
280+ {"nul", "{\"pattern\":\"needle\\u0000\",\"path\":\".\"}"},
281+ } {
282+ t.Run(tc.name, func(t *testing.T) {
283+ if _, err := tool.Execute(context.Background(), []byte(tc.raw), nil); err == nil {
284+ t.Fatal("invalid arguments were accepted")
285+ }
286+ })
287+ }
288+
289+ var schema map[string]any
290+ if err := json.Unmarshal(tool.Schema(), &schema); err != nil {
291+ t.Fatal(err)
292+ }
293+ if schema["type"] != "object" {
294+ t.Fatalf("schema type = %v", schema["type"])
295+ }
296+ if schema["additionalProperties"] != false {
297+ t.Fatalf("schema additionalProperties = %v", schema["additionalProperties"])
298+ }
299+}
300+
301+func executeGrepTest(t *testing.T, tool *GrepTool, root string) core.ToolResult {
302+ t.Helper()
303+ result, err := tool.Execute(context.Background(), grepArgsJSON("needle", root), nil)
304+ if err != nil {
305+ t.Fatal(err)
306+ }
307+ return result
308+}
309+
310+func grepDetails(result core.ToolResult) map[string]any {
311+ return result.Details.(map[string]any)
312+}
313+
314+func grepText(result core.ToolResult) string {
315+ if len(result.Content) == 0 {
316+ return ""
317+ }
318+ block, ok := result.Content[0].(provider.TextBlock)
319+ if !ok {
320+ return ""
321+ }
322+ return block.Text
323+}
324+
325+func grepArgsJSON(pattern, path string) []byte {
326+ return []byte(`{"pattern":` + strconvQuote(pattern) + `,"path":` + strconvQuote(path) + `}`)
327+}
328+
329+func strconvQuote(s string) string {
330+ data, _ := json.Marshal(s)
331+ return string(data)
332+}
333+
334+func lookupScripts(paths map[string]string) func(string) (string, error) {
335+ return func(name string) (string, error) {
336+ if path, ok := paths[name]; ok {
337+ return path, nil
338+ }
339+ return "", os.ErrNotExist
340+ }
341+}
342+
343+func writeGrepScript(t *testing.T, content string) string {
344+ t.Helper()
345+ path := filepath.Join(t.TempDir(), "fake-grep")
346+ if err := os.WriteFile(path, []byte(content), 0o755); err != nil {
347+ t.Fatal(err)
348+ }
349+ return path
350+}
351+
352+func skipGrepScriptTestsOnWindows(t *testing.T) {
353+ t.Helper()
354+ if runtime.GOOS == "windows" {
355+ t.Skip("test uses a POSIX executable fixture")
356+ }
357+}
@@ -0,0 +1,357 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "os"
8+ "path/filepath"
9+ "runtime"
10+ "strings"
11+ "testing"
12+ "time"
13+
14+ "github.com/bnema/zut/packages/core"
15+ "github.com/bnema/zut/packages/provider"
16+)
17+
18+func TestGrepPrefersRGAndFallsBackToGrep(t *testing.T) {
19+ skipGrepScriptTestsOnWindows(t)
20+ root := t.TempDir()
21+ rg := writeGrepScript(t, "#!/bin/sh\nprintf 'rg selected\\n'\n")
22+ grep := writeGrepScript(t, "#!/bin/sh\nprintf 'grep selected\\n'\n")
23+
24+ t.Run("rg", func(t *testing.T) {
25+ tool := &GrepTool{CWD: root, LookPath: func(name string) (string, error) {
26+ switch name {
27+ case "rg":
28+ return rg, nil
29+ case "grep":
30+ return grep, nil
31+ default:
32+ return "", os.ErrNotExist
33+ }
34+ }}
35+ result := executeGrepTest(t, tool, root)
36+ if got := grepText(result); got != "rg selected\n" {
37+ t.Fatalf("output = %q, want rg output", got)
38+ }
39+ if grepDetails(result)["engine"] != "rg" {
40+ t.Fatalf("engine = %v, want rg", grepDetails(result)["engine"])
41+ }
42+ })
43+
44+ t.Run("grep fallback", func(t *testing.T) {
45+ tool := &GrepTool{CWD: root, LookPath: func(name string) (string, error) {
46+ if name == "rg" {
47+ return "", os.ErrNotExist
48+ }
49+ return grep, nil
50+ }}
51+ result := executeGrepTest(t, tool, root)
52+ if got := grepText(result); got != "grep selected\n" {
53+ t.Fatalf("output = %q, want grep output", got)
54+ }
55+ if grepDetails(result)["engine"] != "grep" {
56+ t.Fatalf("engine = %v, want grep", grepDetails(result)["engine"])
57+ }
58+ })
59+}
60+
61+func TestGrepChecksSandboxReadScopeBeforeRunning(t *testing.T) {
62+ skipGrepScriptTestsOnWindows(t)
63+ base := t.TempDir()
64+ root := filepath.Join(base, "workspace")
65+ if err := os.Mkdir(root, 0o755); err != nil {
66+ t.Fatal(err)
67+ }
68+ outside := filepath.Join(base, "outside")
69+ if err := os.Mkdir(outside, 0o755); err != nil {
70+ t.Fatal(err)
71+ }
72+ sandbox := NewSandbox(root)
73+ sandbox.Lock()
74+ called := false
75+ tool := &GrepTool{
76+ CWD: root,
77+ Sandbox: sandbox,
78+ LookPath: func(string) (string, error) {
79+ called = true
80+ return "", errors.New("lookup should not run")
81+ },
82+ }
83+ _, err := tool.Execute(context.Background(), grepArgsJSON("needle", outside), nil)
84+ if err == nil || !strings.Contains(err.Error(), "jailed") {
85+ t.Fatalf("error = %v, want sandbox denial", err)
86+ }
87+ if called {
88+ t.Fatal("executable lookup ran before the sandbox check")
89+ }
90+}
91+
92+func TestGrepNoMatchIsSuccessful(t *testing.T) {
93+ skipGrepScriptTestsOnWindows(t)
94+ root := t.TempDir()
95+ noMatch := writeGrepScript(t, "#!/bin/sh\nexit 1\n")
96+ tool := &GrepTool{CWD: root, LookPath: lookupScripts(map[string]string{"rg": noMatch})}
97+ result := executeGrepTest(t, tool, root)
98+ if result.IsError {
99+ t.Fatal("no-match result is an error")
100+ }
101+ if got := grepDetails(result)["exit_code"]; got != 0 {
102+ t.Fatalf("reported exit code = %v, want 0", got)
103+ }
104+ if got := grepDetails(result)["process_exit_code"]; got != 1 {
105+ t.Fatalf("process exit code = %v, want 1", got)
106+ }
107+ if got := grepText(result); got != "No matches found.\n" {
108+ t.Fatalf("output = %q, want no-match message", got)
109+ }
110+}
111+
112+func TestGrepBoundsOutput(t *testing.T) {
113+ skipGrepScriptTestsOnWindows(t)
114+ root := t.TempDir()
115+ script := writeGrepScript(t, "#!/bin/sh\ni=0\nwhile [ $i -lt 4000 ]; do\n printf 'matching line %04d with enough padding to hit the cap\\n' $i\n i=$((i + 1))\ndone\n")
116+ tool := &GrepTool{CWD: root, LookPath: lookupScripts(map[string]string{"rg": script})}
117+ result := executeGrepTest(t, tool, root)
118+ text := grepText(result)
119+ if !grepDetails(result)["bytes_truncated"].(bool) {
120+ t.Fatal("bytes_truncated = false, want true")
121+ }
122+ if !strings.Contains(text, "truncated at") {
123+ t.Fatalf("bounded output has no truncation marker: %q", text[len(text)-100:])
124+ }
125+ if len(text) > maxGrepOutputBytes {
126+ t.Fatalf("bounded output length = %d, cap exceeded", len(text))
127+ }
128+}
129+
130+func TestGrepExactlyMaxOutputLinesIsNotTruncated(t *testing.T) {
131+ input := strings.Repeat("matching line\n", maxGrepOutputLines)
132+ output := &grepOutput{}
133+ if _, err := output.Write([]byte(input)); err != nil {
134+ t.Fatal(err)
135+ }
136+ got, truncated := output.text()
137+ if truncated {
138+ t.Fatal("exactly maxGrepOutputLines was marked truncated")
139+ }
140+ if got != input {
141+ t.Fatalf("output was altered: got %d bytes, want %d", len(got), len(input))
142+ }
143+}
144+
145+func TestGrepCancellationPropagates(t *testing.T) {
146+ skipGrepScriptTestsOnWindows(t)
147+ root := t.TempDir()
148+ started := filepath.Join(root, "started")
149+ script := writeGrepScript(t, "#!/bin/sh\nprintf started > started\nsleep 10\n")
150+ tool := &GrepTool{CWD: root, LookPath: lookupScripts(map[string]string{"rg": script})}
151+ ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
152+ defer cancel()
153+ resultCh := make(chan error, 1)
154+ go func() {
155+ _, err := tool.Execute(ctx, grepArgsJSON("needle", root), nil)
156+ resultCh <- err
157+ }()
158+
159+ startupDeadline := time.Now().Add(time.Second)
160+ for {
161+ if _, err := os.Stat(started); err == nil {
162+ break
163+ }
164+ if time.Now().After(startupDeadline) {
165+ t.Fatal("grep fixture did not start before the test deadline")
166+ }
167+ time.Sleep(5 * time.Millisecond)
168+ }
169+
170+ select {
171+ case err := <-resultCh:
172+ if !errors.Is(err, context.DeadlineExceeded) {
173+ t.Fatalf("error = %v, want deadline exceeded", err)
174+ }
175+ case <-time.After(time.Second):
176+ t.Fatal("grep did not stop after context cancellation")
177+ }
178+}
179+
180+func TestGrepInternalTimeoutStopsSearch(t *testing.T) {
181+ skipGrepScriptTestsOnWindows(t)
182+ root := t.TempDir()
183+ if err := os.WriteFile(filepath.Join(root, "hold"), nil, 0o644); err != nil {
184+ t.Fatal(err)
185+ }
186+ script := writeGrepScript(t, "#!/bin/sh\nprintf 'started\\n'\nexec tail -f hold\n")
187+ tool := &GrepTool{CWD: root, LookPath: lookupScripts(map[string]string{"rg": script})}
188+
189+ resultCh := make(chan error, 1)
190+ go func() {
191+ _, err := tool.executeWithTimeout(context.Background(), grepArgsJSON("needle", root), nil, 100*time.Millisecond)
192+ resultCh <- err
193+ }()
194+
195+ err := waitForGrepResult(t, resultCh)
196+ var timeoutErr *grepTimeoutError
197+ if !errors.As(err, &timeoutErr) {
198+ t.Fatalf("error = %v, want internal grep timeout", err)
199+ }
200+ if errors.Is(err, context.DeadlineExceeded) {
201+ t.Fatalf("internal timeout error = %v, want distinct caller deadline error", err)
202+ }
203+ if !strings.Contains(err.Error(), "try narrowing the path or glob") {
204+ t.Fatalf("error = %v, want path/glob narrowing guidance", err)
205+ }
206+}
207+
208+func TestGrepCallerDeadlineTakesPrecedenceOverInternalTimeout(t *testing.T) {
209+ skipGrepScriptTestsOnWindows(t)
210+ root := t.TempDir()
211+ if err := os.WriteFile(filepath.Join(root, "hold"), nil, 0o644); err != nil {
212+ t.Fatal(err)
213+ }
214+ script := writeGrepScript(t, "#!/bin/sh\nprintf 'started\\n'\nexec tail -f hold\n")
215+ tool := &GrepTool{CWD: root, LookPath: lookupScripts(map[string]string{"rg": script})}
216+ ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
217+ defer cancel()
218+
219+ resultCh := make(chan error, 1)
220+ go func() {
221+ _, err := tool.executeWithTimeout(ctx, grepArgsJSON("needle", root), nil, time.Second)
222+ resultCh <- err
223+ }()
224+
225+ err := waitForGrepResult(t, resultCh)
226+ if !errors.Is(err, context.DeadlineExceeded) {
227+ t.Fatalf("error = %v, want caller deadline exceeded", err)
228+ }
229+ var timeoutErr *grepTimeoutError
230+ if errors.As(err, &timeoutErr) {
231+ t.Fatalf("error = %v, want caller deadline rather than internal timeout", err)
232+ }
233+}
234+
235+func waitForGrepResult(t *testing.T, resultCh <-chan error) error {
236+ t.Helper()
237+ timer := time.NewTimer(2 * time.Second)
238+ defer timer.Stop()
239+ select {
240+ case err := <-resultCh:
241+ return err
242+ case <-timer.C:
243+ t.Fatal("grep fixture did not stop before the test deadline")
244+ return nil
245+ }
246+}
247+
248+func TestGrepDoesNotRequireBashPermission(t *testing.T) {
249+ skipGrepScriptTestsOnWindows(t)
250+ root := t.TempDir()
251+ script := writeGrepScript(t, "#!/bin/sh\nprintf 'read-only search\\n'\n")
252+ sandbox := NewSandbox(root)
253+ sandbox.Lock()
254+ permissions := &PermissionSet{}
255+ permissions.FS.Read = []string{root}
256+ permissions.Bash.Mode = "none"
257+ sandbox.SetPermissions(permissions)
258+ tool := &GrepTool{CWD: root, Sandbox: sandbox, LookPath: lookupScripts(map[string]string{"rg": script})}
259+ result := executeGrepTest(t, tool, root)
260+ if got := grepText(result); got != "read-only search\n" {
261+ t.Fatalf("output = %q", got)
262+ }
263+}
264+
265+func TestGrepSchemaValidation(t *testing.T) {
266+ tool := &GrepTool{LookPath: func(string) (string, error) {
267+ t.Fatal("schema-invalid input reached executable lookup")
268+ return "", nil
269+ }}
270+ for _, tc := range []struct {
271+ name string
272+ raw string
273+ }{
274+ {"not object", `[]`},
275+ {"missing pattern", `{"path":"."}`},
276+ {"missing path", `{"pattern":"needle"}`},
277+ {"empty pattern", `{"pattern":"","path":"."}`},
278+ {"wrong type", `{"pattern":true,"path":"."}`},
279+ {"unknown field", `{"pattern":"needle","path":".","shell":"echo"}`},
280+ {"nul", "{\"pattern\":\"needle\\u0000\",\"path\":\".\"}"},
281+ } {
282+ t.Run(tc.name, func(t *testing.T) {
283+ if _, err := tool.Execute(context.Background(), []byte(tc.raw), nil); err == nil {
284+ t.Fatal("invalid arguments were accepted")
285+ }
286+ })
287+ }
288+
289+ var schema map[string]any
290+ if err := json.Unmarshal(tool.Schema(), &schema); err != nil {
291+ t.Fatal(err)
292+ }
293+ if schema["type"] != "object" {
294+ t.Fatalf("schema type = %v", schema["type"])
295+ }
296+ if schema["additionalProperties"] != false {
297+ t.Fatalf("schema additionalProperties = %v", schema["additionalProperties"])
298+ }
299+}
300+
301+func executeGrepTest(t *testing.T, tool *GrepTool, root string) core.ToolResult {
302+ t.Helper()
303+ result, err := tool.Execute(context.Background(), grepArgsJSON("needle", root), nil)
304+ if err != nil {
305+ t.Fatal(err)
306+ }
307+ return result
308+}
309+
310+func grepDetails(result core.ToolResult) map[string]any {
311+ return result.Details.(map[string]any)
312+}
313+
314+func grepText(result core.ToolResult) string {
315+ if len(result.Content) == 0 {
316+ return ""
317+ }
318+ block, ok := result.Content[0].(provider.TextBlock)
319+ if !ok {
320+ return ""
321+ }
322+ return block.Text
323+}
324+
325+func grepArgsJSON(pattern, path string) []byte {
326+ return []byte(`{"pattern":` + strconvQuote(pattern) + `,"path":` + strconvQuote(path) + `}`)
327+}
328+
329+func strconvQuote(s string) string {
330+ data, _ := json.Marshal(s)
331+ return string(data)
332+}
333+
334+func lookupScripts(paths map[string]string) func(string) (string, error) {
335+ return func(name string) (string, error) {
336+ if path, ok := paths[name]; ok {
337+ return path, nil
338+ }
339+ return "", os.ErrNotExist
340+ }
341+}
342+
343+func writeGrepScript(t *testing.T, content string) string {
344+ t.Helper()
345+ path := filepath.Join(t.TempDir(), "fake-grep")
346+ if err := os.WriteFile(path, []byte(content), 0o755); err != nil {
347+ t.Fatal(err)
348+ }
349+ return path
350+}
351+
352+func skipGrepScriptTestsOnWindows(t *testing.T) {
353+ t.Helper()
354+ if runtime.GOOS == "windows" {
355+ t.Skip("test uses a POSIX executable fixture")
356+ }
357+}
M packages/agent/tools/read.go +1 -1
@@ -1,5 +1,5 @@
11 // Package tools implements zut's built-in tools: read, write, edit, bash,
2-// create_worktree, lsp, web search, and autonomous goal updates.
2+// create_worktree, grep, lsp, web search, and autonomous goal updates.
33 package tools
44
55 import (
@@ -1,5 +1,5 @@
1 // Package tools implements zut's built-in tools: read, write, edit, bash,1 // Package tools implements zut's built-in tools: read, write, edit, bash,
2-// create_worktree, lsp, web search, and autonomous goal updates.2+// create_worktree, grep, lsp, web search, and autonomous goal updates.
3 package tools3 package tools
4 4
5 import (5 import (

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