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/paygate.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Payment gating for the hook: money-moving tool calls get the same check as `check_payment`.
|
|
2
|
+
|
|
3
|
+
Without this, `check_payment` is voluntary: an injected agent can pay through a wallet, banking or
|
|
4
|
+
payments MCP tool, or a payments API call in a shell, without ever asking scamgate. The hook now:
|
|
5
|
+
|
|
6
|
+
1. Spots a payment in a tool call: a tool whose name says it moves money (pay, payout, transfer,
|
|
7
|
+
withdraw, send_crypto...), or any tool whose arguments carry an amount AND a destination
|
|
8
|
+
account (IBAN, wallet address, account number).
|
|
9
|
+
2. Pulls out payee, account, amount, currency and method from the arguments, and runs the normal
|
|
10
|
+
payment rules with local payee history (the agent's claims about the payee are ignored).
|
|
11
|
+
3. Spots payments in shell/code: crypto CLIs (`cast send`, `bitcoin-cli sendtoaddress`,
|
|
12
|
+
`solana transfer`...) and write calls to payment APIs (Stripe, PayPal, Wise, Revolut Business...).
|
|
13
|
+
Those can't be scored, so a human approves them.
|
|
14
|
+
|
|
15
|
+
Like the rest of the hook it never says "allow": a low-risk payment passes silently.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import math
|
|
21
|
+
import re
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------- tool names
|
|
26
|
+
|
|
27
|
+
_STRONG = re.compile(r"(?:^|_)(?:pay|pays|payment|payments|payout|payouts|send_?money|send_?payment|withdraw|"
|
|
28
|
+
r"withdrawal|wire|remit|remittance|disburse|sepa|send_?(?:eth|btc|sol|crypto|coin|coins|token|"
|
|
29
|
+
r"tokens|usdc|usdt|funds|transaction|tx|native)|transfer_?(?:funds|money|eth|btc|sol|token|tokens|"
|
|
30
|
+
r"crypto|usdc|usdt|native)|sign_?(?:and_?send_?)?transaction|capture_?(?:order|payment)|"
|
|
31
|
+
r"create_?charge|charge_?card|tip)(?:_|$)")
|
|
32
|
+
_WEAK = re.compile(r"(?:^|_)(?:transfer|send|order|buy|purchase|checkout|charge|swap|bridge|deposit|"
|
|
33
|
+
r"top_?up|refund|invoice)(?:_|$)")
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------- argument keys
|
|
36
|
+
|
|
37
|
+
_AMOUNT_KEYS = ("amount", "amount_value", "value", "sum", "total", "total_amount", "price", "amount_cents",
|
|
38
|
+
"amount_minor", "amount_in_cents", "unit_amount", "quantity_usd")
|
|
39
|
+
_MINOR_KEYS = {"amount_cents", "amount_minor", "amount_in_cents", "unit_amount"}
|
|
40
|
+
_CURRENCY_KEYS = ("currency", "ccy", "currency_code", "asset", "token", "coin", "symbol", "denom")
|
|
41
|
+
_ACCOUNT_KEYS = ("iban", "payee_account", "beneficiary_account", "creditor_account", "account_number",
|
|
42
|
+
"account", "to_address", "recipient_address", "destination_address", "wallet_address",
|
|
43
|
+
"receiver_address", "address", "destination", "to", "recipient", "receiver", "payee",
|
|
44
|
+
"beneficiary", "wallet", "sort_code", "routing_number", "card_number", "merchant_id", "pay_to")
|
|
45
|
+
_NAME_KEYS = ("payee_name", "beneficiary_name", "recipient_name", "creditor_name", "account_holder",
|
|
46
|
+
"account_name", "holder_name", "merchant_name", "to_name", "name", "payee", "beneficiary",
|
|
47
|
+
"recipient", "merchant", "counterparty")
|
|
48
|
+
_CONTEXT_KEYS = ("reference", "memo", "description", "note", "message", "purpose", "remittance_information",
|
|
49
|
+
"statement_descriptor", "narrative", "comment", "details", "reason")
|
|
50
|
+
_ACCOUNT_RE = re.compile(r"""(?ix)^(?:
|
|
51
|
+
[A-Z]{2}\d{2}[A-Z0-9 ]{10,32} # IBAN
|
|
52
|
+
| 0x[0-9a-f]{40} # EVM address
|
|
53
|
+
| (?:bc1|[13])[a-z0-9]{25,62} # bitcoin
|
|
54
|
+
| [1-9A-HJ-NP-Za-km-z]{32,44} # base58 (Solana etc.)
|
|
55
|
+
| \d{6,18} # account number
|
|
56
|
+
| (?:acct|ba|card|cus|pm|src)_[A-Za-z0-9]{6,} # Stripe-style ids
|
|
57
|
+
| [\w.+-]+@[\w-]+(?:\.[\w-]+)+ # email (PayPal payouts)
|
|
58
|
+
| \$[A-Za-z][\w-]{1,30} # cashtag
|
|
59
|
+
)$""")
|
|
60
|
+
_CRYPTO = {"btc", "eth", "usdt", "usdc", "sol", "bnb", "xrp", "doge", "ada", "trx", "matic", "pol", "dai",
|
|
61
|
+
"ltc", "avax", "ton", "xmr", "arb", "op", "wbtc", "weth", "shib", "link", "dot", "atom"}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class Payment:
|
|
66
|
+
payee_name: str = ""
|
|
67
|
+
payee_account: str = ""
|
|
68
|
+
amount: float = 0.0
|
|
69
|
+
amount_known: bool = False
|
|
70
|
+
currency: str = ""
|
|
71
|
+
method: str = "other"
|
|
72
|
+
context: str = ""
|
|
73
|
+
why: str = ""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class CodePayment:
|
|
78
|
+
detail: str
|
|
79
|
+
hosts: list[str] = field(default_factory=list)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _flat(value: Any, out: dict[str, Any], depth: int = 0) -> dict[str, Any]:
|
|
83
|
+
"""First value seen for each lowercased key, anywhere in the arguments."""
|
|
84
|
+
if depth > 12:
|
|
85
|
+
return out
|
|
86
|
+
if isinstance(value, dict):
|
|
87
|
+
for k, v in value.items():
|
|
88
|
+
key = str(k).lower().replace("-", "_")
|
|
89
|
+
if not isinstance(v, (dict, list)) and key not in out and v not in (None, ""):
|
|
90
|
+
out[key] = v
|
|
91
|
+
for v in value.values():
|
|
92
|
+
if isinstance(v, (dict, list)):
|
|
93
|
+
_flat(v, out, depth + 1)
|
|
94
|
+
elif isinstance(value, list):
|
|
95
|
+
for v in value[:20]:
|
|
96
|
+
_flat(v, out, depth + 1)
|
|
97
|
+
return out
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _first(flat: dict[str, Any], keys: tuple[str, ...]) -> tuple[str, Any]:
|
|
101
|
+
for k in keys:
|
|
102
|
+
if k in flat:
|
|
103
|
+
return k, flat[k]
|
|
104
|
+
return "", None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _num(v: Any) -> float | None:
|
|
108
|
+
if isinstance(v, bool):
|
|
109
|
+
return None
|
|
110
|
+
if isinstance(v, (int, float)):
|
|
111
|
+
return float(v) if math.isfinite(v) else None # JSON "Infinity"/"NaN" isn't an amount scamgate can read
|
|
112
|
+
if isinstance(v, str):
|
|
113
|
+
s = re.sub(r"[^\d.,-]", "", v)
|
|
114
|
+
if s.count(",") and s.count("."):
|
|
115
|
+
s = s.replace(",", "")
|
|
116
|
+
elif s.count(",") == 1 and len(s.split(",")[1]) in (1, 2):
|
|
117
|
+
s = s.replace(",", ".")
|
|
118
|
+
else:
|
|
119
|
+
s = s.replace(",", "")
|
|
120
|
+
try:
|
|
121
|
+
n = float(s)
|
|
122
|
+
except ValueError:
|
|
123
|
+
return None
|
|
124
|
+
return n if math.isfinite(n) else None # a 309-digit string is inf too
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _looks_like_account(v: Any) -> bool:
|
|
129
|
+
return isinstance(v, str) and bool(_ACCOUNT_RE.match(v.strip()))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _method(tool: str, account: str, currency: str) -> str:
|
|
133
|
+
t, a, c = tool.lower(), account.strip(), currency.lower()
|
|
134
|
+
if c in _CRYPTO or re.match(r"^(?:0x[0-9a-fA-F]{40}|bc1|[13][a-km-zA-HJ-NP-Z1-9]{25,34}$)", a) \
|
|
135
|
+
or re.search(r"crypto|wallet|eth|btc|sol|token|coin|swap|bridge|onchain|web3", t):
|
|
136
|
+
return "crypto"
|
|
137
|
+
if "gift" in t:
|
|
138
|
+
return "gift_card"
|
|
139
|
+
if re.match(r"^[A-Z]{2}\d{2}", a.replace(" ", "")) or re.search(r"transfer|wire|sepa|payout|iban|bank|remit", t):
|
|
140
|
+
return "bank_transfer"
|
|
141
|
+
if re.search(r"card|checkout|charge|purchase|buy|order", t) or a.startswith(("card_", "pm_")):
|
|
142
|
+
return "card"
|
|
143
|
+
return "other"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def detect(tool: str, canon: str, tool_input: dict) -> Payment | None:
|
|
147
|
+
"""A Payment if this tool call moves money, else None."""
|
|
148
|
+
flat = _flat(tool_input, {})
|
|
149
|
+
akey, amount = _first(flat, _AMOUNT_KEYS)
|
|
150
|
+
amt = _num(amount)
|
|
151
|
+
if amt is not None and akey in _MINOR_KEYS:
|
|
152
|
+
amt = amt / 100
|
|
153
|
+
acct_key, account = next(((k, flat[k]) for k in _ACCOUNT_KEYS if k in flat and _looks_like_account(flat[k])),
|
|
154
|
+
("", None))
|
|
155
|
+
strong = bool(_STRONG.search(canon))
|
|
156
|
+
weak = bool(_WEAK.search(canon))
|
|
157
|
+
money_args = amt is not None and account is not None
|
|
158
|
+
if not (strong or money_args or (weak and amt is not None and (account is not None or
|
|
159
|
+
_first(flat, _CURRENCY_KEYS)[1]))):
|
|
160
|
+
return None
|
|
161
|
+
_, name = _first(flat, _NAME_KEYS)
|
|
162
|
+
if name is not None and (not isinstance(name, str) or name == account):
|
|
163
|
+
name = None
|
|
164
|
+
_, cur = _first(flat, _CURRENCY_KEYS)
|
|
165
|
+
ctx = " ".join(str(flat[k]) for k in _CONTEXT_KEYS if k in flat and isinstance(flat[k], str))[:2000]
|
|
166
|
+
acct = str(account).strip() if account is not None else ""
|
|
167
|
+
return Payment(
|
|
168
|
+
payee_name=(str(name).strip() if name else acct)[:200],
|
|
169
|
+
payee_account=acct[:200],
|
|
170
|
+
amount=max(amt or 0.0, 0.0),
|
|
171
|
+
amount_known=amt is not None,
|
|
172
|
+
currency=(str(cur).upper()[:12] if cur else ""),
|
|
173
|
+
method=_method(canon + " " + tool, acct, str(cur or "")),
|
|
174
|
+
context=ctx,
|
|
175
|
+
why="the tool name" if strong else "an amount and a destination account in the arguments",
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# ---------------------------------------------------------------- shell / code
|
|
180
|
+
|
|
181
|
+
_CRYPTO_CLI = re.compile(r"""(?ix)\b(?:
|
|
182
|
+
cast\s+(?:send|publish)\b | bitcoin-cli\b[^\n]*\b(?:sendtoaddress|sendmany|sendrawtransaction|send|walletcreatefundedpsbt)\b
|
|
183
|
+
| electrum\b[^\n]*\b(?:payto|paytomany|broadcast)\b | solana\s+transfer\b | spl-token\s+transfer\b
|
|
184
|
+
| near\s+(?:send|tokens)\b | aptos\s+account\s+transfer\b | sui\s+client\s+(?:transfer|pay|pay-sui|pay-all-sui)\b
|
|
185
|
+
| lncli\s+(?:sendpayment|payinvoice|sendcoins)\b | lightning-cli\s+(?:pay|withdraw|keysend)\b
|
|
186
|
+
| eth_sendTransaction | eth_sendRawTransaction | sendRawTransaction\( | sendTransaction\( | signAndSendTransaction
|
|
187
|
+
| web3\.eth\.send | \.functions\.transfer\( | wallet\.send_?(?:transaction|coins)\(
|
|
188
|
+
)""")
|
|
189
|
+
_PAY_HOSTS = ("api.stripe.com", "api.paypal.com", "api-m.paypal.com", "api.wise.com", "api.transferwise.com",
|
|
190
|
+
"b2b.revolut.com", "api.revolut.com", "api.coinbase.com", "api.commerce.coinbase.com",
|
|
191
|
+
"api.gocardless.com", "adyenpayments.com", "api.adyen.com", "api.checkout.com", "api.mollie.com",
|
|
192
|
+
"connect.squareup.com", "api.squareup.com", "api.braintreegateway.com", "api.razorpay.com",
|
|
193
|
+
"api.paystack.co", "api.kraken.com", "api.binance.com", "api.circle.com", "api.moonpay.com",
|
|
194
|
+
"api.monzo.com", "api.starlingbank.com", "api.truelayer.com", "api.modulrfinance.com",
|
|
195
|
+
"api.currencycloud.com", "api.airwallex.com", "api.payoneer.com", "api.bitpay.com")
|
|
196
|
+
_PAY_PATH = re.compile(r"(?i)/(?:[\w-]*/)*(?:pay|payments?|payouts?|transfers?|charges?|payment_intents|"
|
|
197
|
+
r"checkout|orders?|withdraw\w*|send\w*|disburse\w*|refunds?|captures?|"
|
|
198
|
+
r"capital/withdraw|quotes?|batch_payments)\b")
|
|
199
|
+
_WRITE = re.compile(r"""(?ix)(?:\s-X\s*['"]?(?:POST|PUT|PATCH)\b|\s--request\s+['"]?(?:POST|PUT|PATCH)\b|\s-d[\s'"@]|
|
|
200
|
+
\s--data\b|\s--data-\w+\b|\s--json\b|\s-F\s|\.post\(|\.put\(|\.patch\(|
|
|
201
|
+
method\s*[:=]\s*['"](?:post|put|patch)['"]|request\(\s*['"](?:post|put|patch)['"]|
|
|
202
|
+
Invoke-(?:RestMethod|WebRequest)\b[^\n]*-Method\s+(?:Post|Put|Patch))""")
|
|
203
|
+
_HOST_IN = re.compile(r"(?i)https?://([a-z0-9.-]+)(/[^\s'\"`)]*)?")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
_SDK_PAY = re.compile(
|
|
207
|
+
r"(?i)\bstripe\.(?:Payout|PaymentIntent|Transfer|Charge|Refund|Subscription)\.create\b"
|
|
208
|
+
r"|\bpaypal\.Payout\b|\.payouts\(\)\.create\b"
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def scan_code(code: str) -> CodePayment | None:
|
|
213
|
+
if m := _CRYPTO_CLI.search(code):
|
|
214
|
+
return CodePayment(f"a crypto transaction ('{m.group(0).strip()[:40]}')")
|
|
215
|
+
if m := _SDK_PAY.search(code):
|
|
216
|
+
return CodePayment(f"a payment SDK call ('{m.group(0).strip()[:40]}')")
|
|
217
|
+
if not _WRITE.search(code):
|
|
218
|
+
return None
|
|
219
|
+
hosts = []
|
|
220
|
+
for m in _HOST_IN.finditer(code):
|
|
221
|
+
host, path = m.group(1).lower(), m.group(2) or ""
|
|
222
|
+
if "sandbox" in host or ".test" in host or "-test" in host:
|
|
223
|
+
continue
|
|
224
|
+
if any(host == h or host.endswith("." + h) for h in _PAY_HOSTS) and _PAY_PATH.search(path):
|
|
225
|
+
hosts.append(host)
|
|
226
|
+
if hosts:
|
|
227
|
+
return CodePayment(f"a payment API call to {hosts[0]}", hosts)
|
|
228
|
+
return None
|
scamgate/plugin.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Hermes plugin: the same gate as the shell hook, in-process.
|
|
2
|
+
|
|
3
|
+
Hermes calls `pre_tool_call` before every tool. This returns the hook's decision.
|
|
4
|
+
Under `/yolo` or `approvals.mode: off`, Hermes would auto-approve an ask, so an ask
|
|
5
|
+
becomes a block unless the user set `when_approvals_off: allow`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import threading
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from pydantic import BaseModel, ValidationError
|
|
17
|
+
|
|
18
|
+
from . import asklog, hook, refresh, tools
|
|
19
|
+
|
|
20
|
+
_CTX: Any = None
|
|
21
|
+
_log = logging.getLogger("scamgate")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def approvals_bypassed(session_id: str = "") -> bool:
|
|
25
|
+
"""True when this chat will not actually ask a human. Importing Hermes is optional."""
|
|
26
|
+
try:
|
|
27
|
+
from tools.approval import is_approval_bypass_active_for_session
|
|
28
|
+
except Exception:
|
|
29
|
+
return False
|
|
30
|
+
try:
|
|
31
|
+
return bool(is_approval_bypass_active_for_session(session_id or ""))
|
|
32
|
+
except Exception:
|
|
33
|
+
return False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def when_approvals_off() -> str:
|
|
37
|
+
val = None
|
|
38
|
+
if _CTX is not None:
|
|
39
|
+
try:
|
|
40
|
+
val = _CTX.get_config("when_approvals_off", None)
|
|
41
|
+
except Exception:
|
|
42
|
+
val = None
|
|
43
|
+
if not val:
|
|
44
|
+
val = os.environ.get("SCAMGATE_WHEN_APPROVALS_OFF", "block")
|
|
45
|
+
return str(val).strip().lower()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def on_pre_tool_call(tool_name: str = "", args: Any = None, session_id: str = "",
|
|
49
|
+
tool_call_id: str = "", task_id: str = "", **_: Any) -> dict | None:
|
|
50
|
+
try:
|
|
51
|
+
decision = hook.handle({
|
|
52
|
+
"hook_event_name": "pre_tool_call",
|
|
53
|
+
"tool_name": tool_name,
|
|
54
|
+
"tool_input": args if isinstance(args, dict) else {},
|
|
55
|
+
"session_id": session_id or task_id or "",
|
|
56
|
+
"extra": {"tool_call_id": tool_call_id or "", "task_id": task_id or ""},
|
|
57
|
+
})
|
|
58
|
+
except Exception as exc: # fail closed like the shell hook; don't rely on how Hermes treats a raising hook
|
|
59
|
+
return {"action": "block", "message": f"scamgate hook error, failing closed: {type(exc).__name__}: "
|
|
60
|
+
f"{str(exc)[:200]}"}
|
|
61
|
+
if not decision:
|
|
62
|
+
return None
|
|
63
|
+
if decision.get("action") == "approve" and approvals_bypassed(session_id) and when_approvals_off() != "allow":
|
|
64
|
+
message = decision.get("message") or "scamgate would have asked a human."
|
|
65
|
+
return {"action": "block", "message": message + " Approvals are off in this chat, so scamgate blocked it "
|
|
66
|
+
"instead of asking. Turn approvals back on, or set "
|
|
67
|
+
"plugins.entries.scamgate.settings.when_approvals_off to allow if you "
|
|
68
|
+
"accept that risk."}
|
|
69
|
+
return decision
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def on_post_tool_call(tool_name: str = "", session_id: str = "", tool_call_id: str = "",
|
|
73
|
+
task_id: str = "", status: str | None = None, **_: Any) -> None:
|
|
74
|
+
try:
|
|
75
|
+
hook.handle({
|
|
76
|
+
"hook_event_name": "post_tool_call",
|
|
77
|
+
"tool_name": tool_name,
|
|
78
|
+
"tool_input": {},
|
|
79
|
+
"session_id": session_id or task_id or "",
|
|
80
|
+
"extra": {"tool_call_id": tool_call_id or "", "task_id": task_id or "", "status": status},
|
|
81
|
+
})
|
|
82
|
+
except Exception:
|
|
83
|
+
pass # a post event only records whether you approved; it must never break the tool call
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _run_tool(fn: Any, model: type[BaseModel], args: Any) -> str:
|
|
88
|
+
"""Same arguments and checks as the MCP tool. Bad arguments come back as an error, never a crash."""
|
|
89
|
+
try:
|
|
90
|
+
parsed = model.model_validate(args if isinstance(args, dict) else {})
|
|
91
|
+
except ValidationError as exc:
|
|
92
|
+
problems = "; ".join(f"{'.'.join(map(str, e['loc'])) or 'arguments'}: {e['msg']}" for e in exc.errors())
|
|
93
|
+
return json.dumps({"error": f"scamgate couldn't read these arguments ({problems})."})
|
|
94
|
+
return json.dumps(fn(parsed).model_dump(mode="json"))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def check_url(args: dict, **_: Any) -> str:
|
|
98
|
+
return _run_tool(tools.check_url, tools.UrlArgs, args)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def check_payment(args: dict, **_: Any) -> str:
|
|
102
|
+
return _run_tool(tools.check_payment, tools.PaymentArgs, args)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def slash(raw_args: str = "") -> str:
|
|
106
|
+
"""`/scamgate status | review | update`. Hermes runs this in a worker thread, so `update` can download."""
|
|
107
|
+
cmd = (raw_args or "").strip().lower()
|
|
108
|
+
if cmd in ("", "status"):
|
|
109
|
+
try:
|
|
110
|
+
from .feeds import LazyFeedIndex
|
|
111
|
+
feeds = LazyFeedIndex()
|
|
112
|
+
if not feeds:
|
|
113
|
+
return "scamgate: no phishing lists cached. Run `/scamgate update` to download them."
|
|
114
|
+
_, lines = refresh.status()
|
|
115
|
+
stale = sorted(feeds.stale_feeds(hours=24))
|
|
116
|
+
head = ("scamgate: lists are stale (" + ", ".join(stale) + "). Run `/scamgate update`." if stale
|
|
117
|
+
else "scamgate: phishing lists are loaded.")
|
|
118
|
+
return head + "\n" + "\n".join(lines)
|
|
119
|
+
except Exception as exc:
|
|
120
|
+
return f"scamgate: could not read feed status ({type(exc).__name__})."
|
|
121
|
+
if cmd == "review":
|
|
122
|
+
rows = asklog.summarize(days=7)
|
|
123
|
+
if not rows:
|
|
124
|
+
return "scamgate: nothing in the ask log for the last 7 days."
|
|
125
|
+
lines = []
|
|
126
|
+
for r in rows[:8]:
|
|
127
|
+
top = next(iter(r.rules), "")
|
|
128
|
+
lines.append(f"{r.host}: {r.asks} asked ({r.ran} approved, {r.not_run} refused, {r.pending} pending), "
|
|
129
|
+
f"{r.blocks} blocked" + (f"; top rule {top}" if top else ""))
|
|
130
|
+
return "scamgate, last 7 days:\n" + "\n".join(lines)
|
|
131
|
+
if cmd == "update":
|
|
132
|
+
return refresh.refresh().text()
|
|
133
|
+
return "Usage: /scamgate status | review | update"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def update_feeds_tool(args: Any = None, **_: Any) -> str:
|
|
137
|
+
"""The agent's refresh button. It takes no arguments: whatever the agent sends is ignored, the default
|
|
138
|
+
feeds are downloaded from their fixed sources into the configured cache, at most once an hour."""
|
|
139
|
+
try:
|
|
140
|
+
out = refresh.refresh(if_older_than_hours=refresh.TOOL_MIN_GAP_HOURS)
|
|
141
|
+
except Exception as exc: # a full disk or a broken cache folder must not crash the chat
|
|
142
|
+
return json.dumps({"updated": [], "failed": {"all": f"{type(exc).__name__}: {str(exc)[:200]}"},
|
|
143
|
+
"skipped": ""})
|
|
144
|
+
return json.dumps({**out.as_dict(), "summary": out.text()})
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# ---------------------------------------------------------------- background refresh
|
|
148
|
+
|
|
149
|
+
AUTO_FIRST_DELAY = 120.0 # seconds; a short-lived `hermes ...` command exits before the first pass
|
|
150
|
+
AUTO_EVERY = 30 * 60.0 # seconds between checks; a pass downloads only when the lists are 6 hours old
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def auto_update_enabled(ctx: Any = None) -> bool:
|
|
154
|
+
"""On unless SCAMGATE_AUTO_UPDATE is off, or plugins.entries.scamgate.settings.auto_update_feeds is false."""
|
|
155
|
+
env = os.environ.get("SCAMGATE_AUTO_UPDATE")
|
|
156
|
+
if env is not None and env.strip().lower() in refresh.OFF_VALUES:
|
|
157
|
+
return False
|
|
158
|
+
val = None
|
|
159
|
+
if ctx is not None:
|
|
160
|
+
try:
|
|
161
|
+
val = ctx.get_config("auto_update_feeds", None)
|
|
162
|
+
except Exception:
|
|
163
|
+
val = None
|
|
164
|
+
if isinstance(val, str):
|
|
165
|
+
return val.strip().lower() not in refresh.OFF_VALUES
|
|
166
|
+
return val is None or bool(val)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def start_auto_update(ctx: Any = None) -> threading.Thread | None:
|
|
170
|
+
"""One daemon thread per process. Processes share the lock file, so only one downloads at a time."""
|
|
171
|
+
if not auto_update_enabled(ctx):
|
|
172
|
+
return None
|
|
173
|
+
return refresh.start_background(first_delay=AUTO_FIRST_DELAY, every=AUTO_EVERY,
|
|
174
|
+
on_unload=getattr(ctx, "on_unload", None))
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _parameters(model: type[BaseModel]) -> dict:
|
|
178
|
+
"""The tool's JSON schema for Hermes, from the same model the MCP tool uses (minus titles and null branches)."""
|
|
179
|
+
schema = model.model_json_schema()
|
|
180
|
+
props = {}
|
|
181
|
+
for name, prop in schema["properties"].items():
|
|
182
|
+
prop = {k: v for k, v in prop.items() if k not in ("title", "default")}
|
|
183
|
+
if "anyOf" in prop:
|
|
184
|
+
branches = [b for b in prop.pop("anyOf") if b.get("type") != "null"]
|
|
185
|
+
if len(branches) == 1:
|
|
186
|
+
prop.update(branches[0])
|
|
187
|
+
else:
|
|
188
|
+
prop["anyOf"] = branches
|
|
189
|
+
props[name] = prop
|
|
190
|
+
return {"type": "object", "properties": props, "required": schema.get("required", [])}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
_URL_SCHEMA = {"name": "scamgate_check_url", "description": tools.URL_DESCRIPTION,
|
|
194
|
+
"parameters": _parameters(tools.UrlArgs)}
|
|
195
|
+
_PAY_SCHEMA = {"name": "scamgate_check_payment", "description": tools.PAYMENT_DESCRIPTION,
|
|
196
|
+
"parameters": _parameters(tools.PaymentArgs)}
|
|
197
|
+
UPDATE_DESCRIPTION = tools.UPDATE_DESCRIPTION
|
|
198
|
+
_UPDATE_SCHEMA = {"name": "scamgate_update_feeds", "description": UPDATE_DESCRIPTION,
|
|
199
|
+
"parameters": {"type": "object", "properties": {}, "additionalProperties": False}}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def register(ctx) -> None:
|
|
203
|
+
global _CTX
|
|
204
|
+
_CTX = ctx
|
|
205
|
+
ctx.register_hook("pre_tool_call", on_pre_tool_call)
|
|
206
|
+
ctx.register_hook("post_tool_call", on_post_tool_call)
|
|
207
|
+
ctx.register_tool(name="scamgate_check_url", toolset="scamgate", schema=_URL_SCHEMA, handler=check_url,
|
|
208
|
+
description=_URL_SCHEMA["description"], emoji="🛡️")
|
|
209
|
+
ctx.register_tool(name="scamgate_check_payment", toolset="scamgate", schema=_PAY_SCHEMA, handler=check_payment,
|
|
210
|
+
description=_PAY_SCHEMA["description"], emoji="🛡️")
|
|
211
|
+
ctx.register_tool(name="scamgate_update_feeds", toolset="scamgate", schema=_UPDATE_SCHEMA,
|
|
212
|
+
handler=update_feeds_tool, description=UPDATE_DESCRIPTION, emoji="🛡️")
|
|
213
|
+
ctx.register_command("scamgate", slash, description="scamgate status, review, or update the phishing lists",
|
|
214
|
+
args_hint="status|review|update")
|
|
215
|
+
if callable(getattr(ctx, "register_cli_command", None)): # Hermes builds without it just skip the CLI
|
|
216
|
+
try:
|
|
217
|
+
ctx.register_cli_command("scamgate", help="scamgate phishing lists: update-feeds, feeds-status",
|
|
218
|
+
setup_fn=refresh.setup_cli, handler_fn=refresh.cli_main,
|
|
219
|
+
description="Refresh or inspect scamgate's cached phishing lists.")
|
|
220
|
+
except Exception: # a CLI that can't be wired must never stop the gate from loading
|
|
221
|
+
_log.warning("scamgate: could not add `hermes scamgate`", exc_info=True)
|
|
222
|
+
start_auto_update(ctx)
|
scamgate/plugin.yaml
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
name: scamgate
|
|
2
|
+
version: "0.3.3"
|
|
3
|
+
description: "Offline scam gate. Blocks known-bad links and payments before a tool runs, and asks a human when it cannot see the destination."
|
|
4
|
+
author: Odai Ameera
|
|
5
|
+
requires_hermes: ">=0.19.1"
|
|
6
|
+
provides_hooks:
|
|
7
|
+
- pre_tool_call
|
|
8
|
+
- post_tool_call
|
|
9
|
+
provides_tools:
|
|
10
|
+
- scamgate_check_url
|
|
11
|
+
- scamgate_check_payment
|
|
12
|
+
- scamgate_update_feeds
|
|
13
|
+
python_dependencies:
|
|
14
|
+
- "pydantic>=2.7,<3"
|
|
15
|
+
- "pyyaml>=6.0,<7"
|
|
16
|
+
config_schema:
|
|
17
|
+
when_approvals_off:
|
|
18
|
+
type: str
|
|
19
|
+
description: "When Hermes approvals are off, block asks (default) or allow them."
|
|
20
|
+
default: block
|
|
21
|
+
auto_update_feeds:
|
|
22
|
+
type: bool
|
|
23
|
+
description: "Refresh the phishing lists in the background every 6 hours (default on). Set false to refresh only with /scamgate update or hermes scamgate update-feeds."
|
|
24
|
+
default: true
|
scamgate/redact.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Strip secrets before anything is written to disk.
|
|
2
|
+
|
|
3
|
+
Logs must be safe to read and share: no passwords, query-string values, card numbers,
|
|
4
|
+
full IBANs, emails, API keys, tokens or seed phrases.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from urllib.parse import parse_qsl, urlsplit, urlunsplit
|
|
11
|
+
|
|
12
|
+
_LONG_TOKEN = re.compile(r"(?<![A-Za-z0-9])[A-Za-z0-9_\-]{24,}(?![A-Za-z0-9])")
|
|
13
|
+
_CARD = re.compile(r"(?<!\d)(?:\d[ -]?){12,18}\d(?!\d)")
|
|
14
|
+
_IBAN = re.compile(r"(?i)\b([A-Z]{2}\d{2})((?:[ ]?[A-Z0-9]){10,30})\b")
|
|
15
|
+
_EMAIL = re.compile(r"\b[\w.+-]+@([\w-]+(?:\.[\w-]+)+)\b")
|
|
16
|
+
_KV_SECRET = re.compile(r"(?i)\b(password|passwd|passphrase|passcode|pwd|secret|token|api[_-]?key|otp|pin|cvv|cvc)"
|
|
17
|
+
r"(\s*[=:]\s*|\s+(?:is|was|=)\s+)(\S+)")
|
|
18
|
+
_KEYLIKE = re.compile(r"\b(?:sk|pk|rk|ghp|gho|ghs|ghu|ghr|github_pat|glpat|xox[abprs]|AKIA|ASIA|AIza|hf|npm|pypi)"
|
|
19
|
+
r"[-_][-_A-Za-z0-9]{10,}|\bAKIA[0-9A-Z]{16}\b|\bAIza[-_A-Za-z0-9]{20,}")
|
|
20
|
+
_JWT = re.compile(r"\beyJ[\w-]{4,}\.[\w-]{4,}\.[\w-]{4,}")
|
|
21
|
+
_HIGH_ENTROPY = re.compile(r"(?<![\w/+=-])(?=[\w/+=-]*[a-z])(?=[\w/+=-]*[A-Z])(?=[\w/+=-]*\d)[\w/+=-]{30,}(?![\w/+=-])")
|
|
22
|
+
_SEED = re.compile(r"(?i)\b(seed(?:\s*phrase)?|mnemonic|recovery\s*(?:phrase|words)|secret\s*(?:phrase|words))"
|
|
23
|
+
r"(\W{0,3})((?:[a-z]{3,8}[\s,]+){11,23}[a-z]{3,8})\b")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _luhn_ok(digits: str) -> bool:
|
|
27
|
+
total, alt = 0, False
|
|
28
|
+
for ch in reversed(digits):
|
|
29
|
+
n = int(ch)
|
|
30
|
+
if alt:
|
|
31
|
+
n *= 2
|
|
32
|
+
if n > 9:
|
|
33
|
+
n -= 9
|
|
34
|
+
total += n
|
|
35
|
+
alt = not alt
|
|
36
|
+
return total % 10 == 0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def iban_ok(iban: str) -> bool:
|
|
40
|
+
s = re.sub(r"\s", "", iban).upper()
|
|
41
|
+
if not re.fullmatch(r"[A-Z]{2}\d{2}[A-Z0-9]{10,30}", s):
|
|
42
|
+
return False
|
|
43
|
+
digits = "".join(str(int(c, 36)) for c in s[4:] + s[:4])
|
|
44
|
+
return int(digits) % 97 == 1
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _mask_cards(text: str) -> str:
|
|
48
|
+
def repl(m: re.Match) -> str:
|
|
49
|
+
digits = re.sub(r"\D", "", m.group(0))
|
|
50
|
+
return "<card>" if 13 <= len(digits) <= 19 and _luhn_ok(digits) else m.group(0)
|
|
51
|
+
return _CARD.sub(repl, text)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _mask_iban(text: str) -> str:
|
|
55
|
+
def repl(m: re.Match) -> str:
|
|
56
|
+
whole = m.group(0)
|
|
57
|
+
if whole.isupper() or iban_ok(whole): # lowercase only when the checksum proves it's an IBAN
|
|
58
|
+
return f"{m.group(1).upper()}<iban>"
|
|
59
|
+
return whole
|
|
60
|
+
return _IBAN.sub(repl, text)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _mask_secrets(t: str) -> str:
|
|
64
|
+
t = _SEED.sub(lambda m: f"{m.group(1)}{m.group(2)}<seed>", t)
|
|
65
|
+
t = _KV_SECRET.sub(lambda m: f"{m.group(1)}{m.group(2)}<redacted>", t)
|
|
66
|
+
t = _JWT.sub("<jwt>", t)
|
|
67
|
+
t = _KEYLIKE.sub("<key>", t)
|
|
68
|
+
t = _HIGH_ENTROPY.sub("<secret>", t)
|
|
69
|
+
t = _mask_cards(t)
|
|
70
|
+
t = _mask_iban(t)
|
|
71
|
+
return _EMAIL.sub(lambda m: f"<email>@{m.group(1)}", t)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def redact_text(text: str, limit: int = 300) -> str:
|
|
75
|
+
t = _mask_secrets(text or "")
|
|
76
|
+
t = re.sub(r"\s+", " ", t).strip()
|
|
77
|
+
return t if len(t) <= limit else t[:limit] + "…"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def redact_url(url: str) -> str:
|
|
81
|
+
"""Keep scheme, host, port and path shape; drop userinfo, query values, fragment and secrets in the path."""
|
|
82
|
+
raw = (url or "").strip()
|
|
83
|
+
try:
|
|
84
|
+
p = urlsplit(raw if "://" in raw else "http://" + raw)
|
|
85
|
+
host = p.hostname or ""
|
|
86
|
+
port = f":{p.port}" if p.port else ""
|
|
87
|
+
except ValueError:
|
|
88
|
+
return "<unparseable-url>"
|
|
89
|
+
user = "<user>@" if p.username else ""
|
|
90
|
+
path = _mask_secrets(p.path)
|
|
91
|
+
path = _LONG_TOKEN.sub("<token>", path)
|
|
92
|
+
query = "&".join(f"{k}=" for k, _ in parse_qsl(p.query, keep_blank_values=True))
|
|
93
|
+
return urlunsplit((p.scheme, f"{user}{host}{port}", path, query, ""))
|