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/__init__.py +11 -0
- scamgate/asklog.py +153 -0
- scamgate/bench.py +77 -0
- scamgate/cli.py +209 -0
- scamgate/config/rules.yaml +218 -0
- scamgate/config.py +62 -0
- scamgate/data/blocklist.txt +9 -0
- scamgate/domainage.py +153 -0
- scamgate/engine.py +145 -0
- scamgate/entry.py +19 -0
- scamgate/feeds.py +377 -0
- scamgate/guard.py +116 -0
- scamgate/harden.py +393 -0
- scamgate/hook.py +1055 -0
- scamgate/hook_entry.py +15 -0
- scamgate/hostnorm.py +60 -0
- scamgate/integrity.py +85 -0
- scamgate/models.py +77 -0
- scamgate/overrides.py +90 -0
- scamgate/payees.py +141 -0
- scamgate/paygate.py +228 -0
- scamgate/plugin.py +222 -0
- scamgate/plugin.yaml +24 -0
- scamgate/redact.py +93 -0
- scamgate/refresh.py +233 -0
- scamgate/registry.py +66 -0
- scamgate/rules/__init__.py +1 -0
- scamgate/rules/payment.py +127 -0
- scamgate/rules/url.py +446 -0
- scamgate/server.py +75 -0
- scamgate/state.py +32 -0
- scamgate/tools.py +94 -0
- scamgate-0.3.3.dist-info/METADATA +544 -0
- scamgate-0.3.3.dist-info/RECORD +37 -0
- scamgate-0.3.3.dist-info/WHEEL +4 -0
- scamgate-0.3.3.dist-info/entry_points.txt +3 -0
- scamgate-0.3.3.dist-info/licenses/LICENSE +21 -0
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
|