@allansantos-dev/smart-tool 0.9.10 → 0.9.12

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 CHANGED
@@ -1,5 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.12 - beta
4
+
5
+ - Security: `web_fetch` rendering with Moli (0.9.9 to 0.9.11) followed redirects and page requests to private
6
+ addresses, so a public page could redirect it to `127.0.0.1` or the local network and the agent received what was
7
+ there. Moli now runs with `--block-private-networks`, the same rule as the HTTP reader and the Chromium fallback.
8
+ - Security, browser control: pages the agent opens are untrusted input. Playwright MCP is registered with
9
+ `--no-webmcp` (tools a page registers are never offered to the agent); every request of its browser carries
10
+ `X-Smart-Tool-Agent-Browser`, and the daemon refuses any request with it, so a page cannot steer the agent into the
11
+ setup page and its token, redirects included; `browser_run_code_unsafe` (arbitrary JavaScript in the server
12
+ process, outside any client sandbox) is denied in the client (`permissions.deny` in Claude Code, `disabled_tools` in
13
+ Codex), and the server is not registered when that fails. Existing registrations are refreshed on update.
14
+ - Security hardening: block pattern proposals with lookarounds or backreferences, or that take over 5 s on a long
15
+ command, are discarded; an accepted pattern applies only to calls whose reach the rule cannot measure (a single-file
16
+ search stays allowed, as documented) and the block message no longer repeats the model's text; client CLIs that are
17
+ `.cmd` shims never receive arguments cmd.exe would interpret; the Moli archive extraction refuses drive-relative and
18
+ alternate-stream names.
19
+ - `web_fetch` reads PDFs (pypdf, page by page, up to 300 pages, in a subprocess stopped at the request's deadline; a
20
+ PDF with only an owner password opens, one that needs a password to open is refused with that reason). The hook sends the native WebFetch here, and a PDF
21
+ was a dead end: 9 times in 10 days of sessions.
22
+ - Agents are told to use `smart_search` first to explore code, not only after a block: in 10 days, 89 searches were
23
+ started by the agent against about 5,600 folder searches with the built-in tools. The server instructions and the
24
+ `smart_search` description now say when to use it, as `web_search`/`web_fetch` already did.
25
+
26
+ ## 0.9.11 - beta
27
+
28
+ - Documentation only, no change to the program. The README opens with "How it works": what each tool does behind the
29
+ scenes, in plain words, for someone who has never used it (the index and the hybrid search, the web search sources,
30
+ how `web_fetch` reads a page, the hook, what stays on the machine). Its status line said 0.9.0; a test now fails
31
+ when the README status or the top of this changelog differs from `version.py`, as one already did for the product
32
+ page. The product page credits Moli as the page renderer (Crawl4AI is its fallback), lists Moli among the browsers
33
+ the installer downloads, links to "How it works" and has a favicon.
34
+
3
35
  ## 0.9.10 - beta
4
36
 
5
37
  - Browser control for agents, on by default: registering smart-tool in Claude Code or Codex (and every update, for
package/NOTICE CHANGED
@@ -9,6 +9,7 @@ sources into the installation folder or the user's profile; each keeps its own l
9
9
  Python packages (installation venv, from PyPI)
10
10
  - ddgs (MIT)
11
11
  - trafilatura (Apache-2.0)
12
+ - pypdf (BSD-3-Clause): text of PDFs read by web_fetch
12
13
  - pystray (LGPL-3.0; used unmodified as a library)
13
14
  - Pillow (MIT-CMU)
14
15
  - PyYAML (MIT)
package/README.md CHANGED
@@ -15,7 +15,51 @@ registered as the `playwright` server where this machine lets a browser be drive
15
15
  An optional hook routes the agent's native Grep/Glob/Read/Bash/WebSearch/WebFetch calls to these tools when they
16
16
  are cheaper, or just tells the agent what Smart Tool would do.
17
17
 
18
- **Status:** 0.9.0 beta. Windows only.
18
+ **Status:** 0.9.12 beta. Windows only. What changed in each version: [CHANGELOG.md](CHANGELOG.md).
19
+
20
+ ## How it works
21
+
22
+ A coding agent normally finds code by running `grep` over the whole project and reads the web by downloading whole
23
+ pages. Both fill its context with text it does not need, and every token of it is paid for. Smart Tool is a small
24
+ program that runs on your machine and offers the agent better tools for the same jobs, through
25
+ [MCP](https://modelcontextprotocol.io), the standard way agents call outside tools.
26
+
27
+ ```
28
+ Claude Code / Codex ──MCP──▶ Smart Tool daemon (127.0.0.1, one per machine)
29
+ ├─ smart_search ──▶ local index of your project (SQLite)
30
+ ├─ web_search ───▶ several search engines at once, shared cache
31
+ ├─ web_fetch ────▶ the page, read by a small model
32
+ └─ project_manage
33
+ your model gateway (OpenAI, OpenRouter, Ollama...) ◀── embeddings and small chat calls
34
+ ```
35
+
36
+ **Searching code.** When a project is registered, a small model looks at its folder structure once and decides what
37
+ is worth indexing (source, tests, docs; not build output or dependencies). Each file is cut into chunks of up to 200
38
+ lines, and each chunk gets an embedding, a list of numbers that captures its meaning. A search runs two ways at
39
+ once, by meaning (embeddings) and by exact words (SQLite full-text search), merges the two lists and, if you set a
40
+ rerank model, reorders the best candidates. The agent gets back a few chunks split into code, tests and docs, with
41
+ the functions most likely to matter, instead of every line that contains a word. The index follows the project:
42
+ only files whose content changed are processed again, and each Git branch keeps its own view.
43
+
44
+ **Searching the web.** `web_search` asks several free search sources in parallel (DuckDuckGo, Wikipedia, Yahoo,
45
+ Exa, Google and others), in the language of the question and in English, and merges what comes back. Search APIs with a
46
+ free quota (Tavily, Firecrawl) and a real browser are tried only when those results are not enough. Answers are cached and shared by every agent
47
+ session on the machine, so the same question asked twice costs nothing the second time.
48
+
49
+ **Reading a page.** `web_fetch` takes a URL and a question. It downloads the page over plain HTTP; when the page only
50
+ shows its content with JavaScript, it opens it in a headless browser ([Moli](https://github.com/lexmount/moli)
51
+ first, Chromium as the fallback), which never reaches this machine or your local network. PDFs are read as text, page
52
+ by page. A small model then reads the page and returns only the answer, with the source.
53
+ The agent receives a paragraph instead of the whole page.
54
+
55
+ **Steering the agent (optional hook).** Agents keep reaching for their built-in tools out of habit. The hook sees
56
+ each call before it runs: a content search over a project folder is stopped and the agent is told the exact Smart
57
+ Tool call to make instead; reading a known file, or anything else, runs as usual. The same hook can remind the agent
58
+ to document functions it edits and warn when it writes a function that already exists in the project.
59
+
60
+ **What stays local.** The daemon, the indexes, the caches and the logs live on your machine. Text leaves it only to
61
+ reach the model gateway you chose (for embeddings and the small model calls) and the web search sources. With a local
62
+ gateway such as Ollama, nothing about your code leaves the machine.
19
63
 
20
64
  ## Requirements
21
65
 
@@ -62,7 +106,11 @@ Open the setup screen from the tray icon ("Open settings") or at `http://127.0.0
62
106
  `~/.smart-tool/data/playwright-mcp`. Before registering, Smart Tool starts that server and opens a blank page as an
63
107
  agent would; where that fails (a corporate policy that forbids remote debugging, a missing browser) nothing is
64
108
  registered, agents keep reading pages through `web_fetch`, and the setup screen shows the reason with a retry
65
- button. A `playwright` server you registered yourself is never touched. Remove it with
109
+ button. Pages the agent opens are treated as untrusted: tools a page registers (WebMCP) are never exposed, the
110
+ browser cannot open Smart Tool's own pages (every request it makes carries a header the daemon refuses), and
111
+ `browser_run_code_unsafe`, which runs arbitrary JavaScript in the server process, is denied in the client
112
+ (`permissions.deny` in `~/.claude/settings.json`, `disabled_tools` in `~/.codex/config.toml`); if it cannot be
113
+ denied, the server is not registered. A `playwright` server you registered yourself is never touched. Remove it with
66
114
  `claude mcp remove playwright -s user` / `codex mcp remove playwright`.
67
115
 
68
116
  The hook behaviors:
package/block_patterns.py CHANGED
@@ -9,6 +9,8 @@ import hashlib
9
9
  import json
10
10
  import os
11
11
  import re
12
+ import subprocess
13
+ import sys
12
14
  import threading
13
15
  import time
14
16
 
@@ -29,6 +31,40 @@ CALL_MAX_CHARS = 1500
29
31
  REVIEWED_REASONS = ("The searched path uses a variable the hook cannot resolve.", "No content search over a folder.")
30
32
  _BULK_READ = re.compile(r"\*|\bxargs\b|-exec\b|\bfor\s+\w+\s+in\b|os\.walk|rglob|glob\(|readdirSync|readdir\(|"
31
33
  r"walkSync|-Recurse\b|Get-ChildItem", re.IGNORECASE)
34
+ # A lookaround, backreference or conditional can match by exclusion ("anything but these files").
35
+ _OPEN_ENDED = re.compile(r"\(\?<?[=!]|\(\?P=|\(\?\(|\\[1-9]")
36
+ # Every hook call runs the accepted patterns, so one that backtracks exponentially would hang the daemon (re holds
37
+ # the GIL and cannot be interrupted). Every regex of a proposal runs in a subprocess: first a timing over each prefix
38
+ # of the call followed by a run of characters commands are made of, and over each separator of the call itself (1 to
39
+ # 3 characters with punctuation, such as "; " or "\\") repeated where it first appears; over SLOW_REGEX_S of regex
40
+ # time, or no answer in SLOW_REGEX_S + START_SLACK_S, discards it. Then the checks against the call, the samples and
41
+ # the logged history (no answer in HISTORY_TIMEOUT_S discards it too).
42
+ SLOW_REGEX_S = 5
43
+ START_SLACK_S = 10
44
+ HISTORY_TIMEOUT_S = 60
45
+ _RUNS = tuple(unit * (200 // len(unit)) for unit in ("a", " ", "/", "_", "1", "a ", "a/", "a.", "$a/", "-a "))
46
+ _PREFIXES = 150
47
+ _MEASURE_SCRIPT = """
48
+ import json, re, sys, time
49
+ sys.path.insert(0, sys.argv[1])
50
+ import block_patterns as b
51
+ tool, pattern, text, metrics = json.loads(sys.stdin.read())
52
+ regex = re.compile(pattern)
53
+ if sys.argv[2] == "timing":
54
+ start = time.perf_counter()
55
+ step = max(1, len(text) // b._PREFIXES)
56
+ for end in range(0, len(text) + 1, step):
57
+ for run in b._RUNS:
58
+ regex.search(text[:end] + run + "\\x00")
59
+ for unit in b._units(text):
60
+ regex.search(text[:max(0, text.find(unit))] + unit * (200 // len(unit)) + "\\x00")
61
+ print(json.dumps({"seconds": time.perf_counter() - start}))
62
+ else:
63
+ allowed = next((x for x in b.ALLOWED_ON_PURPOSE if regex.search(x)), None)
64
+ matches, total = b._history(tool, regex, metrics)
65
+ print(json.dumps({"matches_call": bool(regex.search(text)), "allowed": allowed, "matches": matches,
66
+ "total": total}))
67
+ """
32
68
  # Calls the rule lets run on purpose: a pattern matching any of them would block what must run.
33
69
  ALLOWED_ON_PURPOSE = ("grep -n handler src/app.py", "grep -n \"def load\" -A20 src/app.py | head -40",
34
70
  "sed -n 1,80p src/app.py", "cat README.md", "head -50 src/app.py", "tail -20 logs/app.log",
@@ -120,6 +156,33 @@ def matching(tool_name, tool_input):
120
156
  return next((entry for entry, regex in compiled if entry["tool"] == tool_name and regex.search(text)), None)
121
157
 
122
158
 
159
+ def _units(text, limit=400):
160
+ """Distinct 1-3 character pieces of text holding a non-alphanumeric character, plus each one after a letter: the
161
+ separators a pattern's repetition may hinge on."""
162
+ seen = {}
163
+ for size in (1, 2, 3):
164
+ for start in range(len(text) - size + 1):
165
+ piece = text[start:start + size]
166
+ if not piece.isalnum():
167
+ seen.setdefault(piece, None)
168
+ seen.setdefault("a" + piece, None)
169
+ return list(seen)[:limit]
170
+
171
+
172
+ def _measure(stage, timeout, tool_name, pattern, text, metrics_path):
173
+ """One stage of _MEASURE_SCRIPT ("timing" or "checks") in a subprocess; None when it did not answer in time."""
174
+ try:
175
+ done = subprocess.run([sys.executable, "-I", "-c", _MEASURE_SCRIPT, os.path.dirname(os.path.abspath(__file__)), stage],
176
+ input=json.dumps([tool_name, pattern, text, metrics_path]), capture_output=True,
177
+ text=True, encoding="utf-8", timeout=timeout,
178
+ creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
179
+ except subprocess.TimeoutExpired:
180
+ return None
181
+ if done.returncode:
182
+ raise RuntimeError(f"pattern check failed: {done.stderr.strip()[-300:]}")
183
+ return json.loads(done.stdout)
184
+
185
+
123
186
  def _history(tool_name, regex, metrics_path):
124
187
  """(matches, total) of the logged calls of this tool the pattern would have blocked."""
125
188
  matches = total = 0
@@ -177,15 +240,23 @@ def propose(tool_name, tool_input, rule_reason, model, metrics_path, redact):
177
240
  problem, regex = f"invalid regex: {exc}", None
178
241
  if regex and len(pattern) > PATTERN_MAX_CHARS:
179
242
  problem = f"longer than {PATTERN_MAX_CHARS} characters"
180
- elif regex and not regex.search(text):
181
- problem = "does not match the call it came from"
182
- elif regex and any(regex.search(sample) for sample in ALLOWED_ON_PURPOSE):
183
- problem = "matches calls the rule lets run on purpose: " + next(x for x in ALLOWED_ON_PURPOSE if regex.search(x))
243
+ elif regex and _OPEN_ENDED.search(pattern):
244
+ problem = "lookaround, backreference or conditional (matches by exclusion)"
184
245
  if regex and not problem:
185
- matches, total = _history(tool_name, regex, metrics_path)
186
- entry.update(history_matches=matches, history_total=total)
187
- if total and matches / total > BROAD_SHARE:
188
- problem = f"too broad: would block {matches} of {total} logged {tool_name} calls"
246
+ timing = _measure("timing", SLOW_REGEX_S + START_SLACK_S, tool_name, pattern, text, metrics_path)
247
+ checks = (_measure("checks", HISTORY_TIMEOUT_S, tool_name, pattern, text, metrics_path)
248
+ if timing and timing["seconds"] <= SLOW_REGEX_S else None)
249
+ if checks is None:
250
+ problem = f"catastrophic backtracking: over {SLOW_REGEX_S}s on a long command or the logged calls"
251
+ elif not checks["matches_call"]:
252
+ problem = "does not match the call it came from"
253
+ elif checks["allowed"]:
254
+ problem = "matches calls the rule lets run on purpose: " + checks["allowed"]
255
+ else:
256
+ matches, total = checks["matches"], checks["total"]
257
+ entry.update(history_matches=matches, history_total=total)
258
+ if total and matches / total > BROAD_SHARE:
259
+ problem = f"too broad: would block {matches} of {total} logged {tool_name} calls"
189
260
  with _LOCK:
190
261
  data = load()
191
262
  if any(e["id"] == entry["id"] for group in data.values() for e in group):
@@ -6,14 +6,22 @@ isolated in-memory profile, headless. Before registering, a probe launches that
6
6
  as the agent would: machines whose policy forbids it (corporate IT blocking remote debugging, a missing browser)
7
7
  keep today's read-only behavior, and the reason is shown in the setup screen instead of an error. A server named
8
8
  `playwright` the user already registered is left untouched.
9
+
10
+ Pages the agent opens are untrusted input. The server never exposes tools a page registers (WebMCP); every request
11
+ the browser makes carries AGENT_HEADER, which the daemon refuses, so a page cannot steer the agent into Smart Tool's
12
+ own setup page and its token (redirects included); `browser_run_code_unsafe` (arbitrary JavaScript in the server
13
+ process, outside any client sandbox) is denied in the client: Playwright MCP itself is not a security boundary and
14
+ its documentation points to client-level permissions.
9
15
  """
10
16
  import json
11
17
  import os
12
18
  import queue
19
+ import re
13
20
  import subprocess
14
21
  import tempfile
15
22
  import threading
16
23
  import time
24
+ import tomllib
17
25
 
18
26
  import atomic_io
19
27
  import endpoint_sync
@@ -24,8 +32,12 @@ SERVER = "playwright"
24
32
  CLI = install_runtime.PLAYWRIGHT_MCP_CLI
25
33
  STATUS_PATH = os.path.join(paths.DATA_DIR, "browser-control.json")
26
34
  OUTPUT_DIR = os.path.join(paths.DATA_DIR, "playwright-mcp")
35
+ CONFIG_PATH = os.path.join(paths.DATA_DIR, "playwright-mcp.json")
36
+ AGENT_HEADER = "X-Smart-Tool-Agent-Browser"
27
37
  OUTPUT_MAX_BYTES = 100 * 1024 * 1024
28
38
  PROBE_TIMEOUT_S = 60
39
+ DENIED_TOOLS = ("browser_run_code_unsafe",)
40
+ _CODEX_TABLE_RE = re.compile(rf"^\[mcp_servers\.{SERVER}\][ \t]*\r?\n", re.M)
29
41
  _CHROMIUM_PROBE = ("from playwright.sync_api import sync_playwright\n"
30
42
  "with sync_playwright() as p: print(p.chromium.executable_path)")
31
43
 
@@ -50,8 +62,18 @@ def server_command():
50
62
  raise RuntimeError("Supported Node.js not found for Playwright MCP.")
51
63
  if not CLI.is_file():
52
64
  raise RuntimeError(f"Playwright MCP is not installed ({CLI}); reinstall Smart Tool.")
53
- return [node, str(CLI), "--isolated", "--headless", "--executable-path", chromium_executable(),
54
- "--output-dir", OUTPUT_DIR, "--output-max-size", str(OUTPUT_MAX_BYTES)]
65
+ return [node, str(CLI), "--isolated", "--headless", "--no-webmcp", "--config", write_config(),
66
+ "--executable-path", chromium_executable(), "--output-dir", OUTPUT_DIR,
67
+ "--output-max-size", str(OUTPUT_MAX_BYTES)]
68
+
69
+
70
+ def write_config():
71
+ """Writes the Playwright MCP config file that adds AGENT_HEADER to every request of the browser (navigations,
72
+ redirects, subresources; measured: no CORS preflight is added) and returns its path."""
73
+ os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True)
74
+ config = {"browser": {"contextOptions": {"extraHTTPHeaders": {AGENT_HEADER: "1"}}}}
75
+ atomic_io.write_secret_text(CONFIG_PATH, json.dumps(config, indent=1))
76
+ return CONFIG_PATH
55
77
 
56
78
 
57
79
  def probe(command, timeout=PROBE_TIMEOUT_S):
@@ -128,13 +150,89 @@ def registered():
128
150
  except (OSError, ValueError, AttributeError):
129
151
  pass
130
152
  try:
131
- with open(os.path.join(os.path.expanduser("~"), ".codex", "config.toml"), encoding="utf-8") as stream:
153
+ with open(_codex_config(), encoding="utf-8") as stream:
132
154
  found["codex"] = f"[mcp_servers.{SERVER}]" in stream.read()
133
155
  except OSError:
134
156
  pass
135
157
  return found
136
158
 
137
159
 
160
+ def _codex_config():
161
+ """Codex keeps config.toml in CODEX_HOME when set, else ~/.codex."""
162
+ return os.path.join(os.environ.get("CODEX_HOME") or os.path.join(os.path.expanduser("~"), ".codex"), "config.toml")
163
+
164
+
165
+ def _replace_text(path, text):
166
+ temporary = f"{path}.smart-tool-tmp"
167
+ with open(temporary, "w", encoding="utf-8", newline="") as stream:
168
+ stream.write(text)
169
+ os.replace(temporary, path)
170
+
171
+
172
+ def set_claude_deny(present):
173
+ """Adds (present=True) or removes the `permissions.deny` rules for DENIED_TOOLS in Claude Code's user settings,
174
+ keeping every other rule. Raises ValueError when settings.json does not have the documented layout."""
175
+ path = os.path.join(endpoint_sync.claude_dir(), "settings.json")
176
+ try:
177
+ with open(path, encoding="utf-8-sig") as stream:
178
+ data = json.load(stream)
179
+ except FileNotFoundError:
180
+ data = {}
181
+ permissions = data.get("permissions", {}) if isinstance(data, dict) else None
182
+ deny = permissions.get("deny", []) if isinstance(permissions, dict) else None
183
+ if not isinstance(deny, list):
184
+ raise ValueError(f"{path} has no permissions.deny list Claude Code can read; fix it to deny "
185
+ f"{', '.join(DENIED_TOOLS)}.")
186
+ rules = [f"mcp__{SERVER}__{tool}" for tool in DENIED_TOOLS]
187
+ kept = [rule for rule in deny if rule not in rules]
188
+ new = kept + rules if present else kept
189
+ if new == deny:
190
+ return
191
+ permissions["deny"] = new
192
+ if not new:
193
+ del permissions["deny"]
194
+ data["permissions"] = permissions
195
+ if not permissions:
196
+ del data["permissions"]
197
+ os.makedirs(os.path.dirname(path), exist_ok=True)
198
+ _replace_text(path, json.dumps(data, indent=2, ensure_ascii=False) + "\n")
199
+
200
+
201
+ def set_codex_disabled():
202
+ """Adds `disabled_tools` = DENIED_TOOLS to the [mcp_servers.playwright] table `codex mcp add` wrote (the CLI has
203
+ no flag for it) and checks the file still parses with that value. Raises ValueError otherwise."""
204
+ path = _codex_config()
205
+ with open(path, encoding="utf-8", newline="") as stream:
206
+ text = stream.read()
207
+
208
+ def disabled(source):
209
+ return ((tomllib.loads(source).get("mcp_servers") or {}).get(SERVER) or {}).get("disabled_tools")
210
+ current = disabled(text)
211
+ if current is not None:
212
+ if set(DENIED_TOOLS) <= set(current):
213
+ return
214
+ raise ValueError(f"{path} already sets other disabled_tools for {SERVER}; add {', '.join(DENIED_TOOLS)}.")
215
+ match = _CODEX_TABLE_RE.search(text)
216
+ if not match:
217
+ raise ValueError(f"[mcp_servers.{SERVER}] not found in {path}.")
218
+ newline = "\r\n" if match.group(0).endswith("\r\n") else "\n"
219
+ new = (text[:match.end()] + "disabled_tools = [" + ", ".join(json.dumps(t) for t in DENIED_TOOLS) + "]"
220
+ + newline + text[match.end():])
221
+ if not set(DENIED_TOOLS) <= set(disabled(new) or []):
222
+ raise ValueError(f"disabled_tools did not take effect in {path}.")
223
+ _replace_text(path, new)
224
+
225
+
226
+ def deny_unsafe_tools(client):
227
+ """Denies DENIED_TOOLS in the client where Smart Tool registered the server."""
228
+ if client == "claude":
229
+ set_claude_deny(True)
230
+ elif client == "codex":
231
+ set_codex_disabled()
232
+ else:
233
+ raise ValueError(f"Client without MCP registration: {client!r}")
234
+
235
+
138
236
  def register_command(client, command):
139
237
  """Official CLI command that registers a stdio server (Claude Code: user scope, every project)."""
140
238
  if client == "claude":
@@ -154,7 +252,7 @@ def remove_command(client):
154
252
 
155
253
 
156
254
  def status():
157
- """Last outcome per client: {"claude": {"state": "registered"|"unavailable"|"kept", "reason", "at"}, ...}."""
255
+ """Last outcome per client: {"claude": {"state": "registered"|"unavailable"|"kept"|"undenied", "reason", "at"}}."""
158
256
  try:
159
257
  with open(STATUS_PATH, encoding="utf-8") as stream:
160
258
  data = json.load(stream)
@@ -180,8 +278,10 @@ def ensure(client, log=None):
180
278
  and install paths change between versions). A `playwright` server the user registered is kept. When the browser
181
279
  cannot be driven on this machine (probe failed, CLI refused) records `unavailable` with the reason, removes a
182
280
  registration of ours that would no longer work and returns: agents keep the read-only browsers of web_fetch and
183
- web_search. Only an unreadable status file raises (ValueError). log (optional) also gets the reason."""
184
- ours = status().get(client, {}).get("state") == "registered"
281
+ web_search. A registration whose DENIED_TOOLS cannot be denied in the client is removed too; when even that fails
282
+ the state is `undenied` (the next call retries it as ours). Only an unreadable status file raises (ValueError).
283
+ log (optional) also gets the reason."""
284
+ ours = status().get(client, {}).get("state") in ("registered", "undenied")
185
285
  if registered().get(client) and not ours:
186
286
  return _record(client, "kept", "a playwright MCP server you registered is kept")
187
287
  try:
@@ -193,10 +293,25 @@ def ensure(client, log=None):
193
293
  if ours and registered().get(client):
194
294
  endpoint_sync.run_cli(remove_command(client))
195
295
  if problem:
296
+ if ours and client == "claude":
297
+ set_claude_deny(False)
196
298
  return _record(client, "unavailable", problem[:300], log)
197
299
  endpoint_sync.run_cli(register_command(client, command))
198
- except RuntimeError as exc:
199
- return _record(client, "unavailable", str(exc)[:300], log)
200
- if not registered().get(client):
201
- return _record(client, "unavailable", "the client CLI finished, but the server did not appear in its configuration", log)
300
+ if not registered().get(client):
301
+ return _record(client, "unavailable", "the client CLI finished, but the server did not appear in its "
302
+ "configuration", log)
303
+ try:
304
+ deny_unsafe_tools(client)
305
+ except (OSError, ValueError) as exc:
306
+ problem = f"{', '.join(DENIED_TOOLS)} could not be denied ({exc})"
307
+ try:
308
+ endpoint_sync.run_cli(remove_command(client))
309
+ except RuntimeError as removal:
310
+ return _record(client, "undenied", f"remove it with `{' '.join(remove_command(client))}`: {problem} "
311
+ f"and removing it failed ({removal})"[:300], log)
312
+ return _record(client, "unavailable", f"{problem}, so the server was removed"[:300], log)
313
+ except (OSError, RuntimeError, ValueError) as exc:
314
+ # Our registration still in place after a failure (its removal failed) stays ours, flagged, never "kept".
315
+ state = "undenied" if ours and registered().get(client) else "unavailable"
316
+ return _record(client, state, str(exc)[:300], log)
202
317
  return _record(client, "registered")
package/endpoint_sync.py CHANGED
@@ -102,6 +102,7 @@ def registrations():
102
102
 
103
103
 
104
104
  REGISTER_TIMEOUT_S = 60
105
+ _CMD_METACHARS = re.compile(r'[&|<>^%!"\r\n]')
105
106
 
106
107
 
107
108
  def register_command(client, base):
@@ -129,6 +130,11 @@ def run_cli(command):
129
130
  executable = shutil.which(command[0])
130
131
  if not executable:
131
132
  raise RuntimeError(f"CLI '{command[0]}' not found in Smart Tool's PATH. Register it manually: {' '.join(command)}")
133
+ unsafe = [arg for arg in command[1:] if _CMD_METACHARS.search(arg)]
134
+ if executable.lower().endswith((".cmd", ".bat")) and unsafe:
135
+ # cmd.exe parses a .cmd shim's arguments again: & | < > ^ % ! would run as commands or expand as variables.
136
+ raise RuntimeError(f"'{command[0]}' is a cmd.exe script and this argument has characters cmd.exe would "
137
+ f"interpret: {unsafe[0]}. Register it manually: {' '.join(command)}")
132
138
  flags = subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0
133
139
  try:
134
140
  proc = subprocess.Popen([executable, *command[1:]], stdout=subprocess.PIPE, stderr=subprocess.PIPE,
@@ -87,9 +87,11 @@ def _extract_folder(zip_path, top, destination):
87
87
  parts = Path(member.filename.replace("\\", "/")).parts
88
88
  if parts == (top,) and member.is_dir():
89
89
  continue
90
- if len(parts) < 2 or parts[0] != top or ".." in parts:
91
- raise RuntimeError(f"{zip_path.name} contains an unexpected path: {member.filename}")
92
90
  target = destination.joinpath(*parts[1:])
91
+ # "C:x" (drive-relative) and "x:stream" (alternate data stream) components escape joinpath's confinement.
92
+ if (len(parts) < 2 or parts[0] != top or ".." in parts or any(":" in part for part in parts)
93
+ or not target.resolve().is_relative_to(destination.resolve())):
94
+ raise RuntimeError(f"{zip_path.name} contains an unexpected path: {member.filename}")
93
95
  if member.is_dir():
94
96
  target.mkdir(parents=True, exist_ok=True)
95
97
  else:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@allansantos-dev/smart-tool",
3
- "version": "0.9.10",
3
+ "version": "0.9.12",
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/requirements.txt CHANGED
@@ -3,6 +3,7 @@ Pillow
3
3
  PyYAML
4
4
  ddgs==9.16.0
5
5
  trafilatura==2.3.0
6
+ pypdf==6.20.0
6
7
  # Mapas JS/TS: TypeScript 5.9.3 no runtime Node (web_adapters/node/package-lock.json).
7
8
  # Mapas Java/Angular: java-parser 3.0.1 e @angular/compiler 20.3.0 no mesmo runtime Node.
8
9
  # DOCX: extração com zipfile + ElementTree (stdlib; sem Office/macros/rede).
package/router.py CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env python3
2
2
  """Routing of the native tool calls the PreToolUse hook matches (Grep, Glob, Bash, Read). Deterministic: calls that
3
- neither search nor read code, or that change files, run; accepted block patterns redirect; the rest follows
4
- redirect_rule. Searches the rule lets run are reviewed in the background by the router model, which may propose new
3
+ neither search nor read code, or that change files, run; the rest follows redirect_rule, and accepted block patterns
4
+ redirect only allowed calls whose reach the rule cannot measure (block_patterns.reviewable). Searches the rule lets run are reviewed in the background by the router model, which may propose new
5
5
  block patterns (block_patterns). Every decision is logged in router-metrics.jsonl. Also holds the router-model
6
6
  helpers of the web search (tier order and result assessment)."""
7
7
  import contextvars
@@ -157,12 +157,13 @@ def decide(tool_name, tool_input, cwd=None):
157
157
  decision, reason = mechanical
158
158
  _log_decision(tool_name, tool_input, decision, reason, extra={"mechanical": True})
159
159
  return decision, reason, None
160
- learned = block_patterns.matching(tool_name, safe_input)
160
+ decision, target, reason = redirect_rule.decide(tool_name, safe_input, cwd)
161
+ learned = (block_patterns.matching(tool_name, safe_input)
162
+ if decision == "allow" and block_patterns.reviewable(tool_name, safe_input, reason) else None)
161
163
  if learned:
162
- reason = f"accepted block pattern: {learned['reason']}"
164
+ reason = f"accepted block pattern {learned['id']} (approved by the user in Smart Tool's setup screen)"
163
165
  _log_decision(tool_name, tool_input, "redirect", reason, extra={"pattern": learned["id"]})
164
166
  return "redirect", reason, cwd
165
- decision, target, reason = redirect_rule.decide(tool_name, safe_input, cwd)
166
167
  _log_decision(tool_name, tool_input, decision, reason, extra={"rule": True})
167
168
  if decision == "allow":
168
169
  try:
package/setup_ui.py CHANGED
@@ -938,6 +938,7 @@ function browserText(item) {
938
938
  const state = item.browser_control || {};
939
939
  if (state.state === "registered") return "Browser control on: Playwright MCP drives an isolated, headless Chromium";
940
940
  if (state.state === "kept") return "Browser control: your own playwright MCP server is kept";
941
+ if (state.state === "undenied") return "Browser control is registered without its safety rule: " + state.reason;
941
942
  if (state.state === "unavailable") return "Browser control unavailable on this machine (" + state.reason + "); pages are still read by web_fetch";
942
943
  return "Browser control is set up when the MCP is registered";
943
944
  }
@@ -35,6 +35,7 @@ import uuid
35
35
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
36
36
 
37
37
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
38
+ import browser_control
38
39
  import capabilities
39
40
  import client_hooks
40
41
  import install_runtime
@@ -309,7 +310,10 @@ def _smart_search_job_payload(job_id):
309
310
  # upfront, so a blocked agent already has the replacement in its tool list.
310
311
  ALWAYS_LOAD = {"anthropic/alwaysLoad": True}
311
312
  SERVER_INSTRUCTIONS = (
312
- "Smart Tool serves an indexed semantic search over the user's projects and a cheaper web reader. The user installed "
313
+ "Smart Tool serves an indexed semantic search over the user's projects and a cheaper web reader. Use smart_search "
314
+ "FIRST to explore code in the user's projects (where something is implemented, how a flow works, what already "
315
+ "exists to reuse) before reading files one by one; Grep/Read are for an exact string or a file you already know. "
316
+ "Use web_search/web_fetch for any web research. The user also installed "
313
317
  "a PreToolUse hook that blocks content searches over a project folder (grep -r, rg, git grep, Grep on a folder) "
314
318
  "and native WebFetch/WebSearch. A call blocked by Smart Tool is a routing rule, not a failure: make the call the block "
315
319
  "message names (smart_search with project_root and query_identifiers, web_fetch with url and prompt, web_search "
@@ -360,8 +364,9 @@ TOOLS = [
360
364
  "name": "smart_search",
361
365
  "_meta": ALWAYS_LOAD,
362
366
  "description": (
363
- "Semantic search (embed+rerank) over the project's index. Use instead of "
364
- "raw grep/read when the search has no clear scope (e.g. a folder with node_modules). "
367
+ "Semantic search (embed+rerank) over the project's index: the first step to understand code in a "
368
+ "project, finding functions, flows and reusable code by meaning, ranked, with name:line. Use it before "
369
+ "a series of Grep/Read; use Grep only for an exact literal or inside a known file. "
365
370
  "Results come in three blocks, code, test and doc, plus the project profile with the natural "
366
371
  "languages of identifiers, comments and docs (also in project_manage list/status as search_profile). "
367
372
  "Fill query_identifiers with identifier and function-name terms in the identifiers' language and "
@@ -3195,10 +3200,13 @@ WEB_FETCH_VECTOR_ROOT = os.path.join(paths.DATA_DIR, "web-fetch")
3195
3200
 
3196
3201
 
3197
3202
  def _moli_markdown(url, timeout):
3198
- """Markdown of a JavaScript page rendered by Moli (layout and paint only when the page needs them)."""
3203
+ """Markdown of a JavaScript page rendered by Moli (layout and paint only when the page needs them). Every request
3204
+ Moli makes (redirects, scripts, frames, fetch) refuses private, loopback, link-local and CGNAT addresses, as the
3205
+ HTTP reader and the Crawl4AI route guard do: a public page cannot read this machine or its network through it."""
3199
3206
  if not install_runtime.MOLI_EXE.is_file():
3200
3207
  raise RuntimeError(f"Moli is not installed ({install_runtime.MOLI_EXE}); reinstall Smart Tool.")
3201
- done = subprocess.run([str(install_runtime.MOLI_EXE), "fetch", "--dump", "markdown", url], capture_output=True,
3208
+ done = subprocess.run([str(install_runtime.MOLI_EXE), "fetch", "--block-private-networks", "--dump", "markdown",
3209
+ url], capture_output=True,
3202
3210
  timeout=timeout, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
3203
3211
  if done.returncode:
3204
3212
  raise RuntimeError(f"moli exited {done.returncode}: {done.stderr.decode('utf-8', 'replace').strip()[-300:]}")
@@ -3475,6 +3483,11 @@ class MCPHandler(BaseHTTPRequestHandler):
3475
3483
  return origin is None or _loopback_host(origin, require_port=True)
3476
3484
 
3477
3485
  def _reject_foreign_origin(self):
3486
+ """The agent's browser (Playwright MCP marks every request with AGENT_HEADER) never reaches the daemon: a page
3487
+ it opens could otherwise steer the agent into /setup, whose token unlocks every setting."""
3488
+ if self.headers.get(browser_control.AGENT_HEADER):
3489
+ self._write_json(403, {"error": "Smart Tool's pages are not available to the agent's browser"})
3490
+ return True
3478
3491
  if self._origin_allowed():
3479
3492
  return False
3480
3493
  self._write_json(403, {"error": "origin not allowed"})
package/version.py CHANGED
@@ -1,7 +1,7 @@
1
1
  """Single source of the Smart Tool version and of the User-Agent sent to public APIs."""
2
2
  import re
3
3
 
4
- VERSION = "0.9.10"
4
+ VERSION = "0.9.12"
5
5
  _CONTACT_RE = re.compile(r"^(?:[^\s@()<>;]+@[^\s@()<>;]+\.[^\s@()<>;]+|https?://[^\s()<>;]+)$")
6
6
 
7
7
 
package/web_fetch.py CHANGED
@@ -20,6 +20,8 @@ import ipaddress
20
20
  import json
21
21
  import socket
22
22
  import ssl
23
+ import subprocess
24
+ import sys
23
25
  import threading
24
26
  import time
25
27
  import urllib.parse
@@ -37,6 +39,8 @@ SELECT_BUDGET_CHARS = 15_000
37
39
  CHUNK_CHARS = 2_000
38
40
  PAGE_FORMAT = 2
39
41
  MAX_DOWNLOAD_BYTES = 5 * 1024 * 1024
42
+ PDF_MAX_PAGES = 300
43
+ PDF_TIMEOUT_S = 30
40
44
  MAX_REDIRECTS = 5
41
45
  HTTP_TIMEOUT_S = 20
42
46
  READ_CHUNK = 64 * 1024
@@ -208,6 +212,48 @@ def thin(text):
208
212
  return len(stripped) < PLACEHOLDER_SCAN_CHARS and any(p in stripped[:600].lower() for p in PLACEHOLDERS)
209
213
 
210
214
 
215
+ PDF_SCRIPT = """
216
+ import io, json, sys
217
+ import pypdf
218
+ reader = pypdf.PdfReader(io.BytesIO(sys.stdin.buffer.read()))
219
+ if reader.is_encrypted and reader.decrypt("") == pypdf.PasswordType.NOT_DECRYPTED:
220
+ print(json.dumps({"encrypted": True}))
221
+ sys.exit()
222
+ limit, budget = int(sys.argv[1]), int(sys.argv[2])
223
+ pages, size = [], 0
224
+ for page in reader.pages[:limit]:
225
+ pages.append((page.extract_text() or "").strip()[:budget - size])
226
+ size += len(pages[-1])
227
+ if size >= budget:
228
+ break
229
+ print(json.dumps({"pages": pages, "count": len(reader.pages)}))
230
+ """
231
+
232
+
233
+ def pdf_text(body, url, deadline):
234
+ """Text of a PDF (pypdf, pure Python), page by page up to PDF_MAX_PAGES. The native WebFetch reads PDFs and the hook
235
+ sends it here, so a PDF used to be a dead end (9 times in 10 days of transcripts). A hostile PDF can make pypdf
236
+ parse for minutes, so it runs in a subprocess killed at the request's deadline (at most PDF_TIMEOUT_S)."""
237
+ timeout = min(PDF_TIMEOUT_S, _left(deadline))
238
+ try:
239
+ done = subprocess.run([sys.executable, "-I", "-c", PDF_SCRIPT, str(PDF_MAX_PAGES), str(PAGE_STORE_MAX_CHARS)],
240
+ input=body, capture_output=True, timeout=timeout,
241
+ creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
242
+ except subprocess.TimeoutExpired:
243
+ raise FetchError(f"PDF not read in {timeout:.0f}s ({url}).") from None
244
+ if done.returncode:
245
+ detail = (done.stderr.decode("utf-8", "replace").strip().splitlines() or ["no output"])[-1]
246
+ raise FetchError(f"PDF could not be read ({url}): {detail}"[:300])
247
+ result = json.loads(done.stdout)
248
+ if result.get("encrypted"):
249
+ raise FetchError(f"PDF protected by a password is not read by web_fetch ({url}).")
250
+ pages = result["pages"]
251
+ text = "\n\n".join(f"[page {number}]\n{page}" for number, page in enumerate(pages, 1) if page)
252
+ if result["count"] > len(pages):
253
+ text += f"\n\n[pages {len(pages) + 1}-{result['count']} not read]"
254
+ return text
255
+
256
+
211
257
  def read_page(url, deadline, render):
212
258
  """Page text (Markdown) from the 24 h cache, HTTP + trafilatura, or `render(url, deadline)` when HTTP comes thin."""
213
259
  url = normalize(url)
@@ -230,6 +276,8 @@ def read_page(url, deadline, render):
230
276
  text, rendered = markdown, True
231
277
  elif kind in TEXT_TYPES:
232
278
  text = _decode(body, charset)
279
+ elif kind == "application/pdf":
280
+ text = pdf_text(body, final_url, deadline)
233
281
  else:
234
282
  raise FetchError(f"Content type {kind} is not read by web_fetch ({final_url}).")
235
283
  if len(text.strip()) < MIN_CHARS: