limbo-code 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
limbo/skills.py ADDED
@@ -0,0 +1,89 @@
1
+ """Skill support: discover and load SKILL.md skills.
2
+
3
+ A skill is a directory containing a ``SKILL.md`` file with YAML-ish
4
+ frontmatter (``name``, ``description``) followed by the instruction body —
5
+ the same convention used by Claude Code and pi.
6
+
7
+ Discovery paths (later sources win on name collisions):
8
+ - user: ``~/.limbo/skills/<name>/SKILL.md``
9
+ - project: ``<workdir>/.agents/skills/<name>/SKILL.md``
10
+
11
+ Skills are invoked through slash commands: ``/<name> [args]`` injects the
12
+ skill body (plus args) as the turn's prompt.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass
18
+ from pathlib import Path
19
+
20
+ DEFAULT_USER_SKILLS_DIR = Path.home() / ".limbo" / "skills"
21
+ PROJECT_SKILLS_DIR = ".agents/skills"
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Skill:
26
+ name: str
27
+ description: str
28
+ body: str
29
+ path: Path
30
+
31
+
32
+ def parse_skill_md(text: str, fallback_name: str) -> tuple[str, str, str]:
33
+ """Parse SKILL.md content into (name, description, body).
34
+
35
+ Frontmatter is a simple ``---`` delimited block of ``key: value`` lines.
36
+ Without frontmatter, the fallback name is used and the whole file is the
37
+ body.
38
+ """
39
+ if text.startswith("---"):
40
+ end = text.find("\n---", 3)
41
+ if end != -1:
42
+ header = text[3:end]
43
+ body = text[end + 4 :].lstrip("\n")
44
+ name = fallback_name
45
+ description = ""
46
+ for line in header.splitlines():
47
+ key, sep, value = line.partition(":")
48
+ if not sep:
49
+ continue
50
+ key = key.strip()
51
+ value = value.strip()
52
+ if key == "name" and value:
53
+ name = value
54
+ elif key == "description":
55
+ description = value
56
+ return name, description, body
57
+ return fallback_name, "", text
58
+
59
+
60
+ def _load_skills_from(skills_dir: Path) -> list[Skill]:
61
+ skills: list[Skill] = []
62
+ if not skills_dir.is_dir():
63
+ return skills
64
+ for path in sorted(skills_dir.glob("*/SKILL.md")):
65
+ try:
66
+ text = path.read_text(encoding="utf-8")
67
+ except OSError:
68
+ continue
69
+ name, description, body = parse_skill_md(text, fallback_name=path.parent.name)
70
+ skills.append(
71
+ Skill(name=name, description=description, body=body, path=path)
72
+ )
73
+ return skills
74
+
75
+
76
+ def discover_skills(
77
+ workdir: Path, user_dir: Path | None = None
78
+ ) -> list[Skill]:
79
+ """Discover skills from the user dir and the project (.agents/skills).
80
+
81
+ Project skills override user skills with the same name. Sorted by name.
82
+ """
83
+ user_dir = user_dir if user_dir is not None else DEFAULT_USER_SKILLS_DIR
84
+ by_name: dict[str, Skill] = {}
85
+ for skill in _load_skills_from(user_dir):
86
+ by_name[skill.name] = skill
87
+ for skill in _load_skills_from(workdir / PROJECT_SKILLS_DIR):
88
+ by_name[skill.name] = skill
89
+ return [by_name[name] for name in sorted(by_name)]
@@ -0,0 +1 @@
1
+ """Limbo tools package."""
limbo/tools/base.py ADDED
@@ -0,0 +1,102 @@
1
+ """Base class and shared helpers for tools.
2
+
3
+ The base module owns the rituals every tool used to repeat: workdir-safe
4
+ path resolution (raising ``ToolError`` instead of returning union types)
5
+ and the output truncation policy.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from abc import ABC, abstractmethod
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from limbo.models import ToolResult
15
+
16
+ MAX_OUTPUT_BYTES = 512 * 1024
17
+
18
+
19
+ class ToolError(Exception):
20
+ """Raised by tool helpers; ``BaseTool.execute`` turns it into an error result."""
21
+
22
+
23
+ class BaseTool(ABC):
24
+ """Abstract base class for all Limbo tools.
25
+
26
+ Subclasses implement ``run``; ``execute`` is the public entry point and
27
+ converts ``ToolError`` into an error ``ToolResult`` uniformly.
28
+ """
29
+
30
+ name: str
31
+ description: str
32
+ parameters: dict[str, Any]
33
+
34
+ def __init__(self, workdir: Path):
35
+ self.workdir = workdir.resolve()
36
+
37
+ def execute(self, arguments: dict[str, Any]) -> ToolResult:
38
+ try:
39
+ return self.run(arguments)
40
+ except ToolError as e:
41
+ return ToolResult(success=False, error=str(e))
42
+
43
+ @abstractmethod
44
+ def run(self, arguments: dict[str, Any]) -> ToolResult:
45
+ ...
46
+
47
+ # -- path resolution -------------------------------------------------------
48
+
49
+ def resolve(self, raw_path: str, *, strict: bool = True) -> Path:
50
+ """Resolve ``raw_path`` under the workdir, enforcing the boundary.
51
+
52
+ Raises ``ToolError`` for paths outside the workdir, unresolvable
53
+ paths, and (when ``strict``) broken symlinks.
54
+ """
55
+ raw = self.workdir / raw_path
56
+ try:
57
+ target = raw.resolve(strict=False)
58
+ except (OSError, RuntimeError) as e:
59
+ raise ToolError(f"Invalid path: {e}") from e
60
+
61
+ if not is_within_workdir(target, self.workdir):
62
+ raise ToolError("Path is outside working directory.")
63
+
64
+ if strict and raw.is_symlink() and not target.exists():
65
+ raise ToolError("Invalid path: broken symlink")
66
+
67
+ return target
68
+
69
+ def resolve_existing(
70
+ self, raw_path: str, *, noun: str = "Path", kind: str = "any"
71
+ ) -> Path:
72
+ """Resolve a path that must exist. ``kind``: any | file | dir."""
73
+ target = self.resolve(raw_path)
74
+ if not target.exists():
75
+ raise ToolError(f"{noun} not found: {raw_path}")
76
+ if kind == "file" and not target.is_file():
77
+ raise ToolError(f"Not a file: {raw_path}")
78
+ if kind == "dir" and not target.is_dir():
79
+ raise ToolError(f"Not a directory: {raw_path}")
80
+ return target
81
+
82
+ def resolve_creatable(self, raw_path: str) -> Path:
83
+ """Resolve a path that may not exist yet (e.g. for writing)."""
84
+ return self.resolve(raw_path, strict=False)
85
+
86
+ def is_within_workdir(path: Path, workdir: Path) -> bool:
87
+ """Return True if resolved path is inside or equal to workdir."""
88
+ try:
89
+ path.resolve().relative_to(workdir.resolve())
90
+ return True
91
+ except ValueError:
92
+ return False
93
+
94
+
95
+ def truncate_output(
96
+ output: str, *, limit: int = MAX_OUTPUT_BYTES, suffix: str = "\n[Output truncated.]"
97
+ ) -> str:
98
+ """Truncate ``output`` to ``limit`` bytes (UTF-8 safe) and append ``suffix``."""
99
+ encoded = output.encode("utf-8", errors="replace")
100
+ if len(encoded) <= limit:
101
+ return output
102
+ return encoded[:limit].decode("utf-8", errors="replace") + suffix
limbo/tools/bash.py ADDED
@@ -0,0 +1,172 @@
1
+ """Execute bash commands tool."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import shlex
7
+ import shutil
8
+ import subprocess
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from limbo.config import DEFAULT_DANGEROUS_COMMANDS
13
+ from limbo.models import ToolResult
14
+ from limbo.tools.base import MAX_OUTPUT_BYTES, BaseTool, truncate_output
15
+
16
+ DEFAULT_TIMEOUT = 30.0
17
+ MAX_TIMEOUT = 300.0
18
+
19
+
20
+ class BashTool(BaseTool):
21
+ name = "bash"
22
+ description = (
23
+ "Execute a bash command in the current working directory. Returns stdout and stderr. "
24
+ "WARNING: bash is not sandboxed and can access files outside the workdir. "
25
+ "Commands matching dangerous patterns (e.g. rm, git reset --hard) are "
26
+ "rejected outright. The filter is heuristic only: "
27
+ "subshells, command substitution, variable indirection, options before the command "
28
+ "name, and variable assignments before the command name "
29
+ "(e.g. 'bash -c rm -rf /', '$(rm ...)', 'git -C /foo reset --hard', "
30
+ "or 'VAR=1 rm -rf /') can bypass it."
31
+ )
32
+ parameters = {
33
+ "type": "object",
34
+ "properties": {
35
+ "command": {"type": "string", "description": "Bash command to execute"},
36
+ "timeout": {
37
+ "type": "number",
38
+ "description": "Timeout in seconds (optional, default 30, maximum 300)",
39
+ },
40
+ },
41
+ "required": ["command"],
42
+ }
43
+
44
+ def __init__(self, workdir: Path, dangerous_patterns: list[str] | None = None):
45
+ super().__init__(workdir)
46
+ self.dangerous_patterns = dangerous_patterns or list(
47
+ DEFAULT_DANGEROUS_COMMANDS
48
+ )
49
+
50
+ def run(self, arguments: dict[str, Any]) -> ToolResult:
51
+ command = arguments.get("command", "")
52
+ timeout = arguments.get("timeout", DEFAULT_TIMEOUT)
53
+
54
+ if not command:
55
+ return ToolResult(success=False, error="No command provided.")
56
+
57
+ if is_dangerous(command, self.dangerous_patterns):
58
+ return ToolResult(
59
+ success=False,
60
+ error=f"Command blocked by safety policy: {command}",
61
+ )
62
+
63
+ # Cap the effective timeout so a huge value cannot block the worker
64
+ # indefinitely. The cap is documented in the tool schema above.
65
+ try:
66
+ requested_timeout = float(timeout)
67
+ except (TypeError, ValueError):
68
+ return ToolResult(success=False, error=f"Invalid timeout: {timeout}")
69
+ effective_timeout = min(requested_timeout, MAX_TIMEOUT)
70
+
71
+ shell = shutil.which("bash") or "/bin/bash"
72
+
73
+ try:
74
+ proc = subprocess.run(
75
+ [shell, "-c", command],
76
+ capture_output=True,
77
+ text=True,
78
+ errors="replace",
79
+ timeout=effective_timeout,
80
+ cwd=str(self.workdir),
81
+ )
82
+ except subprocess.TimeoutExpired:
83
+ return ToolResult(
84
+ success=False,
85
+ error=f"Command timed out after {effective_timeout}s.",
86
+ )
87
+ except Exception as e: # noqa: BLE001
88
+ return ToolResult(success=False, error=f"Execution failed: {e}")
89
+
90
+ output = proc.stdout
91
+ if proc.stderr:
92
+ output += ("\n" if output else "") + f"[stderr]\n{proc.stderr}"
93
+
94
+ output = truncate_output(
95
+ output,
96
+ suffix=(
97
+ f"\n\n[output truncated: exceeded {MAX_OUTPUT_BYTES} byte limit]"
98
+ ),
99
+ )
100
+
101
+ if proc.returncode != 0:
102
+ exit_msg = f"Command failed with exit code {proc.returncode}."
103
+ output = f"{exit_msg}\n{output}" if output else exit_msg
104
+ return ToolResult(
105
+ success=False,
106
+ output=output,
107
+ error=exit_msg,
108
+ )
109
+
110
+ return ToolResult(success=True, output=output or "")
111
+
112
+
113
+ _CONTROL_OPERATOR_RE = re.compile(r"(;|&&|&|\|\||\|)")
114
+
115
+
116
+ def _tokenize_command(command: str) -> list[str]:
117
+ """Split a command into tokens, treating shell control operators as separate tokens."""
118
+ raw_tokens = shlex.split(command)
119
+ tokens: list[str] = []
120
+ for raw in raw_tokens:
121
+ for part in _CONTROL_OPERATOR_RE.split(raw):
122
+ if part:
123
+ tokens.append(part)
124
+ return tokens
125
+
126
+
127
+ def is_dangerous(command: str, patterns: list[str]) -> bool:
128
+ """Return True if command matches a dangerous pattern.
129
+
130
+ Single-token patterns are matched only in command-name positions: the first
131
+ token of the command or the first token after a shell control operator
132
+ (``;``, ``&&``, ``&``, ``||``, ``|``). Multi-token patterns are matched
133
+ against the leading tokens starting at each command position.
134
+
135
+ .. warning::
136
+ This check is heuristic only. It tokenizes the top-level command, so
137
+ subshells (``bash -c ...``), command substitution (``$(rm ...)``),
138
+ variable indirection, variable assignments before a command name
139
+ (``VAR=1 rm -rf /``), and options between the command name and the
140
+ matched tokens (``git -C /foo reset --hard``) can bypass it.
141
+ """
142
+ tokens = _tokenize_command(command)
143
+ control_operators = {";", "&&", "&", "||", "|"}
144
+ command_starts = [0]
145
+ for i in range(1, len(tokens)):
146
+ if tokens[i - 1] in control_operators:
147
+ command_starts.append(i)
148
+
149
+ for pattern in patterns:
150
+ if not pattern:
151
+ continue
152
+ pattern_tokens = _tokenize_command(pattern)
153
+ for start in command_starts:
154
+ if start >= len(tokens):
155
+ continue
156
+ if len(pattern_tokens) == 1:
157
+ name = pattern_tokens[0]
158
+ token = tokens[start]
159
+ if token == name or Path(token).name == name:
160
+ return True
161
+ else:
162
+ first_pattern = pattern_tokens[0]
163
+ first_token = tokens[start]
164
+ first_matches = (
165
+ first_token == first_pattern
166
+ or Path(first_token).name == Path(first_pattern).name
167
+ )
168
+ if first_matches and tokens[
169
+ start + 1 : start + len(pattern_tokens)
170
+ ] == pattern_tokens[1:]:
171
+ return True
172
+ return False
limbo/tools/edit.py ADDED
@@ -0,0 +1,70 @@
1
+ """Surgical file edit tool."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import difflib
6
+ from typing import Any
7
+
8
+ from limbo.models import ToolResult
9
+ from limbo.tools.base import BaseTool
10
+
11
+
12
+ class EditTool(BaseTool):
13
+ name = "edit"
14
+ description = (
15
+ "Make surgical edits to a file by replacing exact text. old_text must match exactly. "
16
+ "The displayed diff may not clearly indicate trailing-newline changes."
17
+ )
18
+ parameters = {
19
+ "type": "object",
20
+ "properties": {
21
+ "path": {"type": "string", "description": "Path to the file"},
22
+ "old_text": {
23
+ "type": "string",
24
+ "description": "Exact text to find and replace",
25
+ },
26
+ "new_text": {
27
+ "type": "string",
28
+ "description": "New text to replace with",
29
+ },
30
+ },
31
+ "required": ["path", "old_text", "new_text"],
32
+ }
33
+
34
+ def run(self, arguments: dict[str, Any]) -> ToolResult:
35
+ raw_path = arguments.get("path", "")
36
+ old_text = arguments.get("old_text", "")
37
+ new_text = arguments.get("new_text", "")
38
+ target = self.resolve_existing(raw_path, noun="File")
39
+
40
+ try:
41
+ content = target.read_text(encoding="utf-8")
42
+ except OSError as e:
43
+ return ToolResult(success=False, error=f"Could not read file: {e}")
44
+
45
+ if old_text == "":
46
+ return ToolResult(success=False, error="old_text cannot be empty.")
47
+
48
+ occurrences = content.count(old_text)
49
+ if occurrences == 0:
50
+ return ToolResult(success=False, error=f"old_text not found in {raw_path}.")
51
+ if occurrences > 1:
52
+ return ToolResult(success=False, error=f"Text must be unique in {raw_path}.")
53
+
54
+ new_content = content.replace(old_text, new_text, 1)
55
+ if new_content == content:
56
+ return ToolResult(success=False, error=f"No changes to {raw_path}.")
57
+
58
+ diff = self._make_diff(content, new_content)
59
+
60
+ try:
61
+ target.write_text(new_content, encoding="utf-8")
62
+ except OSError as e:
63
+ return ToolResult(success=False, error=f"Could not write file: {e}")
64
+
65
+ return ToolResult(success=True, output=f"Edited {raw_path}.\n{diff}")
66
+
67
+ def _make_diff(self, old: str, new: str) -> str:
68
+ return "".join(
69
+ difflib.unified_diff(old.splitlines(), new.splitlines(), lineterm="\n")
70
+ )
limbo/tools/find.py ADDED
@@ -0,0 +1,71 @@
1
+ """Find files by glob pattern tool."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from limbo.models import ToolResult
8
+ from limbo.tools.base import (
9
+ BaseTool,
10
+ is_within_workdir,
11
+ truncate_output,
12
+ )
13
+ from limbo.tools.ignore import GitignoreMatcher
14
+
15
+ MAX_RESULTS = 1000
16
+
17
+
18
+ class FindTool(BaseTool):
19
+ name = "find"
20
+ description = (
21
+ "Find files by glob pattern. Respects the root .gitignore only. "
22
+ "The built-in matcher is minimal and does not support nested .gitignore "
23
+ "files or advanced glob/negation rules."
24
+ )
25
+ parameters = {
26
+ "type": "object",
27
+ "properties": {
28
+ "pattern": {"type": "string", "description": "Glob pattern"},
29
+ "path": {"type": "string", "description": "Directory to search"},
30
+ "limit": {"type": "integer", "description": "Max results"},
31
+ },
32
+ "required": ["pattern"],
33
+ }
34
+
35
+ def run(self, arguments: dict[str, Any]) -> ToolResult:
36
+ pattern = arguments.get("pattern", "")
37
+ path = arguments.get("path", ".")
38
+ limit = arguments.get("limit", MAX_RESULTS)
39
+
40
+ target = self.resolve_existing(path)
41
+
42
+ try:
43
+ # ``glob`` may traverse directory symlinks before we can filter them;
44
+ # we resolve and enforce the workdir boundary below.
45
+ matches = sorted(target.glob(pattern))
46
+ except ValueError as e:
47
+ return ToolResult(success=False, error=f"Invalid glob pattern: {e}")
48
+
49
+ matcher = GitignoreMatcher(self.workdir)
50
+
51
+ results = []
52
+ for p in matches:
53
+ try:
54
+ resolved = p.resolve()
55
+ except OSError:
56
+ continue
57
+ if not resolved.is_file():
58
+ continue
59
+ if not is_within_workdir(resolved, self.workdir):
60
+ continue
61
+ rel = str(p.relative_to(self.workdir))
62
+ if matcher.is_ignored(rel):
63
+ continue
64
+ results.append(rel)
65
+ if len(results) >= limit:
66
+ break
67
+
68
+ output = "\n".join(results)
69
+ output = truncate_output(output)
70
+
71
+ return ToolResult(success=True, output=output or "No matches.")
limbo/tools/grep.py ADDED
@@ -0,0 +1,182 @@
1
+ """Search file contents tool."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fnmatch
6
+ import re
7
+ import subprocess
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from limbo.models import ToolResult
12
+ from limbo.tools.base import (
13
+ BaseTool,
14
+ is_within_workdir,
15
+ truncate_output,
16
+ )
17
+ from limbo.tools.ignore import GitignoreMatcher
18
+
19
+ MAX_MATCHES = 100
20
+
21
+
22
+ class GrepTool(BaseTool):
23
+ name = "grep"
24
+ description = (
25
+ "Search file contents for a pattern. Respects .gitignore (basic rules). "
26
+ "Install ripgrep for full gitignore, glob, and context-line support. "
27
+ "The Python fallback does not support `**` recursive glob patterns."
28
+ )
29
+ parameters = {
30
+ "type": "object",
31
+ "properties": {
32
+ "pattern": {"type": "string", "description": "Search pattern"},
33
+ "path": {"type": "string", "description": "Directory or file to search"},
34
+ "glob": {
35
+ "type": "string",
36
+ "description": "Filter files by glob (Python fallback does not support `**`)",
37
+ },
38
+ "ignore_case": {"type": "boolean", "description": "Case-insensitive"},
39
+ "fixed_string": {"type": "boolean", "description": "Literal string"},
40
+ "context": {"type": "integer", "description": "Lines before/after match"},
41
+ "limit": {
42
+ "type": "integer",
43
+ "description": (
44
+ "Max matches (per file with ripgrep,"
45
+ " total across files in Python fallback)"
46
+ ),
47
+ },
48
+ },
49
+ "required": ["pattern"],
50
+ }
51
+
52
+ def run(self, arguments: dict[str, Any]) -> ToolResult:
53
+ pattern = arguments.get("pattern", "")
54
+ path = arguments.get("path", ".")
55
+ glob = arguments.get("glob")
56
+ ignore_case = arguments.get("ignore_case", False)
57
+ fixed_string = arguments.get("fixed_string", False)
58
+ context = arguments.get("context", 0)
59
+ limit = arguments.get("limit", MAX_MATCHES)
60
+
61
+ target = self.resolve_existing(path)
62
+
63
+ rg = self._find_rg()
64
+ if rg:
65
+ return self._run_rg(
66
+ rg, target, pattern, glob, ignore_case, fixed_string, context, limit
67
+ )
68
+ return self._run_python_regex(
69
+ target, pattern, glob, ignore_case, fixed_string, context, limit
70
+ )
71
+
72
+ def _find_rg(self) -> str | None:
73
+ import shutil
74
+
75
+ return shutil.which("rg")
76
+
77
+ def _run_rg(
78
+ self,
79
+ rg: str,
80
+ target: Path,
81
+ pattern: str,
82
+ glob: str | None,
83
+ ignore_case: bool,
84
+ fixed_string: bool,
85
+ context: int,
86
+ limit: int,
87
+ ) -> ToolResult:
88
+ cmd = [rg, "--line-number", "--no-heading", "--color=never", "--no-require-git"]
89
+ if ignore_case:
90
+ cmd.append("--ignore-case")
91
+ if fixed_string:
92
+ cmd.append("--fixed-strings")
93
+ if context:
94
+ cmd.extend(["-C", str(context)])
95
+ if glob:
96
+ cmd.extend(["-g", glob])
97
+ # Emit workdir-relative paths, consistent with the Python fallback and find.
98
+ rel_target = target.relative_to(self.workdir)
99
+ cmd.extend(["--max-count", str(limit), "--", pattern, str(rel_target)])
100
+
101
+ try:
102
+ proc = subprocess.run(
103
+ cmd,
104
+ capture_output=True,
105
+ text=True,
106
+ timeout=30,
107
+ cwd=str(self.workdir),
108
+ )
109
+ except subprocess.TimeoutExpired:
110
+ return ToolResult(success=False, error="grep timed out.")
111
+ except FileNotFoundError:
112
+ return ToolResult(success=False, error="ripgrep not found.")
113
+
114
+ output = proc.stdout
115
+ output = truncate_output(output)
116
+
117
+ return ToolResult(success=True, output=output or "No matches.")
118
+
119
+ def _run_python_regex(
120
+ self,
121
+ target: Path,
122
+ pattern: str,
123
+ glob: str | None,
124
+ ignore_case: bool,
125
+ fixed_string: bool,
126
+ context: int,
127
+ limit: int,
128
+ ) -> ToolResult:
129
+ # Fallback path: supports pattern/glob/ignore_case/fixed_string/limit.
130
+ # Context lines are only available through ripgrep.
131
+ if context:
132
+ return ToolResult(
133
+ success=False,
134
+ error="Context lines require ripgrep. Install ripgrep or omit 'context'.",
135
+ )
136
+
137
+ flags = re.IGNORECASE if ignore_case else 0
138
+ try:
139
+ compiled = re.compile(
140
+ re.escape(pattern) if fixed_string else pattern, flags
141
+ )
142
+ except re.error as e:
143
+ return ToolResult(success=False, error=f"Invalid regex: {e}")
144
+
145
+ matches = []
146
+ matcher = GitignoreMatcher(self.workdir)
147
+ # ``rglob`` may traverse directory symlinks before we can filter them;
148
+ # we resolve and enforce the workdir boundary for each candidate.
149
+ files = [target] if target.is_file() else target.rglob("*")
150
+ count = 0
151
+ for f in files:
152
+ # Resolve symlinks and enforce the workdir boundary before reading.
153
+ resolved = f.resolve()
154
+ if not is_within_workdir(resolved, self.workdir):
155
+ continue
156
+ if not resolved.is_file():
157
+ continue
158
+ try:
159
+ rel = resolved.relative_to(self.workdir)
160
+ except ValueError:
161
+ continue
162
+ rel_str = str(rel)
163
+ if matcher.is_ignored(rel_str):
164
+ continue
165
+ if glob is not None and not fnmatch.fnmatch(rel_str, glob):
166
+ continue
167
+ try:
168
+ text = resolved.read_text(encoding="utf-8", errors="replace")
169
+ except OSError:
170
+ continue
171
+ for lineno, line in enumerate(text.splitlines(), start=1):
172
+ if compiled.search(line):
173
+ matches.append(f"{rel}:{lineno}:{line}")
174
+ count += 1
175
+ if count >= limit:
176
+ break
177
+ if count >= limit:
178
+ break
179
+
180
+ output = "\n".join(matches)
181
+ output = truncate_output(output)
182
+ return ToolResult(success=True, output=output or "No matches.")