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/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, ""))