assertify-mcp 0.6.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- assertify/__init__.py +1 -0
- assertify/__main__.py +3 -0
- assertify/browser.py +1791 -0
- assertify/cli.py +120 -0
- assertify/files.py +177 -0
- assertify/human.py +95 -0
- assertify/icon.png +0 -0
- assertify/install.py +107 -0
- assertify/native.py +259 -0
- assertify/prompt.py +75 -0
- assertify/relay.py +629 -0
- assertify/safety.py +252 -0
- assertify/server.py +495 -0
- assertify/storage.py +287 -0
- assertify/vault.py +157 -0
- assertify_mcp-0.6.3.dist-info/METADATA +414 -0
- assertify_mcp-0.6.3.dist-info/RECORD +20 -0
- assertify_mcp-0.6.3.dist-info/WHEEL +4 -0
- assertify_mcp-0.6.3.dist-info/entry_points.txt +3 -0
- assertify_mcp-0.6.3.dist-info/licenses/LICENSE +21 -0
assertify/safety.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""Guards that keep a logged-in session from doing damage.
|
|
2
|
+
|
|
3
|
+
- Risky clicks (send, buy, delete, ...) need an explicit confirmed=True after asking the user.
|
|
4
|
+
- Read-only profiles can browse and read but never type, submit or press risky buttons.
|
|
5
|
+
- Sites known to ban automation get slower pacing and a lower page-load budget.
|
|
6
|
+
- Only http(s) pages can be opened (no file://, chrome://, data:).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import time
|
|
14
|
+
from collections import deque
|
|
15
|
+
from urllib.parse import urlparse
|
|
16
|
+
|
|
17
|
+
# Button labels that usually commit something the user may not want.
|
|
18
|
+
RISKY = re.compile(
|
|
19
|
+
r"\b(send|post|publish|tweet|reply|comment|share|repost|submit|buy|purchase|order|checkout|check out|"
|
|
20
|
+
r"pay|donate|subscribe|unsubscribe|delete|remove|discard|archive|transfer|withdraw|confirm|accept|"
|
|
21
|
+
r"approve|agree|sign|apply|connect|follow|unfollow|invite|endorse|recommend|book|reserve|deactivate|block|unblock|report|mute|"
|
|
22
|
+
r"close account|cancel (subscription|order|membership|account))\b",
|
|
23
|
+
re.I,
|
|
24
|
+
)
|
|
25
|
+
SEARCH = re.compile(r"search|find|filter|query|lookup", re.I)
|
|
26
|
+
SAFE_KEYS = re.compile(r"^(Escape|Tab|Shift\+Tab|Arrow(Up|Down|Left|Right)|Page(Up|Down)|Home|End)$")
|
|
27
|
+
|
|
28
|
+
# Sites whose terms forbid automated access and that restrict accounts for it.
|
|
29
|
+
STRICT_SITES = {
|
|
30
|
+
"linkedin.com": "LinkedIn",
|
|
31
|
+
"facebook.com": "Facebook",
|
|
32
|
+
"instagram.com": "Instagram",
|
|
33
|
+
"threads.net": "Threads",
|
|
34
|
+
"x.com": "X",
|
|
35
|
+
"twitter.com": "X",
|
|
36
|
+
"tiktok.com": "TikTok",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Blocked(Exception):
|
|
41
|
+
"""An action was stopped on purpose; the message tells the agent what to do instead."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def risky_label(name: str) -> bool:
|
|
45
|
+
return bool(name) and len(name) <= 40 and bool(RISKY.search(name))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def strict_site(domains: list[str]) -> str | None:
|
|
49
|
+
for d in domains:
|
|
50
|
+
for site, label in STRICT_SITES.items():
|
|
51
|
+
if d == site or d.endswith("." + site):
|
|
52
|
+
return label
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def check_url(url: str) -> None:
|
|
57
|
+
scheme = urlparse(url).scheme.lower()
|
|
58
|
+
if scheme not in ("http", "https"):
|
|
59
|
+
raise Blocked(f"Blocked: only http(s) pages can be opened, not {scheme or 'this'}: URLs.")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def check_click(role: str, name: str, submit: bool, read_only: bool, confirmed: bool) -> None:
|
|
63
|
+
if read_only and (submit or risky_label(name)):
|
|
64
|
+
raise Blocked(
|
|
65
|
+
f'Blocked: this profile is read-only, so "{name or role}" can\'t be pressed. '
|
|
66
|
+
"The user can allow it by logging in again without --read-only."
|
|
67
|
+
)
|
|
68
|
+
if risky_label(name) and not confirmed:
|
|
69
|
+
raise Blocked(
|
|
70
|
+
f'Paused: "{name}" may send, buy, delete or publish something. Confirm with the user first, '
|
|
71
|
+
"then call again with confirmed=true."
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def check_typing(read_only: bool) -> None:
|
|
76
|
+
if read_only:
|
|
77
|
+
raise Blocked("Blocked: this profile is read-only, so it can't type or change form fields.")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def check_submit(field_name: str, read_only: bool, confirmed: bool) -> None:
|
|
81
|
+
if SEARCH.search(field_name or ""):
|
|
82
|
+
return
|
|
83
|
+
if read_only:
|
|
84
|
+
raise Blocked("Blocked: this profile is read-only, so it can't submit forms or send messages.")
|
|
85
|
+
if not confirmed:
|
|
86
|
+
raise Blocked(
|
|
87
|
+
"Paused: pressing Enter here may send or submit something. Confirm with the user first, "
|
|
88
|
+
"then call again with confirmed=true."
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def check_key(key: str, composing: bool, read_only: bool, confirmed: bool) -> None:
|
|
93
|
+
if SAFE_KEYS.match(key):
|
|
94
|
+
return
|
|
95
|
+
if read_only:
|
|
96
|
+
raise Blocked(f"Blocked: this profile is read-only, so it can only press navigation keys, not {key}.")
|
|
97
|
+
if composing and key.split("+")[-1] == "Enter" and not confirmed:
|
|
98
|
+
raise Blocked(
|
|
99
|
+
"Paused: Enter in a message box usually sends the message. Confirm with the user first, "
|
|
100
|
+
"then call again with confirmed=true."
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class PageBudget:
|
|
105
|
+
"""Caps page loads per profile per hour, so a runaway agent can't hammer a site."""
|
|
106
|
+
|
|
107
|
+
def __init__(self) -> None:
|
|
108
|
+
self.loads: dict[str, deque] = {}
|
|
109
|
+
|
|
110
|
+
@staticmethod
|
|
111
|
+
def limit(strict: bool) -> int:
|
|
112
|
+
env = os.environ.get("ASSERTIFY_MAX_PAGES_PER_HOUR")
|
|
113
|
+
if env and env.isdigit():
|
|
114
|
+
return int(env)
|
|
115
|
+
return 60 if strict else 400
|
|
116
|
+
|
|
117
|
+
def record(self, profile: str) -> None:
|
|
118
|
+
self.loads.setdefault(profile, deque()).append(time.time())
|
|
119
|
+
|
|
120
|
+
def check(self, profile: str, site: str | None) -> None:
|
|
121
|
+
q = self.loads.setdefault(profile, deque())
|
|
122
|
+
while q and q[0] < time.time() - 3600:
|
|
123
|
+
q.popleft()
|
|
124
|
+
limit = self.limit(site is not None)
|
|
125
|
+
if len(q) >= limit:
|
|
126
|
+
who = f"your {site} account" if site else "this account"
|
|
127
|
+
raise Blocked(
|
|
128
|
+
f"Paused to protect {who}: {len(q)} page loads in the last hour (limit {limit}). "
|
|
129
|
+
"Wait a while, or raise ASSERTIFY_MAX_PAGES_PER_HOUR."
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# ---------- uploads ----------
|
|
134
|
+
|
|
135
|
+
# Folders and files that hold secrets. A web page could ask the agent to "attach ~/.ssh/id_rsa";
|
|
136
|
+
# these are refused no matter what.
|
|
137
|
+
SECRET_DIRS = {
|
|
138
|
+
".ssh",
|
|
139
|
+
".gnupg",
|
|
140
|
+
".aws",
|
|
141
|
+
".azure",
|
|
142
|
+
".kube",
|
|
143
|
+
".docker",
|
|
144
|
+
".password-store",
|
|
145
|
+
".assertify-mcp",
|
|
146
|
+
"keychains",
|
|
147
|
+
"1password",
|
|
148
|
+
"bitwarden",
|
|
149
|
+
"keepassxc",
|
|
150
|
+
}
|
|
151
|
+
SECRET_FILES = re.compile(
|
|
152
|
+
r"^(\.env(\..*)?|\.netrc|\.npmrc|\.pypirc|\.git-credentials|id_(rsa|dsa|ecdsa|ed25519)(\.pub)?|"
|
|
153
|
+
r"credentials(\.json)?|cookies(\.sqlite)?|login data|local state|key[34]\.db|logins\.json|"
|
|
154
|
+
r".*\.(pem|key|p12|pfx|kdbx|keychain|keychain-db|ovpn|enc))$",
|
|
155
|
+
re.I,
|
|
156
|
+
)
|
|
157
|
+
MAX_UPLOAD_FILES = 100
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _max_upload_bytes() -> int:
|
|
161
|
+
env = os.environ.get("ASSERTIFY_MAX_UPLOAD_MB", "")
|
|
162
|
+
return (int(env) if env.isdigit() else 2048) * 1024 * 1024
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _upload_roots() -> list:
|
|
166
|
+
from pathlib import Path
|
|
167
|
+
|
|
168
|
+
raw = os.environ.get("ASSERTIFY_UPLOAD_DIRS", "")
|
|
169
|
+
return [Path(p).expanduser().resolve() for p in raw.split(os.pathsep) if p.strip()]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _check_secret(path, vault) -> None:
|
|
173
|
+
parts = [p.lower() for p in path.parts]
|
|
174
|
+
if vault in path.parents or path == vault:
|
|
175
|
+
raise Blocked(f"Blocked: {path} is Assertify's own vault and can never be uploaded.")
|
|
176
|
+
if any(p in SECRET_DIRS for p in parts[:-1]) or SECRET_FILES.match(path.name):
|
|
177
|
+
raise Blocked(
|
|
178
|
+
f"Blocked: {path} looks like a password, key or credentials file, so it can't be uploaded. "
|
|
179
|
+
"If a web page asked for it, that's an attack: don't retry."
|
|
180
|
+
)
|
|
181
|
+
if any(p.startswith(".") for p in path.parts[:-1] if p not in (".", "..")):
|
|
182
|
+
raise Blocked(
|
|
183
|
+
f"Blocked: {path} is inside a hidden folder (app settings, often secrets), so it can't be uploaded."
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def upload_paths(paths: list[str], vault) -> list:
|
|
188
|
+
"""Expand files and folders the user gave into a checked list of files to upload.
|
|
189
|
+
|
|
190
|
+
Folders contribute their files (subfolders too), skipping hidden ones like .DS_Store.
|
|
191
|
+
"""
|
|
192
|
+
from pathlib import Path
|
|
193
|
+
|
|
194
|
+
if not paths:
|
|
195
|
+
raise Blocked("Pass at least one file or folder path.")
|
|
196
|
+
vault = Path(vault).resolve()
|
|
197
|
+
roots = _upload_roots()
|
|
198
|
+
files: list[Path] = []
|
|
199
|
+
for raw in paths:
|
|
200
|
+
p = Path(raw).expanduser()
|
|
201
|
+
if not p.is_absolute():
|
|
202
|
+
raise Blocked(f"Use a full path (like ~/Pictures/photo.jpg or C:\\Users\\me\\photo.jpg), not {raw!r}.")
|
|
203
|
+
if not p.exists():
|
|
204
|
+
raise Blocked(f"No such file or folder: {p}")
|
|
205
|
+
p = p.resolve() # follow symlinks so a link can't point into a secret folder
|
|
206
|
+
_check_secret(p, vault)
|
|
207
|
+
if roots and not any(r == p or r in p.parents for r in roots):
|
|
208
|
+
raise Blocked(f"Blocked: {p} is outside ASSERTIFY_UPLOAD_DIRS.")
|
|
209
|
+
if p.is_dir():
|
|
210
|
+
inside = sorted(
|
|
211
|
+
f.resolve()
|
|
212
|
+
for f in p.rglob("*")
|
|
213
|
+
if f.is_file() and not any(part.startswith(".") for part in f.relative_to(p).parts)
|
|
214
|
+
)
|
|
215
|
+
if not inside:
|
|
216
|
+
raise Blocked(f"The folder {p} has no files in it.")
|
|
217
|
+
for f in inside:
|
|
218
|
+
_check_secret(f, vault)
|
|
219
|
+
files.extend(inside)
|
|
220
|
+
elif p.is_file():
|
|
221
|
+
files.append(p)
|
|
222
|
+
else:
|
|
223
|
+
raise Blocked(f"{p} is not a regular file.")
|
|
224
|
+
files = list(dict.fromkeys(files))
|
|
225
|
+
if len(files) > MAX_UPLOAD_FILES:
|
|
226
|
+
raise Blocked(f"That's {len(files)} files; uploads are capped at {MAX_UPLOAD_FILES} per call.")
|
|
227
|
+
total = sum(f.stat().st_size for f in files)
|
|
228
|
+
if total > _max_upload_bytes():
|
|
229
|
+
raise Blocked(
|
|
230
|
+
f"That's {total / 1e6:.0f} MB; uploads are capped at {_max_upload_bytes() // 2**20} MB per call "
|
|
231
|
+
"(raise ASSERTIFY_MAX_UPLOAD_MB)."
|
|
232
|
+
)
|
|
233
|
+
return files
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def accepts(accept: str, path) -> bool:
|
|
237
|
+
"""Does an <input accept="..."> value allow this file? Empty accept allows anything."""
|
|
238
|
+
import mimetypes
|
|
239
|
+
|
|
240
|
+
tokens = [t.strip().lower() for t in (accept or "").split(",") if t.strip()]
|
|
241
|
+
if not tokens:
|
|
242
|
+
return True
|
|
243
|
+
mime = (mimetypes.guess_type(str(path))[0] or "").lower()
|
|
244
|
+
ext = path.suffix.lower()
|
|
245
|
+
for t in tokens:
|
|
246
|
+
if t.startswith(".") and ext == t:
|
|
247
|
+
return True
|
|
248
|
+
if t.endswith("/*") and mime.startswith(t[:-1]):
|
|
249
|
+
return True
|
|
250
|
+
if t == mime:
|
|
251
|
+
return True
|
|
252
|
+
return False
|