@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/CHANGELOG.md +67 -0
- package/README.md +10 -3
- package/block_patterns.py +266 -0
- package/code_impact.py +28 -1
- package/config.py +22 -0
- package/duplicates.py +23 -7
- package/hook_decision.py +115 -8
- package/index_scope.py +26 -0
- package/indexer.py +17 -1
- package/package.json +1 -1
- package/redirect_rule.py +232 -0
- package/router.py +48 -349
- package/setup_ui.py +84 -3
- package/smart_tool_daemon.py +36 -4
- package/tray.py +51 -8
- package/update_check.py +36 -2
- package/version.py +1 -1
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
|
-
|
|
182
|
-
|
|
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,
|
|
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(
|
|
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.
|
|
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",
|
package/redirect_rule.py
ADDED
|
@@ -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."
|