Merge pull request #126 from bnema/feature/builtin-grep
feat(tools): add built-in grep search tool
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. | |||
| 9 | 9 | ||
| 10 | 10 | - one static binary. | |
| 11 | 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 | 13 | - three run modes (interactive tui, print, json). | |
| 14 | 14 | - built-in telegram bot. | |
| 15 | 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 | 249 | - `read`: read text files, or inline images (PNG, JPEG, GIF, WebP). | |
| 250 | 250 | - `write`: create or overwrite files, making parent directories as needed. | |
| 251 | 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 | 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 | 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 | 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 | 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 | 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 | 261 | ## Modes | |
| 261 | 262 | ||
| @@ -452,7 +453,7 @@ To prevent tool-heavy runs from exhausting context, provider requests and compac | |||
| 452 | 453 | ||
| 453 | 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 | 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 | 822 | ## 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 | 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 | 910 | | `ctrl+b` | Toggle the right sidebar; hidden or narrow widgets use a bounded above-input fallback. | | |
| 910 | 911 | | `ctrl+l` | Redraw the screen. | | |
| 911 | 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 | 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 | 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 | 1093 | packages/agent/modes/ interactive tui, print, json, dialogs | |
| 1093 | 1094 | packages/agent/modes/bot/ protocol-agnostic bot runner (BotAdapter interface) | |
| 1094 | 1095 | 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 | |
| 1096 | 1097 | packages/agent/skills/ skill discovery, frontmatter parser, skill tool | |
| 1097 | 1098 | packages/agent/subagents/ named profiles and resident background runtime | |
| 1098 | 1099 | 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 | ## Modes | 261 | ## 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 rendering | 822 | ## 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, dialogs | 1093 | 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, daemon | 1095 | 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 |
| 1096 | packages/agent/skills/ skill discovery, frontmatter parser, skill tool | 1097 | packages/agent/skills/ skill discovery, frontmatter parser, skill tool | ||
| 1097 | packages/agent/subagents/ named profiles and resident background runtime | 1098 | 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). | |||
| 225 | 225 | ``` | |
| 226 | 226 | ||
| 227 | 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 | 229 | Conflicts with active built-ins are silently shadowed by the built-in. The | |
| 230 | 230 | 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 | 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 | ||
| @@ -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. The | 229 | 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 that | 230 | 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 | |||
| 57 | 57 | ||
| 58 | 58 | ## 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 | 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 | ||
| @@ -57,7 +57,7 @@ Only direct HTTP(S) result URLs are returned. Titles are limited to 300 Unicode | |||||
| 57 | 57 | ||||
| 58 | ## Transcript and integrations | 58 | ## 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 | |||
| 81 | 81 | 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 | 86 | ## Directory layout | |
| 87 | 87 | ||
| @@ -303,7 +303,7 @@ In `--print` / `--stream` / `--json` modes, `pre` still runs first: shell escape | |||
| 303 | 303 | ||
| 304 | 304 | ## 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 | 308 | Two variables are available in filesystem scopes: | |
| 309 | 309 | ||
| @@ -81,7 +81,7 @@ zut verify ./code-reviewer.zut | |||||
| 81 | zut run ./code-reviewer.zut | 81 | 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 layout | 86 | ## Directory layout | ||
| 87 | 87 | ||||
| @@ -303,7 +303,7 @@ In `--print` / `--stream` / `--json` modes, `pre` still runs first: shell escape | |||||
| 303 | 303 | ||||
| 304 | ## Filesystem permissions | 304 | ## 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 @@ | |||
| 6 | 6 | // | |
| 7 | 7 | // The double underscore separates the server name from the tool name, | |
| 8 | 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 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. | |
| 11 | 11 | package main | |
| 12 | 12 | ||
| 13 | 13 | 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 the | 9 | + | // 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 main | 11 | 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) { | |||
| 97 | 97 | } | |
| 98 | 98 | changed := false | |
| 99 | 99 | 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 | |
| 103 | 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 | 105 | continue | |
| 106 | 106 | } | |
| 107 | 107 | if _, exists := r.ToolRegistry[info.Name]; exists { | |
| @@ -1194,6 +1194,8 @@ func (r *Resolved) UseSandbox(s *tools.Sandbox) { | |||
| 1194 | 1194 | v.Sandbox = s | |
| 1195 | 1195 | case *tools.LSPTool: | |
| 1196 | 1196 | v.Sandbox = s | |
| 1197 | + | case *tools.GrepTool: | |
| 1198 | + | v.Sandbox = s | |
| 1197 | 1199 | } | |
| 1198 | 1200 | _ = name | |
| 1199 | 1201 | } | |
| @@ -1229,6 +1231,7 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled | |||
| 1229 | 1231 | "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit}, | |
| 1230 | 1232 | "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox}, | |
| 1231 | 1233 | "create_worktree": &tools.CreateWorktreeTool{CWD: cwd, Sandbox: sandbox}, | |
| 1234 | + | "grep": &tools.GrepTool{CWD: cwd, Sandbox: sandbox}, | |
| 1232 | 1235 | "update_goal": &tools.UpdateGoalTool{}, | |
| 1233 | 1236 | } | |
| 1234 | 1237 | if webSearchAllowedForRegistry(args) { | |
| @@ -1316,7 +1319,7 @@ func autoSubagentsToolAllowedFor(args Args, toolName string) bool { | |||
| 1316 | 1319 | 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 | 1324 | func toolSummaries(reg core.Registry, args Args) []ToolSummary { | |
| 1322 | 1325 | var out []ToolSummary | |
| @@ -97,11 +97,11 @@ func (r *Resolved) MergeExtensionTools(mgr ExtensionToolSource) { | |||||
| 97 | } | 97 | } | ||
| 98 | changed := false | 98 | 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 its | 100 | + | // web_search and grep remain reserved native-tool names even when |
| 101 | - | // capability policy excludes the current session. An extension must | 101 | + | // their capability policy excludes the current session. An extension |
| 102 | - | // not turn a normal CLI opt-out into a differently implemented search | 102 | + | // 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 | continue | 105 | 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 = s | 1194 | v.Sandbox = s | ||
| 1195 | case *tools.LSPTool: | 1195 | case *tools.LSPTool: | ||
| 1196 | v.Sandbox = s | 1196 | v.Sandbox = s | ||
| 1197 | + | case *tools.GrepTool: | |||
| 1198 | + | v.Sandbox = s | |||
| 1197 | } | 1199 | } | ||
| 1198 | _ = name | 1200 | _ = 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 false | 1319 | 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 []ToolSummary | 1325 | 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) { | |||
| 421 | 421 | // into the agent's registry once the extension's ready frame fires. | |
| 422 | 422 | // | |
| 423 | 423 | // 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. | |
| 427 | 427 | func (e *Extension) Tool(name, description string, schema json.RawMessage, fn ToolHandler) { | |
| 428 | 428 | e.registerTool(name, description, schema, false, fn) | |
| 429 | 429 | } | |
| @@ -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 the | 424 | + | // 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 is | 425 | + | // 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 { | |||
| 77 | 77 | BaseURL string | |
| 78 | 78 | ||
| 79 | 79 | // 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 | |
| 81 | 81 | // excluded unless this list explicitly contains "web_search". Pass an | |
| 82 | 82 | // empty-but-non-nil slice | |
| 83 | 83 | // (e.g. []string{}) plus NoTools=true to disable everything. | |
| @@ -77,7 +77,7 @@ type Config struct { | |||||
| 77 | BaseURL string | 77 | BaseURL string | ||
| 78 | 78 | ||||
| 79 | // Tools is the list of tools to enable. Nil/empty = all ordinary built-in | 79 | // 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 |
| 81 | // excluded unless this list explicitly contains "web_search". Pass an | 81 | // excluded unless this list explicitly contains "web_search". Pass an | ||
| 82 | // empty-but-non-nil slice | 82 | // 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): | |||
| 29 | 29 | 2. **Tools** — register tools the LLM itself calls. Schema is | |
| 30 | 30 | JSON Schema; zut routes the model's `tool_call` to the | |
| 31 | 31 | 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 | 34 | 3. **Lifecycle hooks** — subscribe to events | |
| 35 | 35 | (session_start, turn_start, tool_call, turn_end, | |
| @@ -71,8 +71,8 @@ Field rules: | |||
| 71 | 71 | - `name` (required, unique) — id zut uses internally; matches the | |
| 72 | 72 | hello frame. Slash commands & tools live in the same name space | |
| 73 | 73 | 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. | |
| 76 | 76 | - `exec` (required) — the executable path. Resolution: | |
| 77 | 77 | - absolute: as-is | |
| 78 | 78 | - 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 is | 29 | 2. **Tools** — register tools the LLM itself calls. Schema is | ||
| 30 | JSON Schema; zut routes the model's `tool_call` to the | 30 | JSON Schema; zut routes the model's `tool_call` to the | ||
| 31 | extension's `tool_result`. Same lifecycle as built-in tools | 31 | 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 events | 34 | 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 the | 71 | - `name` (required, unique) — id zut uses internally; matches the | ||
| 72 | hello frame. Slash commands & tools live in the same name space | 72 | hello frame. Slash commands & tools live in the same name space | ||
| 73 | as built-ins; conflicts with active built-ins are ignored. The | 73 | as built-ins; conflicts with active built-ins are ignored. The | ||
| 74 | - | native `web_search` name is reserved even when that built-in is | 74 | + | 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-is | 77 | - absolute: as-is | ||
| 78 | - starts with `./` or `../`: relative to the manifest's directory | 78 | - 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 @@ | |||
| 1 | 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 | 3 | package tools | |
| 4 | 4 | ||
| 5 | 5 | 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 tools | 3 | package tools | ||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||