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/hook.py ADDED
@@ -0,0 +1,1055 @@
1
+ """Enforcement hook: the harness runs scamgate before tools act, the model can't skip it.
2
+
3
+ `scamgate hook` reads one hook event as JSON on stdin and writes a decision on stdout.
4
+ It speaks two dialects:
5
+
6
+ - Hermes shell hooks (`pre_tool_call` / `post_tool_call`): {"action": "block"|"approve", ...}
7
+ - Claude Code hooks (`PreToolUse` / `PostToolUse`): {"hookSpecificOutput": {"permissionDecision": ...}}
8
+
9
+ What it gates (pre events):
10
+ 1. Tamper attempts: writes to scamgate's own files/state/cache or the agent's hook config, and shell
11
+ commands that remove the hook/MCP server or promote overrides. Always blocked.
12
+ 2. Network-capable tools: every URL found in the arguments is checked. deny -> block,
13
+ ask -> human approval. A network step whose destination can't be read -> human approval.
14
+ 3. Everything else passes silently.
15
+
16
+ Never returns "allow": silence means "no objection", so the hook can only tighten the
17
+ agent's own permissions, never loosen them.
18
+
19
+ Fails closed: bad input or an internal error on a pre event blocks the call (exit 2).
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import hashlib
25
+ import json
26
+ import os
27
+ import re
28
+ import sys
29
+ from dataclasses import dataclass, field
30
+ from pathlib import Path
31
+ from typing import Any
32
+
33
+ from . import asklog, engine, integrity
34
+ from .models import Decision
35
+ from .rules.url import parse, repair_schemes as _repair_schemes
36
+ from .state import state_dir
37
+
38
+ MAX_URLS = 20
39
+ MAX_INPUT_BYTES = 2 * 1024 * 1024
40
+ MAX_DEPTH = 64
41
+ MAX_SCRIPT_BYTES = 256 * 1024
42
+ MAX_SCRIPT_DEPTH = 4
43
+ STALE_FOR_HOOK_HOURS = 7 * 24
44
+
45
+ # ------------------------------------------------------------------ tool classes
46
+
47
+ URL_TOOLS = { # tools whose arguments name a destination directly
48
+ "browser_navigate", "browser_tool", "web_extract", "webfetch", "fetch", "navigate",
49
+ }
50
+ CODE_TOOLS = { # tools whose arguments are code/commands that may reach the network
51
+ "terminal", "bash", "shell", "execute_code", "browser_exec", "browser_cdp", "browser_console",
52
+ "powershell", "python",
53
+ }
54
+ WRITE_TOOLS = {"write_file", "patch", "write", "edit", "multiedit", "notebookedit", "str_replace_editor"}
55
+ # Tools whose text is a note, a search query or a message, not a visit.
56
+ QUIET_TOOLS = {"memory", "todo", "todo_list", "todowrite", "clarify", "send_message", "skill_manage",
57
+ "hindsight_retain", "context_notes", "web_search", "websearch", "read_file", "search_files",
58
+ "skill_view", "skills_list", "text_to_speech", "image_generate", "browser_click",
59
+ "browser_snapshot", "browser_scroll", "browser_press", "browser_back"}
60
+ # Name fragments that mark an unknown (usually MCP) tool as able to reach the network.
61
+ NET_TOOL_HINT = re.compile(r"(?i)fetch|navigat|browser|browse|url|http|request|download|crawl|scrape|"
62
+ r"open_?page|visit|goto|shell|exec|run_?(?:command|code|script)|terminal|curl|"
63
+ r"evaluate|web")
64
+ URL_KEYS = ("url", "urls", "link", "href", "target", "uri", "endpoint", "page", "image_url", "video_url",
65
+ "address", "location", "src", "source_url", "website")
66
+ CODE_KEYS = ("command", "code", "script", "expression", "cmd", "input", "cmdline", "function", "args", "argv")
67
+ PATH_KEYS = ("path", "file_path", "filepath", "notebook_path", "target_file")
68
+ REASON_KEYS = ("description", "prompt", "intent", "reason", "task", "goal")
69
+
70
+ _URL_RE = re.compile(r"""(?i)\b(?:https?|wss?|ftp)://(?:\[[0-9a-f:.]+\]|[^\s'"<>`)\]}\\])+""") # [::1] stays whole
71
+ # A URL whose host is built at run time: variables, templates, string concatenation.
72
+ _DYNAMIC_URL_RE = re.compile(
73
+ r"""(?ix)\b(?:https?|wss?|ftp)://
74
+ (?: [^\s/'"`]*[$%{}`] # $H, ${H}, %H%, {host}, `cmd` in the host part
75
+ | [^\s/'"`]*['"`]\s*(?:\+|\.\s|['"]) # 'https://evil'+'shop', "https://evil""shop"
76
+ | ['"`] )""")
77
+ _NET_RE = re.compile(
78
+ r"(?i)(?:(?<![\w.-])(?:curl|wget|aria2c|httpie|http|nc|ncat|telnet|ssh|sftp|ftp|tftp|socat|gnutls-cli|"
79
+ r"test-netconnection|tnc|"
80
+ r"nslookup|dig|host|ping|traceroute|"
81
+ r"invoke-webrequest|invoke-restmethod|iwr|irm|start-bitstransfer|start-process|certutil|bitsadmin|"
82
+ r"xdg-open|explorer|"
83
+ r"pipx|uvx|npx|bunx)(?![\w.-])|"
84
+ r"(?:^|(?<=[;&|(]\s)|(?<=[;&|(]))\s*(?:open|start)\s+(?!-\w*\s*$)|" # macOS open / Windows start as a command
85
+ r"(?<![\w.-])(?:rsync|scp|sftp)(?![\w.-])|" # only with a host:path argument
86
+ r"\bopenssl\s+s_client\b|"
87
+ # Package managers: only the subcommands that download something.
88
+ r"(?<![\w.-])(?:pip3?|poetry|conda|npm|yarn|pnpm|bun|deno|gem|cargo|go|brew|winget|choco|scoop|apt|apt-get|"
89
+ r"docker|podman|helm)\s+(?:install|i|add|ci|update|upgrade|fetch|get|pull|download|publish|push|dlx|"
90
+ r"create|search|source|run\s+https?)\b|"
91
+ r"(?<![\w.-])(?:yarn|pnpm|bun)(?:\s*$|\s+--)|" # bare 'yarn' / 'pnpm' installs
92
+ r"(?<![\w.-])kubectl\s+(?:apply|create)\s+-f\s+https?|"
93
+ # git fetch/pull/push go to remotes already configured (adding one with a URL is checked).
94
+ r"\bgit\s+(?:clone|ls-remote|remote\s+(?:add|set-url)|submodule\s+add)\b|"
95
+ r"\buv\s+(?:pip\s+install|add|tool\s+(?:install|run)|run\s+--with)\b|"
96
+ r"requests\.|httpx\.|urllib|urlopen|aiohttp|socket\.|http\.client|ftplib|smtplib|"
97
+ r"\bfetch\(|xmlhttprequest|websocket|navigator\.sendbeacon|"
98
+ r"new_tab\(|goto_url\(|goto\(|navigate\(|page\.goto|window\.open|location\.(?:href|assign|replace)|"
99
+ r"web_search\(|web_extract\(|webbrowser\.open|net\.webclient|downloadstring|downloadfile|"
100
+ r"/dev/(?:tcp|udp)/)" # bash opens a socket for these paths
101
+ )
102
+ # Signs a network step's destination is hidden in a variable, a file, a subshell or stdin.
103
+ _INDIRECT_RE = re.compile(r"""(?ix)\$\{?\w|\$\(|`|<\(|%\w+%|\bopen\(|\.read\(\)|\bxargs\b|
104
+ \s-K\s|--config\b|\s-i\s|--input-file|
105
+ b64decode|b32decode|a85decode|fromhex|unhexlify|codecs\.|atob\(|fromcharcode|
106
+ \[::-1\]|['"]\.join\(|\bchr\(|rot13|reverse\(\)|\bbase64\s+-d|\bxxd\s+-r""")
107
+ _SEGMENT_SPLIT = re.compile(r"\n|;|&&|\|\||\|")
108
+ _LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1", "0.0.0.0"}
109
+ _TAMPER_CMD = re.compile(
110
+ r"(?i)(hermes\s+(?:hooks\s+(?:revoke|rm|remove)|mcp\s+(?:rm|remove)|plugins\s+(?:disable|remove)|"
111
+ r"config\s+set\s+(?:hooks\b|hooks_auto_accept|mcp_servers))|"
112
+ r"(?:scamgate|-m\s+scamgate(?:\.cli)?)\W{0,3}\s+\W{0,2}(?:promote|record-payment)\b|"
113
+ r"\b(?:promote|record-payment)\b[^\n]*\s--(?:host|payee|account|decision)\b|"
114
+ r"scamgate\.(?:overrides|payees|asklog|state|guard)\b|"
115
+ r"(?:over['\"]?\s*\+\s*['\"]?rides|pay['\"]?\s*\+\s*['\"]?ees)|"
116
+ r"__import__\(\s*['\"]scamgate|importlib[^\n]*scamgate|"
117
+ r"from\s+scamgate\s+import\s+[^\n;]*\b(?:overrides|payees|asklog|state|guard)\b|"
118
+ r"disableAllHooks|"
119
+ r"(?:uv\s+tool|pipx|pip3?|uv\s+pip)\s+uninstall\s+(?:-y\s+)?scamgate|"
120
+ r"claude\s+mcp\s+remove\s+scamgate|"
121
+ r"--accept-hooks|HERMES_ACCEPT_HOOKS)"
122
+ )
123
+ _SHELL_WRITE = re.compile(
124
+ r"(?i)(\brm\b|\bdel\b|\berase\b|\bmv\b|\bmove\b|\bcp\b|\bcopy\b|\btruncate\b|\bsed\s+-i|\btee\b|"
125
+ r"\bln\b|\binstall\s+-|\bchmod\b|\bchown\b|\bperl\s+-[a-z]*i|\bdd\b|write_text|write_bytes|\.write\(|"
126
+ r"\b(?:vim?|nvim|ed|ex|emacs|nano|pico|micro|joe|notepad(?:\+\+)?|code|subl|gedit|kate|rsync|patch)\b|"
127
+ r"\s-exec\b|\s-execdir\b|\s-delete\b|"
128
+ r">>?|remove-item|set-content|add-content|out-file|clear-content|rename-item|\.write_text|"
129
+ r"\.unlink|shutil\.|os\.remove|open\([^)]*['\"][wa])"
130
+ )
131
+
132
+
133
+ def _canon_tool(name: str) -> str:
134
+ n = (name or "").strip()
135
+ if n.startswith("mcp__") or n.startswith("mcp_"):
136
+ n = n.split("__")[-1] if "__" in n else n
137
+ return n.lower()
138
+
139
+
140
+ # ------------------------------------------------------------------ protected paths
141
+
142
+ def _norm_path(p: str) -> str:
143
+ s = os.path.expandvars(os.path.expanduser(p.strip().strip("'\"")))
144
+ s = s.replace("\\", "/")
145
+ m = re.match(r"^/([a-zA-Z])/(.*)$", s) # git-bash /c/Users -> c:/users
146
+ if m:
147
+ s = f"{m.group(1)}:/{m.group(2)}"
148
+ try:
149
+ s = str(Path(s).resolve(strict=False)).replace("\\", "/")
150
+ except (OSError, RuntimeError):
151
+ pass
152
+ return s.lower().rstrip("/")
153
+
154
+
155
+ def protected_roots() -> list[str]:
156
+ from .feeds import cache_dir
157
+
158
+ roots: list[str] = []
159
+ pkg = Path(__file__).resolve().parent
160
+ roots.append(str(pkg))
161
+ # The virtualenv scamgate runs from (uv tool / pipx installs are venvs): its site-packages, where a
162
+ # new .pth file would run code at every start, and its entry scripts. A system-wide install is
163
+ # not covered: protecting /usr would block too much.
164
+ if sys.prefix != sys.base_prefix:
165
+ import sysconfig
166
+
167
+ paths = sysconfig.get_paths()
168
+ roots += [paths["purelib"], paths["scripts"]]
169
+ roots.append(str(state_dir()))
170
+ roots.append(str(cache_dir()))
171
+ if env := os.environ.get("SCAMGATE_CONFIG"):
172
+ roots.append(env)
173
+ if env := os.environ.get("SCAMGATE_BLOCKLIST"):
174
+ roots.append(env)
175
+ if home := os.environ.get("HERMES_HOME"):
176
+ h = Path(home)
177
+ roots += [str(h / "config.yaml"), str(h / "shell-hooks-allowlist.json"), str(h / "agent-hooks"),
178
+ str(h / "hooks"), str(h / "plugins")]
179
+ # Claude Code: settings, hooks, and installed plugins (the scamgate plugin's own hooks.json lives there).
180
+ claude_dirs = [Path.home() / ".claude"]
181
+ if env := os.environ.get("CLAUDE_CONFIG_DIR"):
182
+ claude_dirs.append(Path(env))
183
+ for c in claude_dirs:
184
+ roots += [str(c / "settings.json"), str(c / "settings.local.json"), str(c / "hooks"), str(c / "plugins")]
185
+ if env := os.environ.get("SCAMGATE_PROTECT"):
186
+ roots += [p for p in env.split(os.pathsep) if p.strip()]
187
+ return [_norm_path(r) for r in roots if r]
188
+
189
+
190
+ def _is_protected(path: str, roots: list[str]) -> str | None:
191
+ p = _norm_path(path)
192
+ for r in roots:
193
+ if p == r or p.startswith(r + "/"):
194
+ return r
195
+ return None
196
+
197
+
198
+ # Path endings that are always protected, however the path is spelled (~, $HOME, globs, cd + relative).
199
+ _PROTECTED_TAILS = (
200
+ (".claude/settings.json", ".claude/settings"), (".claude/settings.local.json", ".claude/settings"),
201
+ (".claude/hooks", ".claude/hooks"), (".claude/plugins", ".claude/plugins"),
202
+ (".hermes/config.yaml", ".hermes/config"),
203
+ ("hermes/config.yaml", "hermes/config"), ("shell-hooks-allowlist.json", "shell-hooks-allowlist"),
204
+ ("hermes/agent-hooks", "hermes/agent-hooks"), ("hermes/plugins", "hermes/plugins"),
205
+ ("hermes/profiles", "hermes/profiles"), (".hermes/profiles", ".hermes/profiles"),
206
+ )
207
+ _TAIL_RE = re.compile(r"""(?ix)
208
+ (?:\.claude|\.cl\*|\.c\*)[/\\]+(?:settings[\w.*]*|sett\*[\w.*]*|hooks|plugins)\b
209
+ | \bhermes[/\\]+(?:config\.ya?ml|agent-hooks|plugins|hooks|profiles)\b
210
+ | \.hermes[/\\]+profiles\b
211
+ | shell-hooks-allowlist
212
+ | ['"]\.claude['"]\s*[)/]\s*(?:/\s*)?['"]settings
213
+ | \bcd\s+\S*\.claude\b[^\n]*\bsettings
214
+ | \bcd\s+\S*hermes\b[^\n]*\bconfig\.ya?ml
215
+ | \.claude\b[^\n]{0,80}\bsettings
216
+ | \bsettings[\w.*'"]*[^\n]{0,80}\.claude\b""")
217
+
218
+
219
+ def _expand(text: str) -> str:
220
+ t = text.replace("\\", "/")
221
+ home = str(Path.home()).replace("\\", "/")
222
+ t = re.sub(r"(?<![\w/])~(?=/|\b)", home, t)
223
+ t = re.sub(r"\$\{?(?:HOME|USERPROFILE)\}?|\$env:(?:USERPROFILE|HOME)|%USERPROFILE%", home, t, flags=re.I)
224
+ if hh := os.environ.get("HERMES_HOME"):
225
+ t = re.sub(r"\$\{?HERMES_HOME\}?|\$env:HERMES_HOME|%HERMES_HOME%", hh.replace("\\", "/"), t, flags=re.I)
226
+ if la := os.environ.get("LOCALAPPDATA"):
227
+ t = re.sub(r"\$\{?LOCALAPPDATA\}?|\$env:LOCALAPPDATA|%LOCALAPPDATA%", la.replace("\\", "/"), t, flags=re.I)
228
+ return t
229
+
230
+
231
+ def _tidy_paths(norm: str) -> str:
232
+ """Spell paths in command text one way: `a/./b`, `a//b` and `a/x/../b` all become `a/b`."""
233
+ norm = re.sub(r"/(?:\./)+", "/", norm)
234
+ norm = re.sub(r"(?<!:)/{2,}", "/", norm) # keeps https://
235
+ for _ in range(16):
236
+ tidier = re.sub(r"/(?!\.\.(?:/|$))[^/\s'\"`;|&<>]+/\.\.(?=/|$|[\s'\"`;|&<>])", "", norm)
237
+ if tidier == norm:
238
+ break
239
+ norm = tidier
240
+ return norm
241
+
242
+
243
+ def _names_path(text: str, path: str) -> bool:
244
+ """`path` appears in `text` as a whole folder or file: `.cache/scamgate` is not `.cache/scamgate-feeds.log`."""
245
+ return re.search(re.escape(path) + r"(?![\w.-])", text) is not None
246
+
247
+
248
+ def _mentions_protected(text: str, roots: list[str]) -> str | None:
249
+ norm = _expand(text).lower()
250
+ norm = re.sub(r"(?<![a-z])/([a-z])/", lambda m: f"{m.group(1)}:/", norm)
251
+ forms = (norm, _tidy_paths(norm)) # both: `scamgate/..` names the folder too
252
+ for r in roots:
253
+ tail = r.split(":/", 1)[-1] if ":/" in r else r.lstrip("/")
254
+ if any(_names_path(f, r) or (len(tail) > 12 and _names_path(f, tail)) for f in forms):
255
+ return r
256
+ if m := _TAIL_RE.search(norm) or _TAIL_RE.search(forms[1]):
257
+ return m.group(0).strip()
258
+ return None
259
+
260
+
261
+ def _is_protected_tail(path: str) -> bool:
262
+ p = _norm_path(path)
263
+ return any(p.endswith("/" + t) or p == t or f"/{t}/" in p for t, _ in _PROTECTED_TAILS) \
264
+ or bool(re.search(r"/\.claude/settings[^/]*\.json$", p))
265
+
266
+
267
+ # ------------------------------------------------------------------ extraction
268
+
269
+ @dataclass
270
+ class Gate:
271
+ tool: str
272
+ urls: list[str] = field(default_factory=list)
273
+ opaque_network: bool = False
274
+ opaque_detail: str = ""
275
+ stated_reason: str = ""
276
+
277
+ def opaque(self, detail: str) -> None:
278
+ self.opaque_network = True
279
+ self.opaque_detail = self.opaque_detail or detail
280
+
281
+
282
+ def _strings(value: Any, depth: int = 0) -> list[str]:
283
+ if depth > MAX_DEPTH:
284
+ raise ValueError(f"tool input nested more than {MAX_DEPTH} levels")
285
+ if isinstance(value, str):
286
+ return [value]
287
+ if isinstance(value, (list, tuple)):
288
+ return [s for v in value for s in _strings(v, depth + 1)]
289
+ if isinstance(value, dict):
290
+ return [s for v in value.values() for s in _strings(v, depth + 1)]
291
+ return []
292
+
293
+
294
+ def _first_comment(code: str) -> str:
295
+ for line in code.splitlines():
296
+ s = line.strip()
297
+ if s.startswith(("#", "//")):
298
+ return s.lstrip("#/ ").strip()
299
+ if s:
300
+ break
301
+ return ""
302
+
303
+
304
+ _BARE_HOST = re.compile(r"^[\w.-]+\.[a-z]{2,}(?::\d+)?(?:[/?#].*)?$", re.I)
305
+ _HOST_TOKEN = re.compile(r"(?i)(?<![\w@/.:$-])((?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:/[^\s'\"`;|&]*)?)(?![\w-])")
306
+ _NOT_A_HOST = re.compile(r"(?i)\.(?:py|js|mjs|ts|sh|json|ya?ml|toml|md|txt|csv|html?|css|lock|cfg|ini|exe|zip|tar|"
307
+ r"gz|png|jpe?g|gif|pdf|log|env|whl|ps1|bat|cmd|get|post|read|text|status_code)$")
308
+
309
+
310
+ def _looks_like_bare_host(s: str) -> bool:
311
+ s = s.strip()
312
+ return bool(s) and " " not in s and "." in s and not s.startswith(("/", ".", "~")) and bool(_BARE_HOST.match(s))
313
+
314
+
315
+ # Common generic TLDs. Any two-letter label also counts (country codes), except ones that are
316
+ # far more often code: user.id, re.search's "re", file.py, os.path's "os"...
317
+ _GTLDS = set("""com net org edu gov mil int info biz name pro io co ai app dev me tv cc xyz top site online shop
318
+ store tech cloud live link click page blog news club space website fun icu buzz lol cam sbs cfd rest bar mobi asia
319
+ tel travel jobs email digital agency solutions services support network systems software security finance bank
320
+ money pay credit loan tickets events zip mov ink one world today life plus host global wiki chat social art design
321
+ studio media gg network center group company business market exchange trade tokyo london dublin irish""".split())
322
+ _CODE_2 = {"id", "re", "py", "js", "ts", "sh", "md", "rs", "pl", "os", "np", "pd", "tf", "db", "fs", "el", "go",
323
+ "rb", "cs", "pm", "ps", "mk", "am", "in", "is", "it", "to", "at", "as", "do", "no", "on", "or", "so", "up"}
324
+ _IMPORT_BEFORE = re.compile(r"(?:\bimport|\bfrom)\s+[\w., ]*$|\bas\s+$|\brequire\(\s*['\"]$")
325
+
326
+
327
+ def _plausible_host(host: str) -> bool:
328
+ labels = host.lower().split(".")
329
+ tld = labels[-1]
330
+ if len(labels) < 2 or not all(labels):
331
+ return False
332
+ return tld in _GTLDS or (len(tld) == 2 and tld.isalpha() and tld not in _CODE_2)
333
+
334
+
335
+ _REMOTE_TRANSFER = re.compile(r"(?i)(?<![\w.-])(?:scp|sftp|rsync)(?![\w.-])")
336
+ _TRANSFER_VALUE_FLAGS = {"-P", "-p", "-S", "-F", "-i", "-c", "-o", "-J", "-l", "-B", "-D", "-R", "-b", "-e", "--rsh", "--port"}
337
+ _TRANSFER_HOST = re.compile(r"(?i)^(?:[\w.+~-]+@)?(?P<host>(?:[a-z0-9-]+\.)+[a-z]{2,}|\d{1,3}(?:\.\d{1,3}){3}|\[[0-9a-f:]+\])(?::.*)?$")
338
+ _TRANSFER_URL = re.compile(r"(?i)^(?:scp|sftp|rsync)://(?:[^/@\s]+@)?(?P<host>[^/:\s]+)")
339
+
340
+
341
+ def _transfer_hosts(segment: str) -> list[str]:
342
+ """Hosts named by scp, sftp and rsync, including absolute user@host:/path destinations."""
343
+ if not _REMOTE_TRANSFER.search(segment):
344
+ return []
345
+ try:
346
+ toks = _shell_words(segment)
347
+ except ValueError:
348
+ return []
349
+ while toks and toks[0] in ("sudo", "command"):
350
+ toks = toks[1:]
351
+ if not toks or toks[0].lower() not in ("scp", "sftp", "rsync"):
352
+ return []
353
+ out, i = [], 1
354
+ while i < len(toks):
355
+ tok = toks[i]
356
+ if tok in _TRANSFER_VALUE_FLAGS and i + 1 < len(toks):
357
+ i += 2
358
+ continue
359
+ if tok.startswith("-"):
360
+ i += 1
361
+ continue
362
+ m = _TRANSFER_URL.match(tok) or _TRANSFER_HOST.match(tok)
363
+ if m:
364
+ host = m.group("host").strip("[]")
365
+ if _plausible_host(host):
366
+ out.append("https://" + host)
367
+ i += 1
368
+ return ["local-transfer"] if not out else out
369
+
370
+
371
+ _SOCAT_ADDR = re.compile(r"(?i)\b(?:tcp[46]?|udp[46]?|openssl|ssl|tls|proxy|socks[45]a?)"
372
+ r"(?:-connect)?:(localhost|(?:[a-z0-9-]+\.)+[a-z]{2,}|\d{1,3}(?:\.\d{1,3}){3}|\[[0-9a-f:]+\])"
373
+ r"(?::(\d+))?")
374
+
375
+
376
+ def _bare_hosts(segment: str) -> list[str]:
377
+ out = [f"https://{m.group(1)}" + (f":{m.group(2)}" if m.group(2) else "") for m in _SOCAT_ADDR.finditer(segment)]
378
+ if out:
379
+ return out
380
+ out.extend(_transfer_hosts(segment))
381
+ for m in _HOST_TOKEN.finditer(segment):
382
+ tok = m.group(1)
383
+ host = tok.split("/", 1)[0].split(":", 1)[0]
384
+ after = segment[m.end():m.end() + 1]
385
+ if (_NOT_A_HOST.search(host) or not _plausible_host(host) or after in ("(", "[", "=")
386
+ or _IMPORT_BEFORE.search(segment[max(0, m.start() - 60):m.start()])):
387
+ continue
388
+ out.append("https://" + tok)
389
+ return out
390
+
391
+
392
+ _WORD_RUN = re.compile(r"[\w.]+")
393
+ _NAME_START = re.compile(r"[A-Za-z_]")
394
+
395
+
396
+ def _code_like(s: str) -> bool:
397
+ r"""Same answer as `re.compile(r"[A-Za-z_][\w.]*\(").search(s)`, without the quadratic scan.
398
+
399
+ That pattern backtracks from every start position to the end of the string, so one long
400
+ identifier with no parenthesis in it costs O(n^2): a 100k-character command takes about 40 s
401
+ inside the hook, and the hook input cap is 2 MiB. Scanning whole word/dot runs once gives the
402
+ identical answer in O(n).
403
+ """
404
+ for m in _WORD_RUN.finditer(s):
405
+ end = m.end()
406
+ if end < len(s) and s[end] == "(" and _NAME_START.search(s, m.start(), end):
407
+ return True
408
+ return False
409
+
410
+
411
+ _PIP_VALUE_FLAGS = {"-r", "--requirement", "-c", "--constraint", "-e", "--editable", "-i", "--index-url",
412
+ "--extra-index-url", "-f", "--find-links", "-t", "--target", "--prefix", "--python", "-p",
413
+ "--root", "--platform", "--python-version", "--implementation", "--abi", "--only-binary",
414
+ "--no-binary", "--progress-bar", "--log", "--cache-dir", "--src", "--upgrade-strategy"}
415
+ _PATHISH = re.compile(r"^(?:[.~/\\]|[a-zA-Z]:[/\\])|[/\\]|\.(?:whl|tar\.gz|zip|txt|in|toml|lock|cfg)$")
416
+ _LOCAL_ONLY = re.compile(r"""(?ix)^(?:sudo\s+)?(?:
417
+ uv\s+(?:sync|lock)\b | poetry\s+(?:install|lock)\b | bundle(?:\s+install)?\b | cargo\s+(?:build|fetch|test|check)\b |
418
+ go\s+mod\s+(?:download|tidy|vendor)\b | go\s+(?:build|test)\b | composer\s+install\b | pipenv\s+(?:install|sync)\s*$ |
419
+ pipenv\s+sync\b)""")
420
+
421
+
422
+ # Commands that only read or print text: "grep requests.get" or "echo curl" mention the network, they
423
+ # don't use it. Pipes and ; already split segments, so "grep x | curl ..." is judged per part.
424
+ _TEXT_ONLY = re.compile(r"(?i)^(?:grep|egrep|fgrep|rg|ag|ack|cat|bat|head|tail|less|more|wc|sort|uniq|cut|ls|dir|"
425
+ r"echo|printf|type|file|stat|sed\s+-n|awk\s+'[^']*'\s+[\w./-]+$|"
426
+ r"git\s+(?:log|grep|diff|show|status|blame))\b")
427
+ _SUBST = re.compile(r"\$\(|`|<\(|>\(")
428
+ # Anything a shell could rewrite: quotes, backslash escapes. Without them the shell removes
429
+ # nothing, so the whitespace split answers the segment at C speed and the reader is not needed.
430
+ _NEEDS_SHELL = re.compile(r"""['"\\]""")
431
+ _WHITESPACE_SPLIT = re.compile(r"[ \t\r\n]+")
432
+ _SHELL_SPECIAL = re.compile(r"""[ \t\r\n'"\\]""")
433
+ _SHELL_IN_QUOTES = re.compile(r"""["\\]""")
434
+
435
+
436
+ def _shell_words(seg: str) -> list[str]:
437
+ """The segment's words after the shell removes quotes and escapes, read in one pass.
438
+
439
+ `shlex.split` is pure Python and builds each token a character at a time, which on a single
440
+ long token costs seconds per megabyte (2,000,000 characters took 274 s here). This implements
441
+ the same rules, so the answer is word for word the same: whitespace is space, tab, CR and LF;
442
+ single quotes are literal; inside double quotes a backslash escapes only `\\` and `"`; outside
443
+ quotes a backslash escapes the next character; adjacent pieces join into one word; `''` is an
444
+ empty word; an unclosed quote or a trailing backslash raises ValueError, as shlex does.
445
+
446
+ Raises ValueError on input shlex would refuse, with the same messages.
447
+ """
448
+ words: list[str] = []
449
+ buf: list[str] = []
450
+ have = False
451
+ i, n = 0, len(seg)
452
+ while i < n:
453
+ m = _SHELL_SPECIAL.search(seg, i)
454
+ if m is None:
455
+ buf.append(seg[i:])
456
+ have = True
457
+ break
458
+ j = m.start()
459
+ if j > i:
460
+ buf.append(seg[i:j])
461
+ have = True
462
+ ch = seg[j]
463
+ i = j + 1
464
+ if ch == "\\":
465
+ if i >= n:
466
+ raise ValueError("No escaped character")
467
+ buf.append(seg[i])
468
+ i += 1
469
+ have = True
470
+ elif ch == "'":
471
+ k = seg.find("'", i)
472
+ if k < 0:
473
+ raise ValueError("No closing quotation")
474
+ buf.append(seg[i:k])
475
+ i = k + 1
476
+ have = True
477
+ elif ch == '"':
478
+ while True:
479
+ m2 = _SHELL_IN_QUOTES.search(seg, i)
480
+ if m2 is None:
481
+ raise ValueError("No closing quotation")
482
+ k = m2.start()
483
+ if k > i:
484
+ buf.append(seg[i:k])
485
+ have = True
486
+ if seg[k] == '"':
487
+ i = k + 1
488
+ break
489
+ if k + 1 >= n:
490
+ raise ValueError("No escaped character")
491
+ nxt = seg[k + 1]
492
+ if nxt in '\\"':
493
+ buf.append(nxt)
494
+ i = k + 2
495
+ else:
496
+ buf.append("\\")
497
+ i = k + 1
498
+ else:
499
+ if have:
500
+ words.append("".join(buf))
501
+ buf = []
502
+ have = False
503
+ if have:
504
+ words.append("".join(buf))
505
+ return words
506
+
507
+
508
+ def _words(seg: str) -> list[str] | None:
509
+ """The segment's words after the shell removes quotes and escapes. None if it can't be split.
510
+
511
+ shlex is pure Python and builds its tokens one character at a time, so it costs seconds on a
512
+ megabyte-sized segment; `_shell_words` reads the same words in one pass. Where there is no
513
+ quote and no backslash there is nothing to remove, so the whitespace split answers it at C
514
+ speed without walking the segment in Python at all.
515
+ """
516
+ if _NEEDS_SHELL.search(seg) is None:
517
+ return [w for w in _WHITESPACE_SPLIT.split(seg) if w]
518
+ try:
519
+ return _shell_words(seg)
520
+ except ValueError:
521
+ return None
522
+
523
+
524
+ def _local_install(seg: str, words: list[str] | None) -> bool:
525
+ """Installs from a lockfile, manifest or local path: the destination is the configured registry and
526
+ the packages are already declared in the repo, so there's nothing new for a human to judge."""
527
+ if _LOCAL_ONLY.search(seg): # the caller checks this before splitting; kept so the answer stands alone
528
+ return True
529
+ if words is None:
530
+ return False
531
+ toks = list(words)
532
+ while toks and toks[0] in ("sudo", "python", "python3", "py", "-m"):
533
+ toks = toks[1:]
534
+ if not toks:
535
+ return False
536
+ tool = toks[0].lower()
537
+ if tool in ("open", "start", "xdg-open", "explorer") and len(toks) >= 2:
538
+ # Opening a local file or app. A URL or a bare host is handled (and checked) as a link.
539
+ arg = toks[-1]
540
+ return ("://" not in arg and not _INDIRECT_RE.search(seg)
541
+ and (_NOT_A_HOST.search(arg) is not None or not _plausible_host(arg.split("/")[0])))
542
+ if tool == "uv" and len(toks) > 1 and toks[1] == "pip":
543
+ tool, toks = "pip", toks[1:]
544
+ args = toks[1:]
545
+ if tool in ("pip", "pip3"):
546
+ if not args or args[0] not in ("install", "sync"):
547
+ return False
548
+ it = iter(args[1:])
549
+ for a in it:
550
+ if a in _PIP_VALUE_FLAGS:
551
+ v = next(it, "")
552
+ if a in ("-r", "--requirement", "-c", "--constraint", "-e", "--editable") and "://" not in v \
553
+ and not _PATHISH.search(v):
554
+ return False
555
+ continue
556
+ if a.startswith("-"):
557
+ continue
558
+ if "://" in a or not _PATHISH.search(a):
559
+ return False # a named package (or a URL, which is checked on its own)
560
+ return True
561
+ if tool in ("npm", "pnpm", "yarn", "bun"):
562
+ if not args:
563
+ return tool == "yarn"
564
+ if args[0] not in ("install", "i", "ci"):
565
+ return False
566
+ return all(a.startswith("-") for a in args[1:])
567
+ return False
568
+
569
+
570
+ def _is_local(url: str) -> bool:
571
+ import ipaddress
572
+
573
+ h = parse(url).host.strip("[]")
574
+ if h in _LOCAL_HOSTS:
575
+ return True
576
+ try:
577
+ ip = ipaddress.ip_address(h)
578
+ except ValueError:
579
+ return False
580
+ return ip.is_loopback or ip.is_unspecified
581
+
582
+
583
+ _WORD_URL = re.compile(r"(?i)^(?:--?[\w-]+=)?((?:https?|wss?|ftp)://.+)$")
584
+ _DEV_SOCKET = re.compile(r"/dev/(?:tcp|udp)/([^/\s]+)/\d+")
585
+ _RUNTIME_PART = re.compile(r"[$`]|%\w+%")
586
+
587
+
588
+ def _shell_destinations(seg: str, words: list[str] | None) -> tuple[list[str], bool]:
589
+ """Destinations a shell command names, read from its words after the shell removes quotes and
590
+ escapes, so a quoted '@' or ')' can't cut the link short. (urls, could_not_split)."""
591
+ if words is None:
592
+ return [], True
593
+ out = []
594
+ for w in words:
595
+ if (m := _WORD_URL.match(w)) and not _RUNTIME_PART.search(m.group(1)):
596
+ out.append(m.group(1)) # the whole word is one destination
597
+ out += [f"https://{h}" for h in _DEV_SOCKET.findall(w) if not _RUNTIME_PART.search(h)]
598
+ return out, False
599
+
600
+
601
+ def _segments(code: str) -> list[str]:
602
+ return [s.strip() for s in _SEGMENT_SPLIT.split(code) if s.strip()]
603
+
604
+
605
+ # Code that is decoded or downloaded and then run: scamgate can't read what it does, so a human
606
+ # approves it. Either half alone (decoding a file, running a script) is ordinary.
607
+ _DECODE = r"""(?:(?:base(?:32|64)|b32|b64)\s+(?:-d|-D|--decode)\b|openssl\s+(?:base64|enc)\b[^|;&]*\s-d\b|xxd\s+-r|\b(?:b(?:64|32|16|85)decode|decode_base64|decode64)\b|\ba85decode|\bfromhex\b|\bunhexlify|codecs\.decode|\bdecompress\(|rot13|\batob\(|Buffer\.from\([^)]*['"](?:base64|hex)['"]|FromBase64String|\bcurl\b|\bwget\b|\binvoke-webrequest\b|\biwr\b|\birm\b|downloadstring)"""
608
+ _RUNNER = r"(?:ba|z|k|da|fi|c|tc)?sh|python[\d.]*|py|perl|ruby|node|php|pwsh|powershell|iex|invoke-expression"
609
+ _DECODE_RUN = re.compile(
610
+ rf"""(?ix)
611
+ {_DECODE}[^\n;&]*\|\s*(?:sudo\s+)?(?:{_RUNNER})\b(?!\s+-?\w*\.\w) # ... | base64 -d | bash
612
+ | (?:(?:{_RUNNER})\s+(?:-c\s+)?|\beval\s+|\bsource\s+|\.\s+)["']?(?:\$\(|<\(|`)[^\n]*{_DECODE} # bash -c "$(... -d)"
613
+ | \b(?:exec|eval|compile|system|os\.system)\s*\([^\n]*{_DECODE} # exec(b64decode(..))
614
+ | new\s+Function\s*\([^\n]*{_DECODE}
615
+ | (?:iex|invoke-expression)\b[^\n]*{_DECODE}
616
+ | {_DECODE}[^\n]*\|\s*(?:iex|invoke-expression)\b
617
+ | (?:pwsh|powershell)(?:\.exe)?\b[^\n]*\s-(?:e|ec|enc|encodedcommand)\s+[A-Za-z0-9+/=]{{8,}}
618
+ | {_DECODE}[^\n;&]*>>?\s*\S+\s*(?:&&|;)\s*(?:{_RUNNER})\b
619
+ """)
620
+ def _decoded_write_then_run(code: str) -> bool:
621
+ """Decoded bytes written to a file and a shell execution in the same command."""
622
+ import re
623
+ return all(re.search(part, code, re.I | re.S) for part in
624
+ (_DECODE, r"write_bytes|write_text", r"os\s*\.\s*system|subprocess"))
625
+
626
+
627
+ def _scan_code(code: str, g: Gate) -> None:
628
+ """URLs, bare hosts after network commands, and opaque network steps, judged per segment."""
629
+ code = _repair_schemes(code)
630
+ if m := _DECODE_RUN.search(code):
631
+ g.opaque(f"code that is decoded or downloaded and then run ('{m.group(0).strip()[:50]}')")
632
+ elif _decoded_write_then_run(code):
633
+ g.opaque("decoded content written to a file and then run")
634
+ g.urls += _URL_RE.findall(code)
635
+ if m := _DYNAMIC_URL_RE.search(code):
636
+ g.opaque(f"a link built at run time ({m.group(0).strip()[:40]})")
637
+ is_program = any(k in code for k in ("import ", "def ", "const ", "await ", "=>", "require("))
638
+ has_literal = bool(_URL_RE.search(code))
639
+ for seg in _segments(code):
640
+ # The cheap questions first, in the order that costs least: a comment, a segment with no
641
+ # network program in it, or a local install is answered without splitting the segment into
642
+ # words, and splitting a multi-megabyte segment for nothing is the slow path this avoids.
643
+ if (seg.lstrip().startswith(("#", "//")) or not _NET_RE.search(seg)
644
+ or _LOCAL_ONLY.search(seg)):
645
+ continue
646
+ words = _words(seg)
647
+ if _local_install(seg, words) or (_TEXT_ONLY.match(seg) and not _SUBST.search(seg)):
648
+ continue
649
+ if not is_program: # program source is read by the literal/run-time checks, not as shell words
650
+ dests, unsplittable = _shell_destinations(seg, words)
651
+ if unsplittable:
652
+ g.opaque(f"'{seg[:60]}'")
653
+ continue
654
+ g.urls += dests
655
+ else:
656
+ dests = []
657
+ seg_urls = _URL_RE.findall(seg) or dests
658
+ bare = _bare_hosts(seg) if not seg_urls else []
659
+ if bare == ["local-transfer"]:
660
+ continue
661
+ g.urls += bare
662
+ if bare and all(_is_local(u) for u in bare):
663
+ continue
664
+ indirect = bool(_INDIRECT_RE.search(seg))
665
+ if seg_urls and not indirect:
666
+ continue
667
+ if bare and not indirect and not _code_like(seg):
668
+ continue # a shell command naming its host; in code, only a literal URL counts as readable
669
+ if is_program and has_literal and not indirect:
670
+ continue # url = "https://..." on one line, requests.get(url) on the next
671
+ g.opaque(f"'{seg[:60]}'")
672
+
673
+
674
+ def extract(tool: str, tool_input: dict) -> Gate:
675
+ t = _canon_tool(tool)
676
+ g = Gate(tool=tool)
677
+ everything = _strings(tool_input) # also enforces MAX_DEPTH for every tool
678
+ for k in REASON_KEYS:
679
+ if isinstance(tool_input.get(k), str):
680
+ g.stated_reason = tool_input[k]
681
+ break
682
+ if t in QUIET_TOOLS or t in WRITE_TOOLS:
683
+ return g
684
+ netty = t in URL_TOOLS or bool(NET_TOOL_HINT.search(t))
685
+ if t in URL_TOOLS or any(k in tool_input for k in URL_KEYS):
686
+ for k in URL_KEYS:
687
+ for s in _strings(tool_input.get(k)):
688
+ s = _repair_schemes(s)
689
+ found = _URL_RE.findall(s)
690
+ if found:
691
+ g.urls += found
692
+ if _DYNAMIC_URL_RE.search(s):
693
+ g.opaque("a link built at run time")
694
+ elif _looks_like_bare_host(s):
695
+ g.urls.append(s.strip())
696
+ elif s.strip() and netty:
697
+ g.opaque(f"a destination scamgate can't read ('{s.strip()[:40]}')")
698
+ if t in URL_TOOLS:
699
+ for s in everything:
700
+ g.urls += _URL_RE.findall(_repair_schemes(s))
701
+ if t in URL_TOOLS and not g.urls:
702
+ g.opaque("no readable link")
703
+ if t in CODE_TOOLS:
704
+ code = "\n".join(_strings({k: tool_input.get(k) for k in CODE_KEYS}))
705
+ if not g.stated_reason:
706
+ g.stated_reason = _first_comment(code)
707
+ _scan_code(code, g)
708
+ elif t not in URL_TOOLS:
709
+ # Unknown tool (often MCP): any link anywhere in its arguments is checked, and network-ish
710
+ # tools get their code-like arguments scanned like a shell command.
711
+ for s in everything:
712
+ g.urls += _URL_RE.findall(s)
713
+ if netty:
714
+ code = "\n".join(_strings({k: tool_input.get(k) for k in CODE_KEYS}))
715
+ if code:
716
+ _scan_code(code, g)
717
+ if any(_DYNAMIC_URL_RE.search(s) for s in everything):
718
+ g.opaque("a link built at run time")
719
+ seen, out = set(), []
720
+ for u in g.urls:
721
+ u = u.rstrip(".,;:'\"")
722
+ if u and u not in seen:
723
+ seen.add(u)
724
+ out.append(u)
725
+ g.urls = out
726
+ return g
727
+
728
+
729
+ def _tamper_reason(tool: str, tool_input: dict) -> str | None:
730
+ t = _canon_tool(tool)
731
+ roots = protected_roots()
732
+ if t in WRITE_TOOLS:
733
+ for k in PATH_KEYS:
734
+ v = tool_input.get(k)
735
+ if isinstance(v, str) and ((hit := _is_protected(v, roots)) or _is_protected_tail(v)):
736
+ return f"it would change scamgate's protected files ({hit or v})"
737
+ if t in CODE_TOOLS or (t not in QUIET_TOOLS and NET_TOOL_HINT.search(t)):
738
+ code = "\n".join(_strings({k: tool_input.get(k) for k in CODE_KEYS}))
739
+ if m := _TAMPER_CMD.search(code):
740
+ return f"the command would weaken or remove the scam gate ('{m.group(0).strip()}')"
741
+ # The read-only test runs only once a write or a feed call is suspected: most commands never need it.
742
+ if "scamgate" in code.lower() and (m := _FEED_POISON.search(code)) and not _read_only_shell(code):
743
+ return (f"the command would fill scamgate's phishing lists with data it chose ('{m.group(0).strip()}'). "
744
+ "To refresh the lists, use the scamgate_update_feeds tool or /scamgate update")
745
+ if (hit := _mentions_protected(code, roots)) and _SHELL_WRITE.search(_HARMLESS_REDIRECT.sub(" ", code)) \
746
+ and not _read_only_shell(code):
747
+ return f"the command would change scamgate's protected files ({hit})"
748
+ return None
749
+
750
+
751
+ # Redirects that write nowhere: `2>/dev/null`, `&>/dev/null`, `2>&1`, `>nul`.
752
+ _HARMLESS_REDIRECT = re.compile(r"(?i)(?:\d|&)?>>?\s*(?:/dev/null|nul)(?![\w./\\-])|\d?>&\d")
753
+ # Commands that only read or print. Writers with a flag that writes (`sort -o`, `tree -o`), and
754
+ # commands that run other commands (`xargs`, `env`, `sudo`), are deliberately missing.
755
+ _READERS = {"ls", "dir", "cat", "head", "tail", "find", "grep", "egrep", "fgrep", "rg", "stat", "which",
756
+ "wc", "du", "cd", "pwd", "realpath", "readlink", "sha256sum", "sha1sum", "md5sum", "echo", "type"}
757
+ # Anything that writes or runs something else, even inside a reader: redirects, substitutions,
758
+ # find's -exec/-delete/-fprint, ripgrep's --pre.
759
+ _NOT_READ_ONLY = re.compile(r"""(?ix)[<>`] | \$\( | \s-(?:exec|execdir|ok|okdir|delete|fprint0?|fprintf|fls)\b
760
+ | \s--pre(?:-glob)?\b""")
761
+ _READ_SPLIT = re.compile(r"\n|;|&&|\|\||\||&")
762
+ # Feed-cache writes with arguments an agent picked (what to fetch, from where, into which folder).
763
+ # A plain refresh goes through the scamgate_update_feeds tool, which takes no arguments.
764
+ _FEED_POISON = re.compile(r"\bupdate_feeds\s*\(\s*[^)\s]|\b_atomic_write\s*\(|\b_download\s*=(?!=)|"
765
+ r"\bFEEDS\s*\[[^\]\n]{0,200}\]\s*=(?!=)|\bFEEDS\.(?:update|setdefault|pop|clear)\b")
766
+
767
+
768
+ def _read_only_shell(code: str) -> bool:
769
+ """True for a shell command that only reads (ls, cat, grep, find, ...) and writes nowhere.
770
+
771
+ Only each segment's first word is read, by a plain whitespace split: no shlex, so a
772
+ multi-megabyte command costs a few regex passes. A command word that is quoted, escaped or
773
+ prefixed (`'rm'`, `\\rm`, `X=1 cat`) is not a known reader, so it falls back to the write check.
774
+ """
775
+ text = _HARMLESS_REDIRECT.sub(" ", code)
776
+ if _NOT_READ_ONLY.search(text):
777
+ return False
778
+ seen = False
779
+ for seg in _READ_SPLIT.split(text):
780
+ first = seg.split(None, 1)
781
+ if not first:
782
+ continue
783
+ seen = True
784
+ cmd = re.split(r"[/\\]", first[0])[-1].lower()
785
+ if cmd.endswith(".exe"):
786
+ cmd = cmd[:-4]
787
+ if cmd not in _READERS:
788
+ return False
789
+ return seen
790
+
791
+
792
+ # ------------------------------------------------------------------ decision
793
+
794
+ @dataclass
795
+ class Result:
796
+ decision: str # "none" | "approve" | "block"
797
+ message: str = ""
798
+ rule_key: str | None = None
799
+ rules: list[str] = field(default_factory=list)
800
+ score: int = 0
801
+ hosts: list[str] = field(default_factory=list)
802
+
803
+
804
+ _FEEDS: Any = None
805
+
806
+
807
+ def _feeds() -> Any:
808
+ """One lazy feed index per process: the hook is short-lived, so skip building big dicts."""
809
+ global _FEEDS
810
+ if _FEEDS is None:
811
+ from .feeds import LazyFeedIndex
812
+
813
+ _FEEDS = LazyFeedIndex()
814
+ return _FEEDS
815
+
816
+
817
+ def _feed_status() -> str | None:
818
+ """None when the feeds are fine, else a short warning for the human."""
819
+ from .feeds import REFRESH_HINT
820
+
821
+ f = _feeds()
822
+ if not f:
823
+ return ("No known-bad-link feeds are cached, so scamgate is checking without its phishing lists. "
824
+ + REFRESH_HINT)
825
+ if stale := f.stale_feeds(hours=STALE_FOR_HOOK_HOURS):
826
+ return f"scamgate's phishing lists are out of date ({', '.join(sorted(stale))}). " + REFRESH_HINT
827
+ return None
828
+
829
+
830
+ def _feeds_policy() -> str:
831
+ return (os.environ.get("SCAMGATE_NO_FEEDS") or "ask").strip().lower()
832
+
833
+
834
+ def _key(*parts: str) -> str:
835
+ return hashlib.sha256("\0".join(parts).encode()).hexdigest()[:10]
836
+
837
+
838
+ def _check(url: str, reason: str) -> Any:
839
+ """Score a URL. The agent's stated reason may add risk, never remove it."""
840
+ base = engine.check_url(url, None, feeds=_feeds())
841
+ if not reason:
842
+ return base
843
+ with_reason = engine.check_url(url, reason, feeds=_feeds())
844
+ return with_reason if with_reason.risk_score > base.risk_score else base
845
+
846
+
847
+ def _evaluate(g: Gate) -> Result:
848
+ rank = {Decision.allow: 0, Decision.ask: 1, Decision.deny: 2}
849
+ worst, worst_v, worst_url = Decision.allow, None, None
850
+ hosts, rules = [], []
851
+ for u in g.urls:
852
+ v = _check(u, g.stated_reason)
853
+ h = parse(u).host
854
+ if h:
855
+ hosts.append(h)
856
+ if v.decision is not Decision.allow:
857
+ rules += [r.rule_id for r in v.reasons]
858
+ if rank[v.decision] > rank[worst] or (worst_v and v.decision is worst
859
+ and v.risk_score > worst_v.risk_score):
860
+ worst, worst_v, worst_url = v.decision, v, u
861
+ if worst is Decision.deny:
862
+ h = parse(worst_url).host
863
+ return Result("block",
864
+ f"scamgate blocked this: {worst_v.summary} (score {worst_v.risk_score}/100, link host: {h}). "
865
+ "Don't retry it another way. Tell the user what was blocked and why.",
866
+ None, rules, worst_v.risk_score, hosts)
867
+ if len(g.urls) > MAX_URLS:
868
+ return Result("approve", f"scamgate: this call opens {len(g.urls)} links at once. None is on a known-bad "
869
+ "list, but that's too many to review one by one. Approve once, only if you expect this.",
870
+ f"scamgate:bulk:{_key(*sorted(g.urls))}", ["hook.bulk"], 0, hosts)
871
+ if worst is Decision.ask:
872
+ h = parse(worst_url).host or f"unreadable-{_key(worst_url)}" # one "always" never covers other links
873
+ fired = sorted({r.rule_id for r in worst_v.reasons})
874
+ return Result("approve", f"scamgate: {worst_v.summary} (score {worst_v.risk_score}/100). "
875
+ "Approve only if you trust where this link goes.",
876
+ f"scamgate:{h}:{_key(*fired)}", rules, worst_v.risk_score, hosts)
877
+ if g.opaque_network:
878
+ what = f" ({g.opaque_detail})" if g.opaque_detail else ""
879
+ return Result("approve", f"scamgate: this call can reach the network, but scamgate can't see where it "
880
+ f"goes{what}, so it can't check it. Approve once, only if you know the destination.",
881
+ f"scamgate:opaque:{_canon_tool(g.tool)}:{_key(g.opaque_detail, *sorted(g.urls))}",
882
+ ["hook.opaque_network"], 0, hosts)
883
+ remote = [u for u in g.urls if not _is_local(u)]
884
+ if remote and not _feeds() and _feeds_policy() == "ask":
885
+ return Result("approve", f"scamgate: {_feed_status()} Approve once, only if you trust these links.",
886
+ f"scamgate:nofeeds:{_key(*sorted(hosts))}", ["hook.no_feeds"], 0, hosts)
887
+ return Result("none", hosts=hosts)
888
+
889
+
890
+ _RANK = {"none": 0, "approve": 1, "block": 2}
891
+ _PAYMENT_HELP = ("Before paying, confirm the payee and account through a channel you already trust (not one "
892
+ "from this conversation). After a real payment, run `scamgate record-payment` yourself so "
893
+ "next time it counts as a known payee.")
894
+
895
+
896
+ def _payment_result(tool: str, tool_input: dict) -> Result | None:
897
+ """Payments: score money-moving tool calls; payments hidden in shell/code go to a human."""
898
+ from . import paygate
899
+ from .models import PaymentCheck
900
+
901
+ t = _canon_tool(tool)
902
+ if t in QUIET_TOOLS or t in WRITE_TOOLS:
903
+ return None
904
+ if t in CODE_TOOLS or NET_TOOL_HINT.search(t):
905
+ code = "\n".join(_strings({k: tool_input.get(k) for k in CODE_KEYS}))
906
+ if code and (cp := paygate.scan_code(code)):
907
+ return Result("approve", f"scamgate: this call makes a payment ({cp.detail}), and scamgate can't read "
908
+ f"who gets paid or how much. Approve once, only if you started this payment. "
909
+ + _PAYMENT_HELP,
910
+ f"scamgate:paycode:{_canon_tool(tool)}:{_key(code)}", ["hook.payment_in_code"], 0, cp.hosts)
911
+ p = paygate.detect(tool, t, tool_input)
912
+ if p is None:
913
+ return None
914
+ if not p.payee_account or not p.amount_known:
915
+ missing = "who gets paid" if not p.payee_account else "how much"
916
+ return Result("approve", f"scamgate: '{tool}' looks like a payment (from {p.why}), but scamgate can't read "
917
+ f"{missing}, so it can't check it. Approve once, only if you started this payment. "
918
+ + _PAYMENT_HELP,
919
+ f"scamgate:payopaque:{t}:{_key(_canonical_json(tool_input))}", ["hook.payment_unreadable"])
920
+ v = engine.check_payment(PaymentCheck(payee_name=p.payee_name, payee_account=p.payee_account, amount=p.amount,
921
+ currency=p.currency or "?", method=p.method, context_text=p.context or None))
922
+ rules = [r.rule_id for r in v.reasons]
923
+ what = f"{p.amount:g} {p.currency}".strip() + f" to '{p.payee_name[:40]}'"
924
+ warn = (" " + " ".join(v.warnings)) if v.warnings else ""
925
+ if v.decision is Decision.deny:
926
+ return Result("block", f"scamgate blocked this payment ({what}): {v.summary} (score {v.risk_score}/100).{warn} "
927
+ "Don't retry it another way. Tell the user what was blocked and why.",
928
+ None, rules, v.risk_score)
929
+ if v.decision is Decision.ask:
930
+ return Result("approve", f"scamgate: check this payment ({what}): {v.summary} (score {v.risk_score}/100).{warn} "
931
+ + _PAYMENT_HELP,
932
+ f"scamgate:pay:{_key(p.payee_account.upper(), f'{p.amount:.2f}', *sorted(set(rules)))}",
933
+ rules, v.risk_score)
934
+ return None
935
+
936
+
937
+ def _canonical_json(v: Any) -> str:
938
+ return json.dumps(v, sort_keys=True, default=str)[:20000]
939
+
940
+
941
+ # ------------------------------------------------------------------ dialects
942
+
943
+ PRE_EVENTS = {"pre_tool_call": "hermes", "PreToolUse": "claude"}
944
+ POST_EVENTS = {"post_tool_call": "hermes", "PostToolUse": "claude", "PostToolUseFailure": "claude"}
945
+
946
+
947
+ def _call_ids(payload: dict) -> tuple[str, str]:
948
+ extra = payload.get("extra") or {}
949
+ session = str(payload.get("session_id") or extra.get("task_id") or "")
950
+ call_id = str(extra.get("tool_call_id") or payload.get("tool_use_id") or "")
951
+ return session, call_id
952
+
953
+
954
+ def _render(dialect: str, r: Result) -> dict:
955
+ if r.decision == "none":
956
+ return {}
957
+ if dialect == "claude":
958
+ return {"hookSpecificOutput": {"hookEventName": "PreToolUse",
959
+ "permissionDecision": "deny" if r.decision == "block" else "ask",
960
+ "permissionDecisionReason": r.message}}
961
+ out: dict = {"action": r.decision, "message": r.message}
962
+ if r.decision == "approve" and r.rule_key:
963
+ out["rule_key"] = r.rule_key
964
+ return out
965
+
966
+
967
+ def handle(payload: dict) -> dict:
968
+ event = payload.get("hook_event_name") or ""
969
+ tool = payload.get("tool_name") or ""
970
+ tool_input = payload.get("tool_input") or {}
971
+ if not isinstance(tool_input, dict):
972
+ tool_input = {"input": str(tool_input)}
973
+
974
+ if event in POST_EVENTS:
975
+ session, call_id = _call_ids(payload)
976
+ if call_id:
977
+ status = (payload.get("extra") or {}).get("status") or payload.get("status")
978
+ if POST_EVENTS[event] == "claude":
979
+ outcome = "ran" # PostToolUse and PostToolUseFailure both fire after the tool ran
980
+ else:
981
+ # Hermes model_tools.py: "ok" | "error" (ran, maybe failed) | "blocked" (hook or human said no)
982
+ outcome = "not_run" if status == "blocked" else "ran"
983
+ asklog.record_outcome(asklog.make_key(session, call_id), outcome)
984
+ return {}
985
+
986
+ if not event and tool:
987
+ event = "pre_tool_call" # a tool call with no event name: gate it rather than wave it through
988
+ if event not in PRE_EVENTS:
989
+ return {}
990
+ dialect = PRE_EVENTS[event]
991
+
992
+ ok, why = integrity.check()
993
+ if not ok:
994
+ return _render(dialect, Result("block", f"scamgate's installed files don't match their release ({why}). "
995
+ "Blocking all gated tools until it's fixed. Reinstall scamgate."))
996
+
997
+ from .harden import gate_extra, own_check
998
+
999
+ if own_check(tool):
1000
+ return {}
1001
+
1002
+ if reason := _tamper_reason(tool, tool_input):
1003
+ return _render(dialect, Result("block", f"scamgate blocked this because {reason}. "
1004
+ "Agents can't change the scam gate. Don't retry it another way; "
1005
+ "tell the user they must make this change themselves, outside "
1006
+ "the agent."))
1007
+
1008
+ extra = gate_extra(tool, tool_input)
1009
+ g = extract(tool, tool_input)
1010
+ pay = _payment_result(tool, tool_input)
1011
+ r = _evaluate(g) if (g.urls or g.opaque_network) else Result("none")
1012
+ if extra is not None and _RANK[extra.decision] > _RANK[r.decision]:
1013
+ r = extra
1014
+ if pay is not None and _RANK[pay.decision] >= _RANK[r.decision]: # on a tie the payment reason is clearer
1015
+ r = pay
1016
+ if r.decision == "none":
1017
+ return {}
1018
+ if r.decision != "none" and (warn := _feed_status()) and warn not in r.message:
1019
+ r.message = f"{r.message} Note: {warn}"
1020
+ if r.decision != "none":
1021
+ session, call_id = _call_ids(payload)
1022
+ asklog.log_check(tool=tool, decision=r.decision, risk_score=r.score, rule_ids=r.rules,
1023
+ targets=g.urls, hosts=r.hosts, stated_reason=g.stated_reason,
1024
+ session=session, call_id=call_id, agent=dialect)
1025
+ return _render(dialect, r)
1026
+
1027
+
1028
+ def run(payload: dict) -> tuple[str, int]:
1029
+ return json.dumps(handle(payload)), 0
1030
+
1031
+
1032
+ def main(stdin=None, stdout=None) -> int:
1033
+ stdin = stdin or sys.stdin
1034
+ stdout = stdout or sys.stdout
1035
+ raw = stdin.read(MAX_INPUT_BYTES + 1)
1036
+ payload: Any = None
1037
+ try:
1038
+ if len(raw) > MAX_INPUT_BYTES:
1039
+ raise ValueError(f"hook input too large (over {MAX_INPUT_BYTES // (1024 * 1024)} MB)")
1040
+ payload = json.loads(raw)
1041
+ if not isinstance(payload, dict):
1042
+ raise ValueError("hook payload must be a JSON object")
1043
+ text, code = json.dumps(handle(payload)), 0
1044
+ except Exception as exc: # fail closed on pre events (and on unreadable input)
1045
+ event = payload.get("hook_event_name") if isinstance(payload, dict) else None
1046
+ if event in POST_EVENTS or (event and event not in PRE_EVENTS):
1047
+ stdout.write("{}")
1048
+ return 0
1049
+ msg = f"scamgate hook error, failing closed: {type(exc).__name__}: {str(exc)[:200]}"
1050
+ dialect = PRE_EVENTS.get(event or "", "hermes")
1051
+ stdout.write(json.dumps(_render(dialect, Result("block", msg))))
1052
+ print(msg, file=sys.stderr)
1053
+ return 2
1054
+ stdout.write(text)
1055
+ return code