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/config.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Load rules config (weights, thresholds, word lists) and the local blocklist."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from functools import lru_cache
|
|
7
|
+
from importlib import resources
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import yaml
|
|
12
|
+
|
|
13
|
+
ENV_CONFIG = "SCAMGATE_CONFIG"
|
|
14
|
+
ENV_BLOCKLIST = "SCAMGATE_BLOCKLIST"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _default_config_text() -> str:
|
|
18
|
+
return resources.files(__package__).joinpath("config/rules.yaml").read_text("utf-8")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@lru_cache(maxsize=8)
|
|
22
|
+
def load_config(path: str | None = None) -> dict[str, Any]:
|
|
23
|
+
path = path or os.environ.get(ENV_CONFIG)
|
|
24
|
+
text = Path(path).read_text("utf-8") if path else _default_config_text()
|
|
25
|
+
return yaml.safe_load(text)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _blocklist_candidates() -> list[Path]:
|
|
29
|
+
out: list[Path] = []
|
|
30
|
+
if env := os.environ.get(ENV_BLOCKLIST):
|
|
31
|
+
out.append(Path(env))
|
|
32
|
+
# Bundled example list, inside the package (wheel, source checkout and Hermes plugin alike)
|
|
33
|
+
out.append(Path(__file__).resolve().parent / "data" / "blocklist.txt")
|
|
34
|
+
return out
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _parse_domains(text: str) -> set[str]:
|
|
38
|
+
domains = set()
|
|
39
|
+
for line in text.splitlines():
|
|
40
|
+
line = line.split("#", 1)[0].strip().lower().rstrip(".")
|
|
41
|
+
if line:
|
|
42
|
+
domains.add(line)
|
|
43
|
+
return domains
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@lru_cache(maxsize=8)
|
|
47
|
+
def load_blocklist(path: str | None = None) -> frozenset[str]:
|
|
48
|
+
domains: set[str] = set()
|
|
49
|
+
candidates = [Path(path)] if path else _blocklist_candidates()
|
|
50
|
+
for p in candidates:
|
|
51
|
+
if p.is_file():
|
|
52
|
+
domains |= _parse_domains(p.read_text("utf-8"))
|
|
53
|
+
break
|
|
54
|
+
return frozenset(domains)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def rule_cfg(config: dict[str, Any], rule_id: str) -> dict[str, Any]:
|
|
58
|
+
return config.get("rules", {}).get(rule_id, {}) or {}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def lst(config: dict[str, Any], name: str) -> Any:
|
|
62
|
+
return config.get("lists", {}).get(name, [])
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# scamgate local blocklist: one domain per line, '#' for comments.
|
|
2
|
+
# A listed domain also blocks its subdomains.
|
|
3
|
+
# These are made-up EXAMPLE entries on the reserved .example TLD, for tests and demos.
|
|
4
|
+
# Pull real community feeds into data/feeds/ with scripts/fetch_feeds.py (not bundled).
|
|
5
|
+
free-iphone-giveaway.example
|
|
6
|
+
revolut-unlock-account.example
|
|
7
|
+
aib-secure-verify.example
|
|
8
|
+
crypto-doubler.example
|
|
9
|
+
parcel-redelivery-fee.example
|
scamgate/domainage.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Domain age from RDAP, for `url.domain_age`. Off by default; opt in with SCAMGATE_RDAP.
|
|
2
|
+
|
|
3
|
+
New domains are a strong scam-shop signal, but looking one up means asking the domain's registry,
|
|
4
|
+
which tells the registry which site you're about to visit. So it's your call:
|
|
5
|
+
|
|
6
|
+
- `SCAMGATE_RDAP=off` (default): no lookups, the rule never fires.
|
|
7
|
+
- `SCAMGATE_RDAP=cache`: use ages already in the cache (from `scamgate domain-age HOST` or an
|
|
8
|
+
earlier live lookup); never go online.
|
|
9
|
+
- `SCAMGATE_RDAP=live`: look up unknown domains (3 s timeout) and cache the result.
|
|
10
|
+
|
|
11
|
+
The registry list comes from IANA's RDAP bootstrap file (data.iana.org/rdap/dns.json), cached for
|
|
12
|
+
30 days. Ages are cached for a year; failed or unknown lookups for 7 days, so a domain that won't
|
|
13
|
+
answer doesn't slow every call. Any error means "no answer", never a verdict.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
from datetime import datetime, timedelta, timezone
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Callable
|
|
24
|
+
|
|
25
|
+
ENV_RDAP = "SCAMGATE_RDAP"
|
|
26
|
+
_HOSTNAME = re.compile(r"^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$")
|
|
27
|
+
BOOTSTRAP_URL = "https://data.iana.org/rdap/dns.json"
|
|
28
|
+
TIMEOUT_S = 3
|
|
29
|
+
BOOTSTRAP_TTL = timedelta(days=30)
|
|
30
|
+
KNOWN_TTL = timedelta(days=365)
|
|
31
|
+
UNKNOWN_TTL = timedelta(days=7)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def mode() -> str:
|
|
35
|
+
m = (os.environ.get(ENV_RDAP) or "off").strip().lower()
|
|
36
|
+
return m if m in ("off", "cache", "live") else "off"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _cache_path() -> Path:
|
|
40
|
+
from .feeds import cache_dir
|
|
41
|
+
|
|
42
|
+
return cache_dir() / "rdap.json"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _now() -> datetime:
|
|
46
|
+
return datetime.now(timezone.utc)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _load() -> dict:
|
|
50
|
+
try:
|
|
51
|
+
data = json.loads(_cache_path().read_text("utf-8"))
|
|
52
|
+
return data if isinstance(data, dict) else {}
|
|
53
|
+
except (OSError, ValueError):
|
|
54
|
+
return {}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _save(data: dict) -> None:
|
|
58
|
+
p = _cache_path()
|
|
59
|
+
try:
|
|
60
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
tmp = p.with_suffix(".json.tmp")
|
|
62
|
+
tmp.write_text(json.dumps(data, indent=1, sort_keys=True), "utf-8")
|
|
63
|
+
tmp.replace(p)
|
|
64
|
+
except OSError:
|
|
65
|
+
pass # a read-only cache just means no caching
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _http_get(url: str) -> str:
|
|
69
|
+
import urllib.request
|
|
70
|
+
|
|
71
|
+
from .feeds import USER_AGENT
|
|
72
|
+
|
|
73
|
+
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, "Accept": "application/rdap+json"})
|
|
74
|
+
with urllib.request.urlopen(req, timeout=TIMEOUT_S) as resp: # noqa: S310
|
|
75
|
+
return resp.read(2_000_000).decode("utf-8", "replace")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _parse_ts(s: str) -> datetime | None:
|
|
79
|
+
try:
|
|
80
|
+
d = datetime.fromisoformat(s.replace("Z", "+00:00"))
|
|
81
|
+
return d if d.tzinfo else d.replace(tzinfo=timezone.utc)
|
|
82
|
+
except (TypeError, ValueError):
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _rdap_base(tld: str, data: dict, fetch: Callable[[str], str]) -> str | None:
|
|
87
|
+
boot = data.get("_bootstrap") or {}
|
|
88
|
+
fetched = _parse_ts(boot.get("fetched_at", ""))
|
|
89
|
+
if not boot.get("services") or not fetched or _now() - fetched > BOOTSTRAP_TTL:
|
|
90
|
+
try:
|
|
91
|
+
services = json.loads(fetch(BOOTSTRAP_URL)).get("services", [])
|
|
92
|
+
boot = {"fetched_at": _now().isoformat(timespec="seconds"), "services": services}
|
|
93
|
+
data["_bootstrap"] = boot
|
|
94
|
+
except Exception:
|
|
95
|
+
if not boot.get("services"):
|
|
96
|
+
return None
|
|
97
|
+
for tlds, urls in boot.get("services", []):
|
|
98
|
+
if tld in tlds and urls:
|
|
99
|
+
https = [u for u in urls if u.startswith("https://")]
|
|
100
|
+
return (https or urls)[0].rstrip("/") + "/"
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def registration_date(rdap_json: str) -> datetime | None:
|
|
105
|
+
try:
|
|
106
|
+
doc = json.loads(rdap_json)
|
|
107
|
+
except ValueError:
|
|
108
|
+
return None
|
|
109
|
+
for ev in doc.get("events", []) if isinstance(doc, dict) else []:
|
|
110
|
+
if isinstance(ev, dict) and ev.get("eventAction") == "registration":
|
|
111
|
+
return _parse_ts(ev.get("eventDate", ""))
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def lookup(domain: str, *, live: bool | None = None, fetch: Callable[[str], str] | None = None) -> int | None:
|
|
116
|
+
"""Age of `domain` in days, or None if unknown. `live` overrides SCAMGATE_RDAP."""
|
|
117
|
+
domain = (domain or "").strip().lower().rstrip(".")
|
|
118
|
+
if not domain.isascii():
|
|
119
|
+
try:
|
|
120
|
+
ascii_name = domain.encode("idna").decode("ascii") # registries take the xn-- form
|
|
121
|
+
if ascii_name.encode("ascii").decode("idna") != domain:
|
|
122
|
+
return None # Python's IDNA 2003 rules would ask about another name ('faß' -> 'fass')
|
|
123
|
+
except UnicodeError:
|
|
124
|
+
return None
|
|
125
|
+
domain = ascii_name
|
|
126
|
+
if not _HOSTNAME.match(domain):
|
|
127
|
+
return None # only a plain host name goes into the registry URL
|
|
128
|
+
m = mode()
|
|
129
|
+
allow_net = m == "live" if live is None else live
|
|
130
|
+
if m == "off" and live is None:
|
|
131
|
+
return None
|
|
132
|
+
data = _load()
|
|
133
|
+
hit = data.get(domain)
|
|
134
|
+
if isinstance(hit, dict):
|
|
135
|
+
checked = _parse_ts(hit.get("checked_at", ""))
|
|
136
|
+
created = _parse_ts(hit.get("created") or "")
|
|
137
|
+
ttl = KNOWN_TTL if created else UNKNOWN_TTL
|
|
138
|
+
if checked and _now() - checked < ttl:
|
|
139
|
+
return (_now() - created).days if created else None
|
|
140
|
+
if not allow_net:
|
|
141
|
+
return None
|
|
142
|
+
fetch = fetch or _http_get
|
|
143
|
+
created = None
|
|
144
|
+
base = _rdap_base(domain.rsplit(".", 1)[1], data, fetch)
|
|
145
|
+
if base:
|
|
146
|
+
try:
|
|
147
|
+
created = registration_date(fetch(base + "domain/" + domain))
|
|
148
|
+
except Exception:
|
|
149
|
+
created = None
|
|
150
|
+
data[domain] = {"created": created.isoformat(timespec="seconds") if created else None,
|
|
151
|
+
"checked_at": _now().isoformat(timespec="seconds")}
|
|
152
|
+
_save(data)
|
|
153
|
+
return (_now() - created).days if created else None
|
scamgate/engine.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Run registered rules and turn hits into a verdict."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from . import feeds as feeds_mod
|
|
8
|
+
from . import overrides as overrides_mod
|
|
9
|
+
from . import payees as payees_mod
|
|
10
|
+
from .config import load_blocklist, load_config, rule_cfg
|
|
11
|
+
from .models import Decision, PaymentCheck, Reason, UrlCheck, Verdict
|
|
12
|
+
from .registry import Hit, Kind, RuleContext, rules_for
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _apply_overrides(url: str, reasons: list[Reason], items: list[dict], warnings: list[str]) -> list[Reason]:
|
|
16
|
+
from .rules.url import parse
|
|
17
|
+
|
|
18
|
+
host = parse(url).host
|
|
19
|
+
if not host:
|
|
20
|
+
return reasons
|
|
21
|
+
if o := overrides_mod.deny_match(host, items):
|
|
22
|
+
reasons.append(Reason(rule_id="url.override", weight=100,
|
|
23
|
+
explanation=f"You added a deny rule for {o['host']} on {o['added_at'][:10]}."))
|
|
24
|
+
waived = overrides_mod.waived_rules(host, items)
|
|
25
|
+
kept = [r for r in reasons if r.rule_id not in waived]
|
|
26
|
+
if len(kept) != len(reasons):
|
|
27
|
+
names = sorted({r.rule_id for r in reasons} & waived)
|
|
28
|
+
warnings.append(f"Your allow rule for {host} waived: {', '.join(names)}.")
|
|
29
|
+
return kept
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _run(kind: Kind, check: Any, config: dict[str, Any] | None,
|
|
33
|
+
blocklist: frozenset[str] | None, feeds: Any = None,
|
|
34
|
+
extra_warnings: list[str] | None = None) -> Verdict:
|
|
35
|
+
config = config or load_config()
|
|
36
|
+
blocklist = load_blocklist() if blocklist is None else blocklist
|
|
37
|
+
warnings: list[str] = []
|
|
38
|
+
if kind == "url":
|
|
39
|
+
feeds = feeds_mod.current_index() if feeds is None else feeds
|
|
40
|
+
if not feeds:
|
|
41
|
+
warnings.append("No known-bad-link feeds are cached. " + feeds_mod.REFRESH_HINT)
|
|
42
|
+
elif stale := feeds.stale_feeds():
|
|
43
|
+
warnings.append(f"Feed cache older than {feeds_mod.STALE_AFTER_HOURS}h for: "
|
|
44
|
+
f"{', '.join(sorted(stale))}. " + feeds_mod.REFRESH_HINT)
|
|
45
|
+
unreadable, checked_host = False, None
|
|
46
|
+
if kind == "url":
|
|
47
|
+
from .rules.url import parse
|
|
48
|
+
|
|
49
|
+
parsed = parse(check.url)
|
|
50
|
+
# Nothing else can judge a link scamgate can't read, and its text must not reach a reason.
|
|
51
|
+
unreadable = bool(parsed.unreadable)
|
|
52
|
+
checked_host = parsed.host or None
|
|
53
|
+
reasons: list[Reason] = []
|
|
54
|
+
for rule in rules_for(kind):
|
|
55
|
+
if unreadable and rule.rule_id != "url.unreadable":
|
|
56
|
+
continue
|
|
57
|
+
rc = rule_cfg(config, rule.rule_id)
|
|
58
|
+
if not rc.get("enabled", False):
|
|
59
|
+
continue
|
|
60
|
+
out = rule.fn(check, RuleContext(config=config, rule_config=rc, blocklist=blocklist, feeds=feeds))
|
|
61
|
+
hits: list[Hit] = [] if out is None else (out if isinstance(out, list) else [out])
|
|
62
|
+
for h in hits:
|
|
63
|
+
w = h.weight if h.weight is not None else int(rc.get("weight", 0))
|
|
64
|
+
if w <= 0:
|
|
65
|
+
continue
|
|
66
|
+
reasons.append(Reason(rule_id=rule.rule_id, explanation=h.explanation, weight=min(w, 100)))
|
|
67
|
+
|
|
68
|
+
if kind == "url":
|
|
69
|
+
items, bad = overrides_mod.load_checked()
|
|
70
|
+
if bad:
|
|
71
|
+
warnings.append(f"{bad} of your scamgate override rules can't be verified (its signing key is missing or "
|
|
72
|
+
"was changed, or the line was edited by hand), so they're ignored. Check the scamgate "
|
|
73
|
+
"state dir.")
|
|
74
|
+
if items and not unreadable:
|
|
75
|
+
reasons = _apply_overrides(check.url, reasons, items, warnings)
|
|
76
|
+
warnings += extra_warnings or []
|
|
77
|
+
|
|
78
|
+
reasons.sort(key=lambda r: r.weight, reverse=True)
|
|
79
|
+
score = min(100, sum(r.weight for r in reasons))
|
|
80
|
+
th = config.get("thresholds", {})
|
|
81
|
+
if score >= int(th.get("deny", 70)):
|
|
82
|
+
decision = Decision.deny
|
|
83
|
+
elif score >= int(th.get("ask", 25)):
|
|
84
|
+
decision = Decision.ask
|
|
85
|
+
else:
|
|
86
|
+
decision = Decision.allow
|
|
87
|
+
return Verdict(decision=decision, risk_score=score, reasons=reasons,
|
|
88
|
+
summary=_summary(kind, decision, reasons), checked_host=checked_host, warnings=warnings)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _summary(kind: Kind, decision: Decision, reasons: list[Reason]) -> str:
|
|
92
|
+
thing = "link" if kind == "url" else "payment"
|
|
93
|
+
if decision is Decision.allow and not reasons:
|
|
94
|
+
return f"No scam signals found for this {thing}."
|
|
95
|
+
top = reasons[0].explanation if reasons else ""
|
|
96
|
+
if decision is Decision.allow:
|
|
97
|
+
return f"Low risk {thing}, but note: {top}"
|
|
98
|
+
if decision is Decision.ask:
|
|
99
|
+
return f"Check with the human before this {thing}. Main concern: {top}"
|
|
100
|
+
return f"Do not proceed with this {thing}. Main concern: {top}"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def check_url(url: str, intent: str | None = None, *, config: dict[str, Any] | None = None,
|
|
104
|
+
blocklist: frozenset[str] | None = None, feeds: Any = None) -> Verdict:
|
|
105
|
+
"""feeds: a FeedIndex; defaults to the local cache (pass FeedIndex() for none)."""
|
|
106
|
+
return _run("url", UrlCheck(url=url, intent=intent), config, blocklist, feeds)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def check_payment(check: PaymentCheck, *, config: dict[str, Any] | None = None,
|
|
110
|
+
use_history: bool = True) -> Verdict:
|
|
111
|
+
"""Payee facts come from local history (`scamgate record-payment`), not the agent's claims."""
|
|
112
|
+
warnings: list[str] = []
|
|
113
|
+
if use_history:
|
|
114
|
+
check, warnings = _with_history(check)
|
|
115
|
+
return _run("payment", check, config, frozenset(), extra_warnings=warnings)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _with_history(check: PaymentCheck) -> tuple[PaymentCheck, list[str]]:
|
|
119
|
+
facts = payees_mod.lookup(check.payee_name, check.payee_account)
|
|
120
|
+
warnings: list[str] = []
|
|
121
|
+
claimed_known = False
|
|
122
|
+
if facts.history_broken:
|
|
123
|
+
new, changed = True, True
|
|
124
|
+
warnings.append("Your payee history can't be verified (its key or salt is missing or was changed), so "
|
|
125
|
+
"every payee is treated as new with changed details. Check the scamgate state dir.")
|
|
126
|
+
elif facts.known_account:
|
|
127
|
+
new, changed = False, bool(check.bank_details_changed)
|
|
128
|
+
elif facts.name_seen_with_other_account:
|
|
129
|
+
new, changed = False, True
|
|
130
|
+
warnings.append(f"You've paid '{check.payee_name}' before, but never to this account.")
|
|
131
|
+
elif facts.near_name:
|
|
132
|
+
new, changed = True, True
|
|
133
|
+
warnings.append(f"'{check.payee_name}' looks like a payee you've paid before, but this name and account "
|
|
134
|
+
"are new. Lookalike payee names are a common invoice-redirection trick.")
|
|
135
|
+
else:
|
|
136
|
+
new, changed = True, bool(check.bank_details_changed)
|
|
137
|
+
if check.is_new_payee is False:
|
|
138
|
+
claimed_known = True
|
|
139
|
+
warnings.append(f"The agent said '{check.payee_name}' is a known payee, but you've never paid them "
|
|
140
|
+
"from here (no record). Treating them as new.")
|
|
141
|
+
if facts.account_under_other_name:
|
|
142
|
+
changed = True
|
|
143
|
+
warnings.append("You've paid this account before under a different name. Confirm who really owns it.")
|
|
144
|
+
upd = {"is_new_payee": new, "bank_details_changed": changed, "agent_claim_unbacked": claimed_known}
|
|
145
|
+
return check.model_copy(update=upd), warnings
|
scamgate/entry.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""The `scamgate` console script.
|
|
2
|
+
|
|
3
|
+
`scamgate hook` runs once per agent tool call (the Claude Code plugin calls `uvx scamgate==X hook`), so it
|
|
4
|
+
skips the typer/rich command line and goes straight to the hook. Every other command loads the full CLI.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main() -> None:
|
|
13
|
+
if sys.argv[1:] == ["hook"]:
|
|
14
|
+
from .hook import main as run_hook
|
|
15
|
+
|
|
16
|
+
sys.exit(run_hook())
|
|
17
|
+
from .cli import app
|
|
18
|
+
|
|
19
|
+
app()
|