minimalcode 1.5.2__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.
Files changed (58) hide show
  1. minimalcode/__init__.py +3 -0
  2. minimalcode/__main__.py +3 -0
  3. minimalcode/agent.py +1084 -0
  4. minimalcode/cli.py +1520 -0
  5. minimalcode/clipboard.py +212 -0
  6. minimalcode/config.py +1065 -0
  7. minimalcode/defaults.json +491 -0
  8. minimalcode/errors.py +31 -0
  9. minimalcode/highlight/__init__.py +47 -0
  10. minimalcode/highlight/code.py +261 -0
  11. minimalcode/highlight/color.py +58 -0
  12. minimalcode/highlight/markdown.py +710 -0
  13. minimalcode/highlight/palette.py +73 -0
  14. minimalcode/highlight/sanitize.py +175 -0
  15. minimalcode/highlight/thinking.py +71 -0
  16. minimalcode/httpclient.py +278 -0
  17. minimalcode/importer.py +253 -0
  18. minimalcode/prompt.py +195 -0
  19. minimalcode/provider/__init__.py +28 -0
  20. minimalcode/provider/anthropic.py +805 -0
  21. minimalcode/provider/anthropic_env.py +161 -0
  22. minimalcode/provider/chat.py +394 -0
  23. minimalcode/provider/client.py +243 -0
  24. minimalcode/provider/common.py +176 -0
  25. minimalcode/provider/deepseek_web.py +1158 -0
  26. minimalcode/provider/factory.py +63 -0
  27. minimalcode/provider/responses.py +692 -0
  28. minimalcode/provider/sha3_wasm_bg.wasm +0 -0
  29. minimalcode/provider/transport.py +241 -0
  30. minimalcode/provider/types.py +54 -0
  31. minimalcode/sandbox/__init__.py +64 -0
  32. minimalcode/sandbox/audit.py +120 -0
  33. minimalcode/sandbox/backends.py +343 -0
  34. minimalcode/sandbox/execution.py +435 -0
  35. minimalcode/sandbox/network.py +793 -0
  36. minimalcode/sandbox/policy.py +253 -0
  37. minimalcode/sandbox/runner.py +347 -0
  38. minimalcode/sandbox/sensitive.py +286 -0
  39. minimalcode/session.py +465 -0
  40. minimalcode/shell.py +333 -0
  41. minimalcode/subagent.py +120 -0
  42. minimalcode/tools/__init__.py +46 -0
  43. minimalcode/tools/files.py +1139 -0
  44. minimalcode/tools/process.py +164 -0
  45. minimalcode/tools/remote.py +74 -0
  46. minimalcode/tools/runtime.py +219 -0
  47. minimalcode/tools/schemas.py +418 -0
  48. minimalcode/tools/search.py +521 -0
  49. minimalcode/tools/search_tools.py +583 -0
  50. minimalcode/tools/validation.py +99 -0
  51. minimalcode/vision.py +231 -0
  52. minimalcode/web.py +238 -0
  53. minimalcode-1.5.2.dist-info/METADATA +646 -0
  54. minimalcode-1.5.2.dist-info/RECORD +58 -0
  55. minimalcode-1.5.2.dist-info/WHEEL +5 -0
  56. minimalcode-1.5.2.dist-info/entry_points.txt +2 -0
  57. minimalcode-1.5.2.dist-info/licenses/LICENSE +201 -0
  58. minimalcode-1.5.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,583 @@
1
+ """Grep and find implementations exposed as ToolRuntime mixins."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import shutil
8
+ from collections.abc import Iterator
9
+ from functools import lru_cache
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from ..errors import ToolError
14
+ from ..sandbox import SENSITIVE_SEARCH_GLOBS
15
+ from . import search as search_module
16
+ from .search import (
17
+ _MAX_GREP_LINE_CHARS,
18
+ IGNORED_DIRECTORIES,
19
+ IGNORED_GLOBS,
20
+ inline_guard_available,
21
+ inline_python_grep,
22
+ run_python_grep,
23
+ smart_case_ignore,
24
+ )
25
+ from .validation import positive_limit
26
+
27
+ _GREP_RESULT_RE = re.compile(r"^(?P<path>.+?):(?P<line>\d+):")
28
+ # Sensitive-path filtering happens after ripgrep exits, so a run that returns
29
+ # only filtered paths must be retried with a wider window. The cap keeps one
30
+ # pathological search from scanning an unbounded number of lines.
31
+ _MAX_GREP_SCAN_LINES = 20_000
32
+ # `{a,b}{c,d}...` expands to 2**n patterns. A cap keeps a pathological glob
33
+ # from turning `find`/`grep` into an hour-long loop.
34
+ _MAX_GLOB_VARIANTS = 256
35
+
36
+
37
+ class SearchToolsMixin:
38
+ """Workspace-scoped text and filename search."""
39
+
40
+ def grep(
41
+ self,
42
+ pattern: str,
43
+ path: str = ".",
44
+ glob: str | None = None,
45
+ max_results: int | None = None,
46
+ ) -> dict[str, Any]:
47
+ """Search file contents with ripgrep semantics and a Python fallback."""
48
+ if not isinstance(pattern, str) or not pattern:
49
+ raise ToolError("pattern must be a non-empty string")
50
+ if glob is not None and not isinstance(glob, str):
51
+ raise ToolError("glob must be a string or null")
52
+ target = self._path(path, operation="read")
53
+ if not target.exists():
54
+ raise ToolError(f"path not found: {target}")
55
+ if not target.is_file() and not target.is_dir():
56
+ raise ToolError(f"path is not a file or directory: {target}")
57
+ limit = positive_limit(max_results, self.max_results, 2000, "max_results")
58
+ if (
59
+ glob
60
+ and target.is_file()
61
+ and not self._matches_glob(target, target.parent, glob)
62
+ ):
63
+ # ripgrep does not apply `--glob` to a file named on the command
64
+ # line, so `grep(path="a.txt", glob="*.py")` returned the file with
65
+ # rg installed and nothing without it. The restriction is enforced
66
+ # here so both engines agree.
67
+ return {"status": "success", "matches": [], "truncated": False}
68
+
69
+ # Prefer ripgrep when it is installed and the backend can spawn argv.
70
+ rg = shutil.which("rg")
71
+ if rg and self._can_spawn_argv():
72
+ command = [
73
+ rg,
74
+ "--no-config",
75
+ "--line-number",
76
+ "--no-heading",
77
+ # A single-file target otherwise prints `LINE:TEXT` with no
78
+ # path, which the result-path filter rejects as a stray line.
79
+ "--with-filename",
80
+ "--color",
81
+ "never",
82
+ "--hidden",
83
+ "--no-ignore",
84
+ "--no-follow",
85
+ "--smart-case",
86
+ "--max-columns",
87
+ str(_MAX_GREP_LINE_CHARS),
88
+ "--max-columns-preview",
89
+ ]
90
+ if glob:
91
+ command.extend(["--glob", self._anchor_glob(glob, target)])
92
+ # Exclusions must follow the positive glob: ripgrep uses the last
93
+ # matching --glob, so this order stays exclusion-only.
94
+ for ignored_glob in self._ignore_globs(target):
95
+ command.extend(["--glob", ignored_glob])
96
+ for sandbox_glob in self._sandbox_globs():
97
+ command.extend(["--glob", sandbox_glob])
98
+ command.extend(["--", pattern, str(target)])
99
+ requested = limit + 1
100
+ while True:
101
+ returncode, lines, truncated, stderr = (
102
+ search_module.run_bounded_command(
103
+ command,
104
+ cwd=self.cwd,
105
+ # One line past the requested count proves truncation.
106
+ limit=requested,
107
+ sandbox=self.sandbox,
108
+ )
109
+ )
110
+ # Exit 1 means "no matches" and is not an error.
111
+ if not truncated and returncode not in (0, 1):
112
+ raise ToolError(stderr or f"grep exited {returncode}")
113
+ matches = [line for line in lines if self._grep_line_allowed(line)]
114
+ if not truncated or len(matches) > limit or len(lines) < requested:
115
+ break
116
+ # Filtered paths consumed the whole window. Read a wider one
117
+ # so an allowed match later in the output is not lost.
118
+ grown = min(requested * 4, _MAX_GREP_SCAN_LINES)
119
+ if grown <= requested:
120
+ break
121
+ requested = grown
122
+ return {
123
+ "status": "success",
124
+ "matches": matches[:limit],
125
+ "truncated": truncated or len(matches) > limit,
126
+ }
127
+ return self._python_grep(pattern, target, glob, limit)
128
+
129
+ def _explicit_ignored_target(self, target: Path) -> bool:
130
+ """Return True when the caller named a normally ignored directory."""
131
+ relative = None
132
+ for root in (self.cwd, *self.sandbox.policy.allowed_roots):
133
+ try:
134
+ relative = target.relative_to(root)
135
+ break
136
+ except ValueError:
137
+ continue
138
+ if relative is None:
139
+ relative = target
140
+ parts = list(relative.parts)
141
+ if target.is_file():
142
+ parts = parts[:-1]
143
+ return any(part in IGNORED_DIRECTORIES for part in parts)
144
+
145
+ def _ignore_globs(self, target: Path) -> tuple[str, ...]:
146
+ # An explicitly requested ignored path must still be searchable;
147
+ # otherwise `grep(..., path="build")` silently returns nothing.
148
+ return () if self._explicit_ignored_target(target) else IGNORED_GLOBS
149
+
150
+ def _sandbox_globs(self) -> tuple[str, ...]:
151
+ # The sensitive-file filter is a path policy, not an execution setting,
152
+ # so it also applies when `sandbox.enabled` is false.
153
+ if self.sandbox.policy.allow_sensitive_files:
154
+ return ()
155
+ return SENSITIVE_SEARCH_GLOBS
156
+
157
+ def _anchor_glob(self, glob_pattern: str, target: Path) -> str:
158
+ """Anchor a path-bearing glob the way ripgrep matches ``--glob``.
159
+
160
+ ripgrep compares a glob against the candidate path relative to its
161
+ working directory (absolute only outside it), so a glob from the
162
+ caller — which is relative to the *search root* — never matched when
163
+ the two differed. `src/*.py` searched from `.` silently returned no
164
+ files while the Python fallback found them. Globs without a separator
165
+ keep matching the file name at any depth.
166
+ """
167
+ if "/" not in glob_pattern:
168
+ return glob_pattern
169
+ negated = glob_pattern.startswith("!")
170
+ body = glob_pattern[1:] if negated else glob_pattern
171
+ if body.startswith("/") or (len(body) > 1 and body[1] == ":"):
172
+ return glob_pattern
173
+ try:
174
+ prefix = target.relative_to(self.cwd).as_posix()
175
+ except ValueError:
176
+ # A root outside the workspace has no cwd-relative form.
177
+ prefix = target.as_posix()
178
+ anchored = body if prefix in {"", "."} else f"{prefix.rstrip('/')}/{body}"
179
+ return f"!{anchored}" if negated else anchored
180
+
181
+ def _allowed_result_path(self, raw: str) -> bool:
182
+ """Return True when one search result may be shown for this workspace."""
183
+ try:
184
+ candidate = Path(raw)
185
+ if not candidate.is_absolute():
186
+ candidate = self.cwd / candidate
187
+ candidate = candidate.resolve(strict=False)
188
+ except (OSError, RuntimeError, ValueError):
189
+ return False
190
+ return self.sandbox.is_allowed(
191
+ candidate, operation="read", allow_outside=self.allow_outside
192
+ )
193
+
194
+ def _grep_line_allowed(self, line: str) -> bool:
195
+ """Keep one ripgrep match only when its path is allowed."""
196
+ path = self._grep_line_path(line)
197
+ if path is None:
198
+ # A head/tail truncation marker or a line fragment is not a match.
199
+ return False
200
+ return self._allowed_result_path(path)
201
+
202
+ def _grep_line_path(self, line: str) -> str | None:
203
+ """Extract the file path from one `path:line:text` match line.
204
+
205
+ A file name may itself contain ``:N:``, so the shortest prefix is not
206
+ always the real path. The longest prefix that exists on disk wins; the
207
+ first candidate is the fallback for a path that vanished mid-search.
208
+ """
209
+ match = _GREP_RESULT_RE.match(line)
210
+ if match is None:
211
+ return None
212
+ fallback = match.group("path")
213
+ chosen = fallback
214
+ for boundary in re.finditer(r":\d+:", line):
215
+ candidate = line[: boundary.start()]
216
+ try:
217
+ path = Path(candidate)
218
+ if not path.is_absolute():
219
+ path = self.cwd / path
220
+ if path.exists():
221
+ chosen = candidate
222
+ except (OSError, RuntimeError, ValueError):
223
+ continue
224
+ return chosen
225
+
226
+ def _find_result_allowed(self, raw: str) -> bool:
227
+ """Keep one `rg --files` path only when it names an allowed file."""
228
+ try:
229
+ candidate = Path(raw)
230
+ if not candidate.is_absolute():
231
+ candidate = self.cwd / candidate
232
+ if not candidate.is_file():
233
+ return False
234
+ except (OSError, RuntimeError, ValueError):
235
+ return False
236
+ return self._allowed_result_path(str(candidate))
237
+
238
+ def _can_spawn_argv(self) -> bool:
239
+ """Return True when the active backend can start a child process.
240
+
241
+ Capabilities describe what a backend *could* do; a selected backend
242
+ that is not installed must still let file tools fall back to the
243
+ in-process scanner instead of failing the whole search.
244
+ """
245
+ if not self.sandbox.policy.enabled:
246
+ return True
247
+ return (
248
+ "argv" in self.sandbox.backend.capabilities
249
+ and self.sandbox.backend.available()
250
+ )
251
+
252
+ def _python_grep(
253
+ self,
254
+ pattern: str,
255
+ target: Path,
256
+ glob_pattern: str | None,
257
+ limit: int,
258
+ ) -> dict[str, Any]:
259
+ """Run the Python fallback over the same file set ripgrep would see."""
260
+ flags = re.IGNORECASE if smart_case_ignore(pattern) else 0
261
+ try:
262
+ re.compile(pattern, flags)
263
+ except re.error as exc:
264
+ raise ToolError(f"invalid regular expression: {exc}") from exc
265
+
266
+ ignored = () if self._explicit_ignored_target(target) else IGNORED_DIRECTORIES
267
+ files = (
268
+ [target]
269
+ if target.is_file()
270
+ else list(self._walk_files(target, ignored=ignored))
271
+ )
272
+ if glob_pattern:
273
+ # A single-file target has no relative path of its own, so its
274
+ # parent is the root the glob is matched against; otherwise the
275
+ # search root anchors the walk.
276
+ glob_root = target.parent if target.is_file() else target
277
+ files = [
278
+ file_path
279
+ for file_path in files
280
+ if self._matches_glob(file_path, glob_root, glob_pattern)
281
+ ]
282
+ if not files:
283
+ return {"status": "success", "matches": [], "truncated": False}
284
+ if not self._can_spawn_argv():
285
+ # A backend without argv support still gets line-accurate results.
286
+ # The in-process engine cannot interrupt a catastrophic regex from
287
+ # a worker thread or on Windows, so in that case the trusted child
288
+ # interpreter is used instead of risking a hung agent process.
289
+ if inline_guard_available():
290
+ return inline_python_grep(
291
+ pattern=pattern,
292
+ files=files,
293
+ limit=limit,
294
+ max_bytes=self.max_file_bytes,
295
+ )
296
+ return run_python_grep(
297
+ pattern=pattern,
298
+ files=files,
299
+ limit=limit,
300
+ max_bytes=self.max_file_bytes,
301
+ sandbox=None,
302
+ cwd=self.cwd,
303
+ )
304
+ return run_python_grep(
305
+ pattern=pattern,
306
+ files=files,
307
+ limit=limit,
308
+ max_bytes=self.max_file_bytes,
309
+ sandbox=self.sandbox,
310
+ cwd=self.cwd,
311
+ )
312
+
313
+ @staticmethod
314
+ @lru_cache(maxsize=256)
315
+ def _glob_tokens(glob_pattern: str) -> tuple[tuple[str, Any], ...]:
316
+ """Translate a ripgrep-style glob into path-aware match tokens.
317
+
318
+ A regex translation of repeated ``**`` runs (``**a**a**a...``) is
319
+ exponential on a non-matching path, so matching is a small linear scan
320
+ instead. ``*`` never crosses ``/``, ``**`` does, and ``**/`` matches
321
+ zero or more complete directories.
322
+ """
323
+ tokens: list[tuple[str, Any]] = []
324
+ index = 0
325
+ length = len(glob_pattern)
326
+ while index < length:
327
+ char = glob_pattern[index]
328
+ if char == "*":
329
+ if glob_pattern.startswith("**/", index):
330
+ tokens.append(("dirs", None))
331
+ index += 3
332
+ elif glob_pattern.startswith("**", index):
333
+ tokens.append(("any", None))
334
+ index += 2
335
+ else:
336
+ tokens.append(("star", None))
337
+ index += 1
338
+ continue
339
+ if char == "?":
340
+ tokens.append(("one", None))
341
+ index += 1
342
+ continue
343
+ if char == "[":
344
+ end = index + 1
345
+ if end < length and glob_pattern[end] in "!^":
346
+ end += 1
347
+ if end < length and glob_pattern[end] == "]":
348
+ end += 1
349
+ while end < length and glob_pattern[end] != "]":
350
+ end += 1
351
+ if end >= length:
352
+ tokens.append(("lit", "["))
353
+ index += 1
354
+ continue
355
+ content = glob_pattern[index + 1 : end]
356
+ if content.startswith("!"):
357
+ content = "^" + content[1:]
358
+ if content in {"", "^"}:
359
+ tokens.append(("lit", "[]"))
360
+ else:
361
+ try:
362
+ tokens.append(("class", re.compile(f"[{content}]")))
363
+ except re.error:
364
+ tokens.append(("lit", glob_pattern[index : end + 1]))
365
+ index = end + 1
366
+ continue
367
+ end = index
368
+ while end < length and glob_pattern[end] not in "*?[":
369
+ end += 1
370
+ tokens.append(("lit", glob_pattern[index:end]))
371
+ index = end
372
+ return tuple(tokens)
373
+
374
+ @classmethod
375
+ @lru_cache(maxsize=256)
376
+ def _expand_braces(cls, glob_pattern: str) -> tuple[str, ...]:
377
+ """Expand the simple ``{py,txt}`` alternation ripgrep accepts."""
378
+ if cls._brace_variants(glob_pattern) > _MAX_GLOB_VARIANTS:
379
+ # Matching the pattern literally beats an exponential expansion.
380
+ return (glob_pattern,)
381
+ return cls._expand_braces_uncapped(glob_pattern)
382
+
383
+ @staticmethod
384
+ def _brace_variants(glob_pattern: str) -> int:
385
+ """Count the alternatives the brace groups would expand to."""
386
+ count = 1
387
+ index = 0
388
+ while True:
389
+ start = glob_pattern.find("{", index)
390
+ if start < 0:
391
+ return count
392
+ end = glob_pattern.find("}", start + 1)
393
+ if end < 0:
394
+ return count
395
+ choices = len(glob_pattern[start + 1 : end].split(","))
396
+ if choices > 1:
397
+ count *= choices
398
+ if count > _MAX_GLOB_VARIANTS:
399
+ return count
400
+ index = end + 1
401
+
402
+ @classmethod
403
+ def _expand_braces_uncapped(cls, glob_pattern: str) -> tuple[str, ...]:
404
+ start = glob_pattern.find("{")
405
+ if start < 0:
406
+ return (glob_pattern,)
407
+ end = glob_pattern.find("}", start + 1)
408
+ if end < 0:
409
+ return (glob_pattern,)
410
+ choices = glob_pattern[start + 1 : end].split(",")
411
+ if len(choices) < 2 or any(not choice for choice in choices):
412
+ return (glob_pattern,)
413
+ prefix = glob_pattern[:start]
414
+ suffix = glob_pattern[end + 1 :]
415
+ # A pattern can hold more than one group (`{a,b}.{py,txt}`); leaving a
416
+ # later group in place matched nothing at all.
417
+ return tuple(
418
+ expanded
419
+ for choice in choices
420
+ for expanded in cls._expand_braces(prefix + choice + suffix)
421
+ )
422
+
423
+ @classmethod
424
+ def _matches_glob(cls, file_path: Path, root: Path, glob_pattern: str) -> bool:
425
+ """Match a ripgrep-style glob against the file name and relative path."""
426
+ if glob_pattern.startswith("!"):
427
+ # ripgrep treats a leading `!` as an exclusion.
428
+ return not cls._matches_glob(file_path, root, glob_pattern[1:])
429
+ return any(
430
+ cls._matches_single_glob(file_path, root, expanded)
431
+ for expanded in cls._expand_braces(glob_pattern)
432
+ )
433
+
434
+ @classmethod
435
+ def _matches_single_glob(
436
+ cls, file_path: Path, root: Path, glob_pattern: str
437
+ ) -> bool:
438
+ try:
439
+ relative = file_path.relative_to(root).as_posix()
440
+ except ValueError:
441
+ relative = file_path.as_posix()
442
+ # A glob containing a separator is matched against the relative path;
443
+ # `*.py` keeps matching any file name, but `*` no longer crosses `/`.
444
+ candidate = relative if "/" in glob_pattern else file_path.name
445
+ return cls._match_glob_tokens(cls._glob_tokens(glob_pattern), candidate)
446
+
447
+ @staticmethod
448
+ def _match_glob_tokens(tokens: tuple[tuple[str, Any], ...], text: str) -> bool:
449
+ """Return True when ``text`` matches the token sequence."""
450
+ length = len(text)
451
+ positions: set[int] = {0}
452
+ for kind, value in tokens:
453
+ next_positions: set[int] = set()
454
+ if kind == "lit":
455
+ for position in positions:
456
+ if text.startswith(value, position):
457
+ next_positions.add(position + len(value))
458
+ elif kind == "one":
459
+ for position in positions:
460
+ if position < length and text[position] != "/":
461
+ next_positions.add(position + 1)
462
+ elif kind == "class":
463
+ for position in positions:
464
+ if (
465
+ position < length
466
+ and text[position] != "/"
467
+ and value.match(text[position])
468
+ ):
469
+ next_positions.add(position + 1)
470
+ elif kind == "star":
471
+ for position in positions:
472
+ next_positions.add(position)
473
+ end = position
474
+ while end < length and text[end] != "/":
475
+ end += 1
476
+ next_positions.add(end)
477
+ elif kind == "any":
478
+ for position in positions:
479
+ next_positions.update(range(position, length + 1))
480
+ elif kind == "dirs":
481
+ for position in positions:
482
+ next_positions.add(position)
483
+ end = position
484
+ while True:
485
+ slash = text.find("/", end)
486
+ if slash < 0:
487
+ break
488
+ next_positions.add(slash + 1)
489
+ end = slash + 1
490
+ positions = next_positions
491
+ if not positions:
492
+ return False
493
+ return length in positions
494
+
495
+ def find(
496
+ self,
497
+ pattern: str,
498
+ path: str = ".",
499
+ max_results: int | None = None,
500
+ ) -> dict[str, Any]:
501
+ """Find files by glob without descending into ignored directories."""
502
+ if not isinstance(pattern, str) or not pattern:
503
+ raise ToolError("pattern must be a non-empty string")
504
+ target = self._path(path, operation="read")
505
+ if not target.is_dir():
506
+ raise ToolError(f"directory not found: {target}")
507
+ limit = positive_limit(max_results, self.max_results, 5000, "max_results")
508
+
509
+ rg = shutil.which("rg")
510
+ if rg and self._can_spawn_argv():
511
+ command = [
512
+ rg,
513
+ "--no-config",
514
+ "--files",
515
+ "--hidden",
516
+ "--no-ignore",
517
+ "--no-follow",
518
+ "--glob",
519
+ self._anchor_glob(pattern, target),
520
+ ]
521
+ for ignored_glob in self._ignore_globs(target):
522
+ command.extend(["--glob", ignored_glob])
523
+ for sandbox_glob in self._sandbox_globs():
524
+ command.extend(["--glob", sandbox_glob])
525
+ command.append(str(target))
526
+ requested = limit + 1
527
+ while True:
528
+ returncode, paths, truncated, stderr = (
529
+ search_module.run_bounded_command(
530
+ command,
531
+ cwd=self.cwd,
532
+ limit=requested,
533
+ sandbox=self.sandbox,
534
+ )
535
+ )
536
+ if not truncated and returncode not in (0, 1):
537
+ raise ToolError(stderr or f"find exited {returncode}")
538
+ files = [path for path in paths if self._find_result_allowed(path)]
539
+ if not truncated or len(files) > limit or len(paths) < requested:
540
+ break
541
+ grown = min(requested * 4, _MAX_GREP_SCAN_LINES)
542
+ if grown <= requested:
543
+ break
544
+ requested = grown
545
+ return {
546
+ "status": "success",
547
+ "files": files[:limit],
548
+ "truncated": truncated or len(files) > limit,
549
+ }
550
+
551
+ paths: list[str] = []
552
+ ignored = () if self._explicit_ignored_target(target) else IGNORED_DIRECTORIES
553
+ for file_path in self._walk_files(target, ignored=ignored):
554
+ if self._matches_glob(file_path, target, pattern):
555
+ paths.append(str(file_path))
556
+ if len(paths) > limit:
557
+ return {
558
+ "status": "success",
559
+ "files": paths[:limit],
560
+ "truncated": True,
561
+ }
562
+ return {"status": "success", "files": paths[:limit], "truncated": False}
563
+
564
+ def _walk_files(
565
+ self,
566
+ root: Path,
567
+ *,
568
+ ignored: tuple[str, ...] = IGNORED_DIRECTORIES,
569
+ ) -> Iterator[Path]:
570
+ """Yield readable files under ``root`` while respecting the sandbox."""
571
+ ignored_names = set(ignored)
572
+ for directory, dirnames, filenames in os.walk(root):
573
+ dirnames[:] = [name for name in dirnames if name not in ignored_names]
574
+ for filename in filenames:
575
+ candidate = Path(directory) / filename
576
+ # os.walk does not descend into symlinked directories, but a
577
+ # symlinked file or a configured extra root can still leave the
578
+ # sandbox, so every candidate is re-checked.
579
+ if not self.sandbox.is_allowed(
580
+ candidate, operation="read", allow_outside=self.allow_outside
581
+ ):
582
+ continue
583
+ yield candidate
@@ -0,0 +1,99 @@
1
+ """Argument validation shared by the tool dispatcher.
2
+
3
+ Model-supplied values are untrusted. Binding a signature only checks names and
4
+ arity, so these helpers also enforce the documented primitive types before a
5
+ handler touches the filesystem or a child process.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from types import UnionType
11
+ from typing import Any, Union, get_args, get_origin, get_type_hints
12
+
13
+ from ..errors import ToolError
14
+
15
+ _UNION_ORIGINS = {Union, UnionType}
16
+ _TYPE_LABELS: dict[Any, str] = {
17
+ str: "a string",
18
+ bool: "a boolean",
19
+ int: "an integer",
20
+ float: "a number",
21
+ type(None): "null",
22
+ }
23
+
24
+
25
+ def clip(value: str, limit: int = 180) -> str:
26
+ """Collapse whitespace and bound a value used in one-line activity text."""
27
+ value = " ".join(value.split())
28
+ return value if len(value) <= limit else value[: limit - 3] + "..."
29
+
30
+
31
+ def positive_limit(
32
+ requested: int | None,
33
+ default: int,
34
+ maximum: int,
35
+ label: str,
36
+ ) -> int:
37
+ """Validate a result limit and clamp it to the schema's hard cap."""
38
+ if requested is not None and (
39
+ isinstance(requested, bool) or not isinstance(requested, int)
40
+ ):
41
+ raise ToolError(f"{label} must be an integer")
42
+ # A configured default above the hard cap must not silently exceed it.
43
+ limit = min(default if requested is None else requested, maximum)
44
+ if limit < 1:
45
+ raise ToolError(f"{label} must be at least 1")
46
+ return limit
47
+
48
+
49
+ def _describe_annotation(annotation: Any) -> str:
50
+ """Render a supported annotation as a short human-readable phrase."""
51
+ if get_origin(annotation) in _UNION_ORIGINS:
52
+ return " or ".join(_describe_annotation(arg) for arg in get_args(annotation))
53
+ return _TYPE_LABELS.get(annotation, "the documented type")
54
+
55
+
56
+ def _matches_annotation(value: Any, annotation: Any) -> bool:
57
+ """Return True when ``value`` satisfies the primitive annotation.
58
+
59
+ Unknown annotations are accepted so custom handlers keep their own checks;
60
+ this guard exists to stop obvious mismatches, not to replace them.
61
+ """
62
+ if get_origin(annotation) in _UNION_ORIGINS:
63
+ return any(_matches_annotation(value, arg) for arg in get_args(annotation))
64
+ if annotation is bool:
65
+ return isinstance(value, bool)
66
+ if annotation is str:
67
+ return isinstance(value, str)
68
+ if annotation is int:
69
+ return isinstance(value, int) and not isinstance(value, bool)
70
+ if annotation is float:
71
+ return isinstance(value, (int, float)) and not isinstance(value, bool)
72
+ if annotation is type(None):
73
+ return value is None
74
+ return True
75
+
76
+
77
+ def check_argument_types(
78
+ name: str,
79
+ handler: Any,
80
+ arguments: dict[str, Any],
81
+ ) -> None:
82
+ """Reject model-supplied arguments that do not match the handler signature.
83
+
84
+ ``bind`` only checks names and arity, so a call such as ``{"command": 5}``
85
+ would otherwise reach the handler and raise ``AttributeError``/``TypeError``
86
+ outside the tool-loop error handling.
87
+ """
88
+ try:
89
+ hints = get_type_hints(handler)
90
+ except (NameError, TypeError): # pragma: no cover - stay permissive
91
+ return
92
+ for key, value in arguments.items():
93
+ annotation = hints.get(key)
94
+ if annotation is None or _matches_annotation(value, annotation):
95
+ continue
96
+ raise ToolError(
97
+ f"invalid arguments for {name}: {key} must be "
98
+ f"{_describe_annotation(annotation)}"
99
+ )