@allansantos-dev/smart-tool 0.9.11 → 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 +23 -0
- package/NOTICE +1 -0
- package/README.md +8 -3
- package/block_patterns.py +79 -8
- package/browser_control.py +125 -10
- package/endpoint_sync.py +6 -0
- package/install_runtime.py +4 -2
- package/package.json +1 -1
- package/requirements.txt +1 -0
- package/router.py +6 -5
- package/setup_ui.py +1 -0
- package/smart_tool_daemon.py +18 -5
- package/version.py +1 -1
- package/web_fetch.py +48 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
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
|
+
|
|
3
26
|
## 0.9.11 - beta
|
|
4
27
|
|
|
5
28
|
- Documentation only, no change to the program. The README opens with "How it works": what each tool does behind the
|
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,7 @@ 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.
|
|
18
|
+
**Status:** 0.9.12 beta. Windows only. What changed in each version: [CHANGELOG.md](CHANGELOG.md).
|
|
19
19
|
|
|
20
20
|
## How it works
|
|
21
21
|
|
|
@@ -48,7 +48,8 @@ session on the machine, so the same question asked twice costs nothing the secon
|
|
|
48
48
|
|
|
49
49
|
**Reading a page.** `web_fetch` takes a URL and a question. It downloads the page over plain HTTP; when the page only
|
|
50
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)
|
|
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.
|
|
52
53
|
The agent receives a paragraph instead of the whole page.
|
|
53
54
|
|
|
54
55
|
**Steering the agent (optional hook).** Agents keep reaching for their built-in tools out of habit. The hook sees
|
|
@@ -105,7 +106,11 @@ Open the setup screen from the tray icon ("Open settings") or at `http://127.0.0
|
|
|
105
106
|
`~/.smart-tool/data/playwright-mcp`. Before registering, Smart Tool starts that server and opens a blank page as an
|
|
106
107
|
agent would; where that fails (a corporate policy that forbids remote debugging, a missing browser) nothing is
|
|
107
108
|
registered, agents keep reading pages through `web_fetch`, and the setup screen shows the reason with a retry
|
|
108
|
-
button.
|
|
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
|
|
109
114
|
`claude mcp remove playwright -s user` / `codex mcp remove playwright`.
|
|
110
115
|
|
|
111
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
|
|
181
|
-
problem = "
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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):
|
package/browser_control.py
CHANGED
|
@@ -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", "--
|
|
54
|
-
"--
|
|
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(
|
|
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.
|
|
184
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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,
|
package/install_runtime.py
CHANGED
|
@@ -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.
|
|
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;
|
|
4
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|
package/smart_tool_daemon.py
CHANGED
|
@@ -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.
|
|
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
|
|
364
|
-
"
|
|
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",
|
|
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
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:
|