@allansantos-dev/smart-tool 0.9.5 → 0.9.7

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.
package/hook_decision.py CHANGED
@@ -42,6 +42,109 @@ def deny(reason):
42
42
  "permissionDecisionReason": reason}}
43
43
 
44
44
 
45
+ # Claude Code built-in subagents whose tool list has no MCP tool (claude-code-guide: Glob, Grep, Read, WebFetch,
46
+ # WebSearch; statusline-setup: Read, Edit).
47
+ BUILTIN_AGENTS_WITHOUT_MCP = frozenset({"claude-code-guide", "statusline-setup"})
48
+
49
+
50
+ def _frontmatter(path):
51
+ """`name` and `tools` of an agent definition (.md with YAML frontmatter); tools is None when the field is absent
52
+ (the agent inherits every tool), else the list of tool names (inline `[a, b]`, `a, b` or a `- a` block)."""
53
+ with open(path, encoding="utf-8") as stream:
54
+ lines = stream.read().splitlines()
55
+ if not lines or lines[0].strip() != "---":
56
+ return None, None
57
+ name, tools, block = None, None, False
58
+ for line in lines[1:]:
59
+ if line.strip() == "---":
60
+ break
61
+ if block and line.lstrip().startswith("- "):
62
+ tools.append(line.strip()[2:].strip().strip("'\""))
63
+ continue
64
+ block = False
65
+ key, _sep, value = line.partition(":")
66
+ value = value.strip()
67
+ if key == "name":
68
+ name = value.strip("'\"")
69
+ elif key == "tools":
70
+ if value:
71
+ tools = [item.strip().strip("'\"") for item in value.strip("[]").split(",") if item.strip()]
72
+ else:
73
+ tools, block = [], True
74
+ return name, tools
75
+
76
+
77
+ def agent_has_smart_tool(agent_type, cwd):
78
+ """Whether the subagent running the call can call the Smart Tool MCP tools: denying a native tool to an agent that
79
+ has no replacement only makes it give up. Agents defined in .claude/agents (user or project) are read; unknown
80
+ agents keep the redirect."""
81
+ if not agent_type:
82
+ return True
83
+ if agent_type in BUILTIN_AGENTS_WITHOUT_MCP:
84
+ return False
85
+ for folder in (os.path.join(cwd or "", ".claude", "agents"), os.path.join(endpoint_sync.claude_dir(), "agents")):
86
+ try:
87
+ names = sorted(os.listdir(folder))
88
+ except OSError:
89
+ continue
90
+ for entry in names:
91
+ if not entry.endswith(".md"):
92
+ continue
93
+ try:
94
+ name, tools = _frontmatter(os.path.join(folder, entry))
95
+ except (OSError, UnicodeDecodeError):
96
+ continue
97
+ if (name or entry.split(".")[0]) != agent_type:
98
+ continue
99
+ return tools is None or any(tool == "*" or tool.startswith(f"mcp__{SMART_TOOL_SERVER}") for tool in tools)
100
+ return True
101
+
102
+
103
+ def _search_root(target, cwd):
104
+ """project_root for smart_search: the registered project holding the folder the redirected search reads, else that
105
+ folder; the session folder when the rule names none."""
106
+ target = os.path.abspath(target or cwd or ".")
107
+ key = os.path.normcase(target) + os.sep
108
+ roots = [p["root"] for p in project_store.all_projects()
109
+ if key.startswith(os.path.normcase(os.path.abspath(p["root"])).rstrip(os.sep) + os.sep)]
110
+ return max(roots, key=len) if roots else target
111
+
112
+
113
+ def _tool_name(client, tool):
114
+ return f"mcp__{SMART_TOOL_SERVER}__{tool}" if client == "claude" else f"the {tool} tool of the {SMART_TOOL_SERVER} MCP server"
115
+
116
+
117
+ def _load_hint(client, tool):
118
+ """Claude Code defers MCP tools behind tool search: a blocked agent may not have the schema loaded yet."""
119
+ if client != "claude":
120
+ return ""
121
+ return f' If it is not in your tool list yet, load it first with ToolSearch, query "select:mcp__{SMART_TOOL_SERVER}__{tool}".'
122
+
123
+
124
+ def redirect_message(client, tool_name, target, reason, cwd):
125
+ """Deny text for a broad raw search: the exact tool and arguments to call (project_root from the folder the search
126
+ reads), and that rewriting the same search in Bash, python, node or PowerShell is the bypass the rule stops."""
127
+ root = _search_root(target, cwd).replace("\\", "/")
128
+ return (f"Blocked by Smart Tool, a routing rule set by the user, not a failure ({tool_name}: {clean_reason(reason)}). "
129
+ f'Run this search with {_tool_name(client, "smart_search")}: project_root="{root}", query_identifiers = what '
130
+ f"you are looking for in identifier terms (plus query_comments when the project has two languages)."
131
+ f'{_load_hint(client, "smart_search")} Do not repeat the same search through Bash, python, node or '
132
+ f"PowerShell: that bypasses the rule. Reading one known file, or searching inside one known file, is never "
133
+ f"blocked.")
134
+
135
+
136
+ def web_message(client, tool_name, url, why):
137
+ """Deny text for native WebFetch/WebSearch: the Smart Tool call to make instead, with the URL filled in."""
138
+ if tool_name == "WebFetch":
139
+ call = f'{_tool_name(client, "web_fetch")} with url="{url}" and prompt = what you need from the page'
140
+ tool, fetchers = "web_fetch", "curl, wget, python or PowerShell"
141
+ else:
142
+ call = f"{_tool_name(client, 'web_search')} with query (and query_en in English)"
143
+ tool, fetchers = "web_search", "curl, a browser script or another search tool"
144
+ return (f"Blocked by Smart Tool, a routing rule set by the user, not a failure. Call {call}: {why}"
145
+ f"{_load_hint(client, tool)} Do not fetch it through {fetchers}: that bypasses the rule.")
146
+
147
+
45
148
  def smart_tool_in_session(cwd):
46
149
  """Claude Code loads MCP servers from ~/.claude.json (user and per project) and the project's .mcp.json."""
47
150
  with open(endpoint_sync.claude_json(), encoding="utf-8") as stream:
@@ -149,7 +252,8 @@ def _edit_review(payload, doc_mode, duplicate_mode):
149
252
 
150
253
  def decide(payload, client, web_route):
151
254
  """Hook output for one PreToolUse payload: edits follow doc_mode (require | remind | off) and duplicate_mode
152
- (warn | off), the other tools follow hook_mode (redirect | advise | off)."""
255
+ (warn | off), the other tools follow hook_mode (redirect | advise | off). A subagent whose tool list has no
256
+ Smart Tool MCP tool is never redirected: it has nothing to switch to."""
153
257
  cfg = config.load_config()
154
258
  if isinstance(payload, dict) and payload.get("tool_name") in EDIT_TOOLS:
155
259
  doc_mode, duplicate_mode = config.doc_mode(cfg), config.duplicate_mode(cfg)
@@ -161,6 +265,9 @@ def decide(payload, client, web_route):
161
265
  return {}
162
266
  output = _route(payload, client, web_route, cfg)
163
267
  reason = (output.get("hookSpecificOutput") or {}).get("permissionDecisionReason")
268
+ if reason and not agent_has_smart_tool(payload.get("agent_type") if payload.get("agent_id") else None,
269
+ payload.get("cwd")):
270
+ return {}
164
271
  return advise(reason) if mode == "advise" and reason else output
165
272
 
166
273
 
@@ -168,21 +275,21 @@ def _route(payload, client, web_route, cfg):
168
275
  tool_name = payload.get("tool_name", "")
169
276
  tool_input = payload.get("tool_input") if isinstance(payload.get("tool_input"), dict) else {}
170
277
  cwd = payload.get("cwd")
171
- router_model = cfg.get("router_model")
172
- if not router_model:
173
- return {}
174
278
  if tool_name in WEB_TOOLS:
279
+ if not cfg.get("router_model"):
280
+ return {}
175
281
  try:
176
282
  present = smart_tool_in_session(cwd)
177
283
  except (OSError, ValueError):
178
284
  present = False
179
285
  if not present:
180
286
  return {}
181
- decision = web_route(tool_name, str(tool_input.get("url") or ""))
182
- return deny(clean_reason(decision.get("reason"))) if decision.get("redirect") else {}
287
+ url = str(tool_input.get("url") or "")
288
+ decision = web_route(tool_name, url)
289
+ return deny(web_message(client, tool_name, url, clean_reason(decision.get("reason")))) if decision.get("redirect") else {}
183
290
  router.CLIENT.set(client)
184
291
  try:
185
- decision, reason = router.decide(tool_name, tool_input, router_model, cwd=cwd)
292
+ decision, reason, target = router.decide(tool_name, tool_input, cwd=cwd)
186
293
  except Exception as exc:
187
294
  try:
188
295
  router.log_unavailable(tool_name, tool_input, exc)
@@ -190,5 +297,5 @@ def _route(payload, client, web_route, cfg):
190
297
  pass
191
298
  return {"systemMessage": f"Smart Tool unavailable, routing skipped: {type(exc).__name__}"}
192
299
  if decision == "redirect":
193
- return deny(f"Use smart_search instead of raw {tool_name}: {clean_reason(reason)}")
300
+ return deny(redirect_message(client, tool_name, target, reason, cwd))
194
301
  return {}
package/index_scope.py CHANGED
@@ -138,6 +138,32 @@ def load_scope(root):
138
138
  return None
139
139
 
140
140
 
141
+ def sibling_scope(root):
142
+ """Scope of the most recently saved other Git view of the same project, or None. A new branch or a worktree starts
143
+ without a scope; asking the scope and profile models again cost ~28 s of a 52 s first search (measured 2026-10-09
144
+ on a branch at the same commit). The caller still checks it with needs_rescan before using it."""
145
+ if not index_views.describe(root).get("git"):
146
+ return None
147
+ own = scope_path(root)
148
+ prefix = project_identity.project_id(root) + ".v-"
149
+ try:
150
+ names = [n for n in os.listdir(SCOPE_DIR) if n.startswith(prefix) and n.endswith(".scope.json")]
151
+ except OSError:
152
+ return None
153
+ paths = sorted((os.path.join(SCOPE_DIR, n) for n in names), key=os.path.getmtime, reverse=True)
154
+ for path in paths:
155
+ if os.path.normcase(path) == os.path.normcase(own):
156
+ continue
157
+ try:
158
+ with open(path, "r", encoding="utf-8") as f:
159
+ data = json.load(f)
160
+ except (OSError, ValueError, UnicodeError):
161
+ continue
162
+ if valid_scope(data):
163
+ return data
164
+ return None
165
+
166
+
141
167
  def save_scope(root, scope):
142
168
  if not valid_scope(scope):
143
169
  raise ValueError("Scope must contain lists of paths in include/exclude.")
package/indexer.py CHANGED
@@ -230,12 +230,28 @@ def migrate_legacy(root):
230
230
  if not old and index_views.describe(root).get('git'):
231
231
  old = next((os.path.join(INDEX_DIR, key + '.sqlite3') for key in
232
232
  [project_identity.project_id(root), *project_identity.legacy_ids(root)]
233
- if os.path.isfile(os.path.join(INDEX_DIR, key + '.sqlite3'))), None)
233
+ if os.path.isfile(os.path.join(INDEX_DIR, key + '.sqlite3'))), None) or \
234
+ _sibling_view_db(root, target)
234
235
  if old:
235
236
  _backup(old, target)
236
237
  return target
237
238
 
238
239
 
240
+ def _sibling_view_db(root, target):
241
+ """Most recently written index of another Git view of the same project, or None. A new branch or worktree starts
242
+ from its copy: the full content check a view change forces then reprocesses only the files that differ, instead
243
+ of chunking every file again (17 s of a 52 s first search on a branch at the same commit, measured 2026-10-09)."""
244
+ prefix = project_identity.project_id(root) + ".v-"
245
+ try:
246
+ names = os.listdir(INDEX_DIR)
247
+ except OSError:
248
+ return None
249
+ candidates = [os.path.join(INDEX_DIR, n) for n in names
250
+ if n.startswith(prefix) and n.endswith(".sqlite3") and ".building-" not in n]
251
+ candidates = [p for p in candidates if os.path.normcase(p) != os.path.normcase(target)]
252
+ return max(candidates, key=os.path.getmtime, default=None)
253
+
254
+
239
255
  def _open_path(path):
240
256
  conn = sqlite3.connect(path, timeout=10)
241
257
  # `chunks.text` é conteúdo de arquivo em texto puro: sem isto, um DELETE só desliga a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@allansantos-dev/smart-tool",
3
- "version": "0.9.5",
3
+ "version": "0.9.7",
4
4
  "description": "Local MCP server that gives coding agents (Claude Code, Codex) cheaper, sharper tools than their built-in search. Windows.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Allan Santos",
@@ -0,0 +1,232 @@
1
+ """Deterministic redirect rule of the PreToolUse hook for native code searches (Grep, Glob, Bash).
2
+
3
+ A content search over a directory of a project (grep -r, rg, git grep, the Grep tool on a folder) or over more than
4
+ LISTED_MAX files goes to smart_search; reading listed files, listings, Glob and commands that change files run. Only
5
+ the part of a Bash command that searches is measured, with its own paths: `cd` is carried, `$VAR` set in the command or
6
+ in the environment, `~` and globs are expanded, and a path still holding an unresolved variable is left alone.
7
+
8
+ Measured on 2026-10-09 against 2,789 real decisions of the LLM router it replaced (session cwd recovered from the
9
+ transcripts): the model redirected 315, this rule 463 (134 shared); in a sample of 40 redirected by the rule alone, 40
10
+ were content searches over a folder or many files; in a sample of 40 redirected by the model alone, ~36 were wrong
11
+ (one file, `node -e` over one file, `git show`, listings, commands changing files). Median 0.2 ms against 1.5 s.
12
+ """
13
+ import glob
14
+ import os
15
+ import re
16
+ import shlex
17
+ import time
18
+
19
+ import index_scope
20
+ import project_store
21
+
22
+ LISTED_MAX = 20
23
+ SCAN_MAX_FILES = 4000
24
+ SCAN_MAX_SECONDS = 0.25
25
+ # Trees the native tools walk into: their presence is part of the cost, they are counted.
26
+ HEAVY_DIRS = frozenset({"node_modules", ".venv", "venv", "vendor", "target", "dist", "build", "out", ".next", ".nuxt",
27
+ ".gradle", "Pods"})
28
+ SEARCHERS = frozenset({"grep", "egrep", "fgrep", "rg", "ripgrep", "ack", "ag", "findstr", "select-string", "sls"})
29
+ ALWAYS_RECURSIVE = frozenset({"rg", "ripgrep", "ack", "ag"})
30
+ LISTERS = frozenset({"find", "fd", "ls", "dir", "tree", "get-childitem", "gci"})
31
+ CD_VERBS = frozenset({"cd", "pushd", "set-location", "sl"})
32
+ PATTERN_FLAGS = frozenset({"-e", "--regexp", "-f", "--file"})
33
+ VALUE_FLAGS = frozenset({"-A", "-B", "-C", "-m", "--max-count", "--include", "--exclude", "--exclude-dir", "-g",
34
+ "--glob", "-t", "--type", "-T", "--type-not", "--max-depth", "-d", "--context",
35
+ "--after-context", "--before-context"})
36
+ RECURSIVE_FLAGS = frozenset({"-r", "-R", "--recursive", "-Recurse", "/s", "/S"})
37
+ _ASSIGN = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)=(.*)$")
38
+ _VARIABLE = re.compile(r"\$\{(\w+)\}|\$(\w+)")
39
+
40
+
41
+ def resolve_path(raw, base):
42
+ """Path normalized for the disk: relative to base, and the MSYS `/c/...` form agents write in a POSIX shell on
43
+ Windows turned into `C:/...`."""
44
+ if not raw:
45
+ return None
46
+ path = raw.strip().replace("\\", "/")
47
+ msys = re.fullmatch(r"/([A-Za-z])(/.*)?", path)
48
+ if msys:
49
+ path = f"{msys.group(1).upper()}:{msys.group(2) or '/'}"
50
+ if not os.path.isabs(path) and base:
51
+ path = os.path.join(base, path)
52
+ return os.path.normpath(path)
53
+
54
+
55
+ def count_files(path):
56
+ """(files, truncated) under a directory, skipping dot-directories, bounded by count and time."""
57
+ files, deadline = 0, time.monotonic() + SCAN_MAX_SECONDS
58
+ for _dirpath, dirnames, filenames in os.walk(path):
59
+ dirnames[:] = [d for d in dirnames if not d.startswith(".") and d != "__pycache__"]
60
+ files += len(filenames)
61
+ if files > SCAN_MAX_FILES or time.monotonic() > deadline:
62
+ return files, True
63
+ return files, False
64
+
65
+
66
+ def _in_project(target, cwd):
67
+ """A registered project holds target, or target is inside the session's directory."""
68
+ key = os.path.normcase(os.path.abspath(target)) + os.sep
69
+ if any(key.startswith(os.path.normcase(os.path.abspath(p["root"])).rstrip(os.sep) + os.sep)
70
+ for p in project_store.all_projects()):
71
+ return True
72
+ return bool(cwd) and index_scope._within_root(cwd, target)
73
+
74
+
75
+ def _has_files(target):
76
+ """Any file under the folder: the size limit measured made no difference (0 files: 463 redirects, 50: 400), and a
77
+ missed redirect costs more context than a needless one."""
78
+ files, truncated = count_files(target)
79
+ return truncated or files > 0
80
+
81
+
82
+ def segments(command):
83
+ """(text, after_single_pipe) of each part of a shell command split on ; && || | and newlines outside quotes."""
84
+ parts, current, quote, i, after_pipe = [], [], None, 0, False
85
+ while i < len(command):
86
+ ch = command[i]
87
+ if quote:
88
+ current.append(ch)
89
+ quote = None if ch == quote else quote
90
+ elif ch in "'\"":
91
+ quote = ch
92
+ current.append(ch)
93
+ elif command.startswith(("&&", "||"), i):
94
+ parts.append(("".join(current), after_pipe))
95
+ current, after_pipe = [], False
96
+ i += 2
97
+ continue
98
+ elif ch in ";\n|":
99
+ parts.append(("".join(current), after_pipe))
100
+ current, after_pipe = [], ch == "|"
101
+ else:
102
+ current.append(ch)
103
+ i += 1
104
+ parts.append(("".join(current), after_pipe))
105
+ return [(text.strip(), pipe) for text, pipe in parts if text.strip()]
106
+
107
+
108
+ def _expand(token, variables):
109
+ def substitute(match):
110
+ name = match.group(1) or match.group(2)
111
+ return variables.get(name, os.environ.get(name, match.group(0)))
112
+ return os.path.expanduser(_VARIABLE.sub(substitute, token))
113
+
114
+
115
+ def _paths(raw, base):
116
+ """Existing paths of a token (globs expanded), or None when it still holds an unresolved variable."""
117
+ if "$" in raw or "`" in raw:
118
+ return None
119
+ path = resolve_path(raw, base)
120
+ if any(ch in raw for ch in "*?["):
121
+ return [os.path.normpath(p) for p in glob.glob(path)]
122
+ return [path] if path and os.path.exists(path) else []
123
+
124
+
125
+ def _search_args(verb, tokens):
126
+ """Path arguments of a grep-like command (the pattern and flag values dropped) and whether it is recursive."""
127
+ recursive = verb in ALWAYS_RECURSIVE or any(
128
+ t in RECURSIVE_FLAGS or (re.fullmatch(r"-[a-zA-Z]+", t) and ("r" in t or "R" in t)) for t in tokens)
129
+ args, skip, pattern_given = [], False, False
130
+ for token in tokens:
131
+ if skip:
132
+ skip = False
133
+ elif token in PATTERN_FLAGS:
134
+ skip, pattern_given = True, True
135
+ elif token in VALUE_FLAGS:
136
+ skip = True
137
+ elif not token.startswith("-") and not (verb == "findstr" and token.startswith("/")):
138
+ args.append(token)
139
+ return (args if pattern_given else args[1:]), recursive
140
+
141
+
142
+ def _bash(command, cwd):
143
+ base, variables, verdict = cwd, {}, ("allow", "No content search over a folder.")
144
+ for text, after_pipe in segments(command):
145
+ try:
146
+ tokens = shlex.split(text, posix=True)
147
+ except ValueError:
148
+ tokens = text.split()
149
+ while tokens and _ASSIGN.match(tokens[0]):
150
+ name, value = _ASSIGN.match(tokens[0]).groups()
151
+ variables[name] = _expand(value, variables)
152
+ tokens = tokens[1:]
153
+ if not tokens:
154
+ continue
155
+ verb = tokens[0].lower()
156
+ if verb in CD_VERBS and len(tokens) > 1:
157
+ base = resolve_path(_expand(tokens[1], variables), base) or base
158
+ continue
159
+ xargs = verb == "xargs" and len(tokens) > 1 and tokens[1].lower() in SEARCHERS
160
+ if after_pipe and not xargs:
161
+ continue
162
+ if xargs:
163
+ if base and os.path.isdir(base) and _in_project(base, cwd) and _has_files(base):
164
+ return "redirect", base, "content search over the files of a listing"
165
+ continue
166
+ if verb == "git":
167
+ rest, repo = tokens[1:], base
168
+ while len(rest) > 1 and rest[0] == "-C":
169
+ repo = resolve_path(_expand(rest[1], variables), repo) or repo
170
+ rest = rest[2:]
171
+ if rest and rest[0] == "grep":
172
+ specs = rest[rest.index("--") + 1:] if "--" in rest else []
173
+ found = [_paths(_expand(raw, variables), repo) for raw in specs]
174
+ only_files = specs and all(f and all(os.path.isfile(p) for p in f) for f in found)
175
+ listed = sum(len(f) for f in found if f) if only_files else 0
176
+ if (not only_files or listed > LISTED_MAX) and repo and os.path.isdir(repo) and \
177
+ _in_project(repo, cwd) and _has_files(repo):
178
+ return "redirect", repo, "git grep over the repository"
179
+ continue
180
+ if verb in LISTERS:
181
+ recursive = verb in ("find", "fd", "tree") or "-recurse" in (t.lower() for t in tokens) or any(
182
+ re.fullmatch(r"-[a-zA-Z]*R[a-zA-Z]*", t) for t in tokens[1:])
183
+ first = next((t for t in tokens[1:] if not t.startswith("-")), ".")
184
+ for target in (_paths(_expand(first, variables), base) or []) if recursive else []:
185
+ if os.path.isdir(target) and _in_project(target, cwd) and count_files(target)[1]:
186
+ return "redirect", target, "recursive listing of a very large tree"
187
+ continue
188
+ if verb not in SEARCHERS:
189
+ continue
190
+ args, recursive = _search_args(verb, tokens[1:])
191
+ if not args:
192
+ if recursive and base and os.path.isdir(base) and _in_project(base, cwd) and _has_files(base):
193
+ return "redirect", base, "recursive content search of the working folder"
194
+ continue
195
+ listed = 0
196
+ for raw in args:
197
+ found = _paths(_expand(raw, variables), base)
198
+ if found is None:
199
+ verdict = ("allow", "The searched path uses a variable the hook cannot resolve.")
200
+ continue
201
+ for target in found:
202
+ if os.path.isdir(target):
203
+ if recursive and _in_project(target, cwd) and _has_files(target):
204
+ return "redirect", target, "recursive content search over a folder"
205
+ else:
206
+ listed += 1
207
+ if listed > LISTED_MAX:
208
+ return "redirect", base, f"content search over {listed} files"
209
+ if listed:
210
+ verdict = ("allow", "Search inside listed files.")
211
+ return verdict[0], None, verdict[1]
212
+
213
+
214
+ def decide(tool_name, tool_input, cwd):
215
+ """(decision, target, reason) for a Grep, Glob or Bash call: decision is redirect or allow, target the folder that
216
+ would be searched (None when allowed)."""
217
+ if tool_name == "Glob":
218
+ return "allow", None, "Glob lists file names; smart_search does not replace a listing."
219
+ if tool_name == "Grep":
220
+ target = resolve_path(tool_input.get("path") or cwd, cwd)
221
+ if not target or not os.path.exists(target):
222
+ return "allow", None, "The path does not exist: the native tool's error is the useful answer."
223
+ if os.path.isfile(target):
224
+ return "allow", None, "Search inside one file."
225
+ if not _in_project(target, cwd):
226
+ return "allow", None, "The folder is outside every project: no index covers it."
227
+ if not _has_files(target):
228
+ return "allow", None, "The folder has no files."
229
+ return "redirect", target, "content search over a folder"
230
+ if tool_name == "Bash":
231
+ return _bash(tool_input.get("command") or "", cwd)
232
+ return "allow", None, "Tool not redirected."