scamgate 0.3.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.
scamgate/harden.py ADDED
@@ -0,0 +1,393 @@
1
+ """Extra gate checks found while fitting scamgate to Hermes.
2
+
3
+ The hook already scores links and payments. This module covers what that scan never saw:
4
+
5
+ - Hermes safety switches (approvals off, /yolo, uninstalling the gate).
6
+ - Reading the signing key.
7
+ - A script the agent writes, then runs, including one cron will run later with no agent.
8
+ - The body of a file another tool will run (Makefile, package.json, git hooks, CI).
9
+
10
+ Quiet tools (notes, chat) are not scanned: quoting a scam link in a report is not a visit.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import re
17
+ from pathlib import Path
18
+
19
+ _SAFETY_BLOCK = re.compile(
20
+ r"""(?ix)
21
+ hermes\s+config\s+set\s+(?:approvals\b|plugins\.(?:hook_callback_timeout|disabled|entries)\b)
22
+ | hermes\s+config\s+unset\s+approvals\b
23
+ | hermes\s+config\s+edit\b
24
+ | hermes(?:['\"\],\s]+)config(?:['\"\],\s]+)set(?:['\"\],\s]+)['\"]?approvals
25
+ | hermes\s+plugins\s+(?:uninstall|remove|disable)\b
26
+ | (?<![A-Za-z0-9_-])--yolo\b
27
+ | HERMES_YOLO_MODE\s*=
28
+ | HERMES_YOLO_MODE['\"]\s*\]\s*=
29
+ | \$env:HERMES_YOLO_MODE\b
30
+ | (?<![A-Za-z0-9_-])--safe-mode\b
31
+ | HERMES_SAFE_MODE\s*=
32
+ | (?<![A-Za-z0-9_-])--ignore-user-config\b
33
+ | (?<![A-Za-z0-9_-])--dangerously-skip-permissions\b
34
+ | --permission-mode\s+bypassPermissions\b
35
+ | --dangerously-bypass-approvals-and-sandbox\b
36
+ | set_config_value\s*\([^)]*approvals
37
+ """
38
+ )
39
+ _SAFETY_ASK = re.compile(
40
+ r"""(?ix)
41
+ hermes\s+plugins\s+(?:install|enable)\b
42
+ | \bhermes\b[^\n]{0,120}\s(?:-p|--profile)\s+\S
43
+ """
44
+ )
45
+ _SECRET = re.compile(r"(?i)state\.key|payees\.salt|\.local[/\\]+state[/\\]+scamgate\b")
46
+ # Claude Code: disabling or removing a plugin (scamgate is one) or its marketplace, and starting a nested
47
+ # claude session that skips hooks (bare mode, or settings with disableAllHooks). The other bypass flags are
48
+ # in _SAFETY_BLOCK.
49
+ _CLAUDE_BIN = r"\bclaude(?:-code)?(?:\.exe|\.cmd)?"
50
+ _CLAUDE_SAFETY_BLOCK = re.compile(
51
+ rf"""(?ix)
52
+ {_CLAUDE_BIN}\s+plugins?\s+(?:disable|uninstall|remove|rm)\b
53
+ | {_CLAUDE_BIN}\s+plugins?\s+marketplace\s+(?:remove|rm)\b
54
+ | {_CLAUDE_BIN}\b[^\n|;&]{{0,300}}?(?<![A-Za-z0-9_-])--bare\b
55
+ | {_CLAUDE_BIN}\b[^\n|;&]{{0,300}}?/plugins?\s+(?:disable|uninstall|remove|rm)\b
56
+ | (?<![A-Za-z0-9_-])--allow-dangerously-skip-permissions\b
57
+ | disableAllHooks['"\s]*[:=]\s*['"]?true\b
58
+ """
59
+ )
60
+ # Claude Code: changing which plugins or settings a session loads.
61
+ _CLAUDE_SAFETY_ASK = re.compile(
62
+ rf"""(?ix)
63
+ {_CLAUDE_BIN}\s+plugins?\s+(?:install|i|enable|configure)\b
64
+ | {_CLAUDE_BIN}\b[^\n|;&]{{0,300}}?(?<![A-Za-z0-9_-])--setting-sources\b
65
+ | {_CLAUDE_BIN}\b[^\n|;&]{{0,300}}?(?<![A-Za-z0-9_-])--settings\b
66
+ """
67
+ )
68
+ # Exactly scamgate's own tools: Claude Code MCP, Claude Code plugin MCP, Hermes MCP, Hermes plugin. Not any
69
+ # name containing them.
70
+ _OWN_CHECK = re.compile(
71
+ r"(?i)^(?:mcp__scamgate__|mcp__plugin_scamgate_scamgate__|mcp_scamgate_|scamgate_)check_(?:url|payment)$")
72
+ _SCRIPT_EXT = re.compile(r"(?i)\.(?:sh|bash|py|js|mjs|cjs|ps1|bat|cmd)$")
73
+ _ENV_ASSIGN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
74
+ _RUNNABLE = re.compile(
75
+ r"(?i)(?:^|/)(?:makefile|package\.json|conftest\.py|[^/]+\.(?:sh|bash|py|js|mjs|ps1|bat|cmd|ya?ml))$"
76
+ )
77
+ _CONTENT_KEYS = ("content", "new_string", "new_text", "text", "body", "patch")
78
+ _PATH_KEYS = ("path", "file_path", "filepath", "notebook_path", "target_file")
79
+ _SKIP = {"sudo", "command", "time", "nohup", "builtin"}
80
+ _SHELLS = {"bash", "sh", "dash", "zsh", "ksh", "fish"}
81
+ _PY = {"python", "python3", "py", "python.exe", "python3.exe"}
82
+ _NODE = {"node", "nodejs", "node.exe"}
83
+ _PWSH = {"pwsh", "powershell", "pwsh.exe", "powershell.exe"}
84
+
85
+
86
+ def own_check(tool: str) -> bool:
87
+ """scamgate's own check tools. Asking 'is this link bad?' must not be blocked for naming it."""
88
+ return bool(_OWN_CHECK.match((tool or "").strip()))
89
+
90
+
91
+ def gate_extra(tool: str, tool_input: dict):
92
+ """Strongest extra decision, or None when these checks have nothing to say."""
93
+ from .hook import QUIET_TOOLS, Result, _RANK, _canon_tool, _norm_path, _strings
94
+
95
+ t = _canon_tool(tool)
96
+ quiet = t in QUIET_TOOLS
97
+ found = Result("none")
98
+
99
+ def take(new):
100
+ nonlocal found
101
+ if new is not None and _RANK[new.decision] > _RANK[found.decision]:
102
+ found = new
103
+
104
+ for key in _PATH_KEYS:
105
+ val = tool_input.get(key)
106
+ if isinstance(val, str) and _secret_path(val):
107
+ take(_block(f"it would read scamgate's signing state ({val})"))
108
+
109
+ if not quiet:
110
+ blob = "\n".join(_strings(tool_input))
111
+ if _SECRET.search(blob) or _mentions_state_dir(blob):
112
+ take(_block("it would read scamgate's signing key or payee history"))
113
+ if m := _SAFETY_BLOCK.search(blob) or _CLAUDE_SAFETY_BLOCK.search(blob):
114
+ take(_block(f"it would switch a safety control off ('{m.group(0).strip()[:80]}')"))
115
+ elif m := _SAFETY_ASK.search(blob) or _CLAUDE_SAFETY_ASK.search(blob):
116
+ take(_ask(f"it changes which agent or plugins are in force ('{m.group(0).strip()[:80]}'). "
117
+ "Approve only if you meant to install or switch."))
118
+ if t in {"write_file", "patch", "write", "edit", "multiedit", "notebookedit", "str_replace_editor"}:
119
+ path = next((tool_input.get(k) for k in _PATH_KEYS if isinstance(tool_input.get(k), str)), "")
120
+ body = "\n".join(tool_input.get(k) for k in _CONTENT_KEYS if isinstance(tool_input.get(k), str))
121
+ if path and body:
122
+ take(_judge_written(path, body))
123
+
124
+ if t == "cronjob_manage" or "cronjob" in t:
125
+ take(_cron_scripts(tool_input))
126
+ if not quiet:
127
+ take(_ran_scripts(tool, tool_input))
128
+
129
+ return None if found.decision == "none" else found
130
+
131
+
132
+ def _block(why: str):
133
+ from .hook import Result
134
+
135
+ return Result("block", f"scamgate blocked this because {why}. Agents can't weaken the scam gate or read "
136
+ "its signing key. Don't retry it another way; tell the user to do it themselves, "
137
+ "outside the agent.")
138
+
139
+
140
+ def _ask(why: str):
141
+ from .hook import Result
142
+
143
+ return Result("approve", f"scamgate: {why}", "scamgate:safety")
144
+
145
+
146
+ def _secret_path(path: str) -> bool:
147
+ from .hook import _norm_path
148
+ from .state import state_dir
149
+
150
+ p = _norm_path(path)
151
+ root = _norm_path(str(state_dir()))
152
+ if root and (p == root or p.startswith(root + "/")):
153
+ return True
154
+ return bool(_SECRET.search(p))
155
+
156
+
157
+ def _mentions_state_dir(text: str) -> bool:
158
+ from .hook import _expand, _norm_path
159
+ from .state import state_dir
160
+
161
+ root = _norm_path(str(state_dir()))
162
+ if len(root) < 8:
163
+ return False
164
+ return root in _norm_path(_expand(text))
165
+
166
+
167
+ def _runnable(path: str) -> bool:
168
+ p = path.replace("\\", "/").lower()
169
+ if "/.git/hooks/" in p or "/.github/workflows/" in p:
170
+ return True
171
+ if _under_hermes_scripts(path):
172
+ return True
173
+ return bool(_RUNNABLE.search(p))
174
+
175
+
176
+ def _shellish(path: str) -> bool:
177
+ p = path.replace("\\", "/").lower()
178
+ if p.endswith(("/makefile", "makefile", ".sh", ".bash", ".ps1", ".bat", ".cmd")):
179
+ return True
180
+ if "/.git/hooks/" in p or "/.github/workflows/" in p:
181
+ return True
182
+ return _under_hermes_scripts(path)
183
+
184
+
185
+ def _under_hermes_scripts(path: str) -> bool:
186
+ from .hook import _norm_path
187
+
188
+ home = os.environ.get("HERMES_HOME")
189
+ if not home:
190
+ return False
191
+ root = _norm_path(str(Path(home) / "scripts"))
192
+ p = _norm_path(path)
193
+ return p == root or p.startswith(root + "/")
194
+
195
+
196
+ def _judge_written(path: str, body: str):
197
+ from .hook import Result, _DECODE_RUN, _INDIRECT_RE, _NET_RE, _RANK, _URL_RE, _evaluate, _repair_schemes
198
+ from .hook import Gate
199
+
200
+ if not _runnable(path):
201
+ return None
202
+ if m := _SAFETY_BLOCK.search(body):
203
+ return _block(f"the file would switch a safety control off ('{m.group(0).strip()[:80]}')")
204
+ g = Gate(tool="write_file")
205
+ g.urls = _URL_RE.findall(_repair_schemes(body))
206
+ found = _evaluate(g) if g.urls else Result("none")
207
+ if _DECODE_RUN.search(body):
208
+ ask = Result("approve", "scamgate: this file decodes something and runs it, so scamgate can't see what "
209
+ "it will do. Approve only if you wrote it.", "scamgate:write-decode")
210
+ if _RANK[ask.decision] > _RANK[found.decision]:
211
+ found = ask
212
+ if _shellish(path) and _NET_RE.search(body) and _INDIRECT_RE.search(body) and found.decision != "block":
213
+ ask = Result("approve", "scamgate: this script can reach the network, but the destination isn't written "
214
+ "out, so scamgate can't check it. Approve only if you know where it goes.",
215
+ "scamgate:write-opaque")
216
+ if _RANK[ask.decision] > _RANK[found.decision]:
217
+ found = ask
218
+ return None if found.decision == "none" else found
219
+
220
+
221
+ def _cron_scripts(tool_input: dict):
222
+ from .hook import Result
223
+
224
+ home = os.environ.get("HERMES_HOME")
225
+ base = Path(home) / "scripts" if home else None
226
+ worst = None
227
+ for key in ("script", "monitor_script"):
228
+ name = tool_input.get(key)
229
+ if not isinstance(name, str) or not name.strip():
230
+ continue
231
+ path = Path(name)
232
+ if not path.is_absolute():
233
+ path = (base / name) if base else path
234
+ got = _read(path)
235
+ if got is None:
236
+ worst = worst or Result("approve", "scamgate: this scheduled job names a script scamgate can't read, "
237
+ "so it can't check what the job will do later. Approve only if you "
238
+ "know that script.", "scamgate:cron-unread")
239
+ continue
240
+ if got == "big":
241
+ return Result("approve", "scamgate: this scheduled script is too large to read. Approve only if you "
242
+ "know what it does.", "scamgate:cron-big")
243
+ judged = _judge_script(got, "cronjob_manage")
244
+ if judged and (worst is None or judged.decision == "block" or worst.decision != "block"):
245
+ worst = judged
246
+ return worst
247
+
248
+
249
+ def _ran_scripts(tool: str, tool_input: dict):
250
+ from .hook import CODE_KEYS, CODE_TOOLS, NET_TOOL_HINT, Result, _canon_tool, _segments
251
+
252
+ t = _canon_tool(tool)
253
+ if t not in CODE_TOOLS and not NET_TOOL_HINT.search(t):
254
+ return None
255
+ command = "\n".join(str(tool_input.get(k)) for k in CODE_KEYS if isinstance(tool_input.get(k), str))
256
+ if not command.strip():
257
+ return None
258
+ workdir = tool_input.get("workdir") if isinstance(tool_input.get("workdir"), str) else ""
259
+ cwd = workdir
260
+ worst = None
261
+ seen: set[str] = set()
262
+ for seg in _segments(command):
263
+ cd = re.match(r"(?i)^cd\s+(.+)$", seg.strip())
264
+ if cd and "&&" not in seg and ";" not in seg:
265
+ dest = cd.group(1).strip().strip("'\"")
266
+ cwd = dest if Path(dest).is_absolute() or not cwd else str(Path(cwd) / dest)
267
+ continue
268
+ for token in _script_tokens(seg):
269
+ path = _resolve(token, cwd)
270
+ hit = _follow(path, seen, 0)
271
+ if hit is None:
272
+ continue
273
+ if isinstance(hit, Result) and (worst is None or hit.decision == "block" or worst.decision != "block"):
274
+ worst = hit
275
+ return worst
276
+
277
+
278
+ def _follow(path: Path, seen: set[str], depth: int):
279
+ from .hook import MAX_SCRIPT_DEPTH, Result, _segments
280
+
281
+ key = str(path)
282
+ if key in seen or depth > MAX_SCRIPT_DEPTH:
283
+ return None
284
+ seen.add(key)
285
+ got = _read(path)
286
+ if got is None or got == "big":
287
+ if got == "big":
288
+ return Result("approve", f"scamgate: '{path.name}' is too large to read, so scamgate can't check what "
289
+ "it will do. Approve only if you know that script.", "scamgate:script-big")
290
+ return None
291
+ judged = _judge_script(got, "terminal")
292
+ if judged and judged.decision == "block":
293
+ return judged
294
+ nested = judged
295
+ for seg in _segments(got):
296
+ for token in _script_tokens(seg):
297
+ child = _follow(_resolve(token, str(path.parent)), seen, depth + 1)
298
+ if child is not None and (nested is None or child.decision == "block"):
299
+ nested = child
300
+ return nested
301
+
302
+
303
+ def _judge_script(text: str, tool: str):
304
+ from . import paygate
305
+ from .hook import Result, _RANK, _TAMPER_CMD, _evaluate, _scan_code
306
+ from .hook import Gate
307
+
308
+ if m := _SAFETY_BLOCK.search(text):
309
+ return _block(f"the script would switch a safety control off ('{m.group(0).strip()[:80]}')")
310
+ if m := _TAMPER_CMD.search(text):
311
+ return _block(f"the script would weaken or remove the scam gate ('{m.group(0).strip()[:80]}')")
312
+ g = Gate(tool=tool)
313
+ _scan_code(text, g)
314
+ found = _evaluate(g) if (g.urls or g.opaque_network) else Result("none")
315
+ if cp := paygate.scan_code(text):
316
+ ask = Result("approve", f"scamgate: this script makes a payment ({cp.detail}), and scamgate can't read "
317
+ "who gets paid or how much. Approve once, only if you started this payment.",
318
+ "scamgate:script-pay")
319
+ if _RANK[ask.decision] >= _RANK[found.decision]:
320
+ found = ask
321
+ return None if found.decision == "none" else found
322
+
323
+
324
+ def _read(path: Path):
325
+ from .hook import MAX_SCRIPT_BYTES
326
+
327
+ try:
328
+ if not path.is_file():
329
+ return None
330
+ if path.stat().st_size > MAX_SCRIPT_BYTES:
331
+ return "big"
332
+ return path.read_text(encoding="utf-8", errors="replace")
333
+ except OSError:
334
+ return "big"
335
+
336
+
337
+ def _resolve(token: str, cwd: str) -> Path:
338
+ token = os.path.expanduser(token.strip().strip("'\""))
339
+ path = Path(token)
340
+ if not path.is_absolute():
341
+ path = (Path(cwd) if cwd else Path.cwd()) / token
342
+ return path
343
+
344
+
345
+ def _script_tokens(segment: str) -> list[str]:
346
+ from .hook import _words # shlex's words in one pass: shlex itself is superlinear on long tokens
347
+
348
+ toks = _words(segment)
349
+ if toks is None:
350
+ return []
351
+ i = 0
352
+ while i < len(toks) and _ENV_ASSIGN.match(toks[i]) and "/" not in toks[i] and "\\" not in toks[i]:
353
+ i += 1
354
+ while i < len(toks) and Path(toks[i]).name.lower() in _SKIP:
355
+ i += 1
356
+ while i < len(toks) and toks[i].startswith("-"):
357
+ i += 1
358
+ if i >= len(toks):
359
+ return []
360
+ if toks[i] in ("&", "."):
361
+ return [toks[i + 1]] if i + 1 < len(toks) else []
362
+ cmd = Path(toks[i]).name.lower()
363
+ rest = toks[i + 1:]
364
+ if cmd in _SHELLS or cmd == "source":
365
+ return _first_file(rest, inline_flags={"-c", "-lc"})
366
+ if cmd in _PY or cmd in _NODE:
367
+ return _first_file(rest, inline_flags={"-c", "-m"})
368
+ if cmd == "uv" and rest and rest[0] == "run":
369
+ return _first_file(rest[1:], inline_flags=set())
370
+ if cmd in _PWSH:
371
+ if "-File" in rest:
372
+ k = rest.index("-File")
373
+ return [rest[k + 1]] if k + 1 < len(rest) else []
374
+ if rest and rest[0].lower().endswith(".ps1"):
375
+ return [rest[0]]
376
+ return []
377
+ token = toks[i]
378
+ if token.startswith("./") or token.startswith(".\\") or _SCRIPT_EXT.search(token):
379
+ return [token]
380
+ return []
381
+
382
+
383
+ def _first_file(args: list[str], inline_flags: set[str]) -> list[str]:
384
+ j = 0
385
+ while j < len(args):
386
+ arg = args[j]
387
+ if arg in inline_flags:
388
+ return []
389
+ if arg.startswith("-"):
390
+ j += 1
391
+ continue
392
+ return [arg]
393
+ return []