scamgate 0.3.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
scamgate/hook_entry.py ADDED
@@ -0,0 +1,15 @@
1
+ """Fast entry point for the enforcement hook (skips typer/rich startup).
2
+
3
+ `scamgate-hook` runs once per tool call, so startup time matters. `scamgate hook` does the
4
+ same thing through the full CLI.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import sys
10
+
11
+
12
+ def main() -> None:
13
+ from .hook import main as run
14
+
15
+ sys.exit(run())
scamgate/hostnorm.py ADDED
@@ -0,0 +1,60 @@
1
+ """Host normalisation shared by the rules and the feed lookups.
2
+
3
+ Browsers and curl accept hosts that look nothing like the domain they reach:
4
+ percent-encoded letters (`evilsh%6fp.com`), integer IPv4 (`3405803781`), hex or octal parts
5
+ (`0xcb007105`, `0313.0.0.1`). Normalise them so every check sees the real destination.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ipaddress
11
+ import re
12
+ import unicodedata
13
+ from urllib.parse import unquote
14
+
15
+ _NUM_PART = re.compile(r"^(?:0x[0-9a-f]+|0[0-7]*|[1-9][0-9]*)$", re.I)
16
+
17
+
18
+ def _num(part: str) -> int:
19
+ p = part.lower()
20
+ if p.startswith("0x"):
21
+ return int(p, 16)
22
+ if len(p) > 1 and p.startswith("0"):
23
+ return int(p, 8)
24
+ return int(p)
25
+
26
+
27
+ def numeric_ipv4(host: str) -> str | None:
28
+ """'3405803781', '0xcb007105', '0313.0.0.1', '203.0x0.113.5' -> dotted quad, else None.
29
+
30
+ Follows inet_aton: 1-4 parts, the last part fills the remaining bytes.
31
+ """
32
+ h = host.strip("[]").rstrip(".")
33
+ parts = h.split(".")
34
+ if not 1 <= len(parts) <= 4 or not all(_NUM_PART.match(p) for p in parts):
35
+ return None
36
+ try:
37
+ nums = [_num(p) for p in parts]
38
+ except ValueError:
39
+ return None
40
+ *head, last = nums
41
+ if any(n > 255 for n in head) or last >= 256 ** (4 - len(head)):
42
+ return None
43
+ value = 0
44
+ for n in head:
45
+ value = value * 256 + n
46
+ value = value * 256 ** (4 - len(head)) + last
47
+ return str(ipaddress.IPv4Address(value))
48
+
49
+
50
+ # Ideographic and halfwidth full stops: browsers read them as '.' in a host name.
51
+ _DOTS = str.maketrans({"。": ".", ".": ".", "。": "."})
52
+
53
+
54
+ def canon_host(host: str) -> str:
55
+ """Lowercase, percent-decoded, no trailing dot, numeric IPv4 in dotted form."""
56
+ h = unicodedata.normalize("NFKC", unquote(host or "")).translate(_DOTS).strip().lower().rstrip(".")
57
+ if h and not h.startswith("["):
58
+ if (ip := numeric_ipv4(h)) is not None and h != ip:
59
+ return ip
60
+ return h
scamgate/integrity.py ADDED
@@ -0,0 +1,85 @@
1
+ """Check that the installed scamgate code hasn't been modified.
2
+
3
+ A wheel install has a RECORD file with a SHA-256 for every file. The hook verifies scamgate's
4
+ own Python and config files against it at startup and fails closed if anything changed.
5
+ An editable/dev install (source checkout) has nothing to verify against, so it reports "dev"
6
+ and passes: run the hook from a pinned install (`uv tool install`) to get this protection.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ import hashlib
13
+ from importlib import metadata
14
+ from pathlib import Path
15
+
16
+
17
+ def _digest(path: Path) -> str:
18
+ return base64.urlsafe_b64encode(hashlib.sha256(path.read_bytes()).digest()).rstrip(b"=").decode()
19
+
20
+
21
+ def verify(entries: list[tuple[Path, str, str]]) -> tuple[bool, str]:
22
+ problems = []
23
+ for path, algo, expected in entries:
24
+ if algo != "sha256":
25
+ continue
26
+ if not path.is_file():
27
+ problems.append(f"missing: {path.name}")
28
+ elif _digest(path) != expected:
29
+ problems.append(f"modified: {path.name}")
30
+ if problems:
31
+ return False, "; ".join(problems[:5])
32
+ return True, f"{len(entries)} files verified"
33
+
34
+
35
+ def _is_editable(dist: metadata.Distribution) -> bool:
36
+ import json
37
+
38
+ try:
39
+ direct = json.loads(dist.read_text("direct_url.json") or "{}")
40
+ except (FileNotFoundError, json.JSONDecodeError):
41
+ return False
42
+ return bool((direct.get("dir_info") or {}).get("editable"))
43
+
44
+
45
+ _KNOWN_PTH = {"distutils-precedence.pth", "_virtualenv.pth"}
46
+
47
+
48
+ def unexpected_pth(dirs: list[Path]) -> list[str]:
49
+ """.pth files no installed package owns. Python runs `import` lines in them at every start."""
50
+ out = []
51
+ for d in dirs:
52
+ d = Path(d)
53
+ if not d.is_dir():
54
+ continue
55
+ owned = set()
56
+ for rec in d.glob("*.dist-info/RECORD"):
57
+ for line in rec.read_text("utf-8", errors="replace").splitlines():
58
+ name = line.split(",", 1)[0].replace("\\", "/")
59
+ if name.endswith(".pth") and "/" not in name:
60
+ owned.add(name)
61
+ for p in sorted(d.glob("*.pth")):
62
+ if p.name not in owned and p.name not in _KNOWN_PTH and not p.name.startswith("__editable__"):
63
+ out.append(p.name)
64
+ return out
65
+
66
+
67
+ def check() -> tuple[bool, str]:
68
+ try:
69
+ dist = metadata.distribution("scamgate")
70
+ except metadata.PackageNotFoundError:
71
+ return True, "dev (not installed as a package)"
72
+ if _is_editable(dist):
73
+ return True, "dev (editable install, nothing to verify)"
74
+ entries = []
75
+ for f in dist.files or []:
76
+ s = str(f).replace("\\", "/")
77
+ if not s.startswith("scamgate/") or not f.hash or not s.endswith((".py", ".yaml", ".txt")):
78
+ continue
79
+ entries.append((Path(f.locate()), f.hash.mode, f.hash.value))
80
+ if not entries:
81
+ return True, "dev (no RECORD hashes)"
82
+ ok, why = verify(entries)
83
+ if ok and (extra := unexpected_pth([Path(dist.locate_file(""))])):
84
+ return False, f"unknown startup file(s) next to scamgate: {', '.join(extra[:3])}"
85
+ return ok, why
scamgate/models.py ADDED
@@ -0,0 +1,77 @@
1
+ """Shared input and verdict models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import Enum
6
+
7
+ from pydantic import BaseModel, Field
8
+
9
+
10
+ class Decision(str, Enum):
11
+ allow = "allow"
12
+ ask = "ask" # stop and ask the human
13
+ deny = "deny"
14
+
15
+
16
+ class PaymentMethod(str, Enum):
17
+ card = "card"
18
+ bank_transfer = "bank_transfer"
19
+ crypto = "crypto"
20
+ gift_card = "gift_card"
21
+ other = "other"
22
+
23
+
24
+ class VopResult(str, Enum):
25
+ """Verification of Payee (name vs account) result, if the caller has one."""
26
+
27
+ match = "match"
28
+ close_match = "close_match"
29
+ no_match = "no_match"
30
+ unavailable = "unavailable"
31
+
32
+
33
+ class Reason(BaseModel):
34
+ rule_id: str
35
+ explanation: str = Field(description="Plain-English reason a human can read")
36
+ weight: int = Field(ge=0, le=100)
37
+
38
+
39
+ class Verdict(BaseModel):
40
+ decision: Decision
41
+ risk_score: int = Field(ge=0, le=100)
42
+ reasons: list[Reason] = Field(default_factory=list)
43
+ summary: str
44
+ checked_host: str | None = Field(
45
+ default=None,
46
+ description="For links: the host scamgate judged, the one a browser would open. If it isn't where you "
47
+ "expected the link to go, treat that as a warning. Empty for payments and unreadable links.",
48
+ )
49
+ warnings: list[str] = Field(default_factory=list, description="Notes about the check itself, e.g. a stale feed cache")
50
+ advisory: str = (
51
+ "scamgate is advisory. It can miss scams and flag legitimate actions; "
52
+ "a human should make the final call on anything flagged."
53
+ )
54
+
55
+
56
+ class UrlCheck(BaseModel):
57
+ url: str
58
+ intent: str | None = None
59
+
60
+
61
+ class PaymentCheck(BaseModel):
62
+ payee_name: str
63
+ payee_account: str = Field(description="IBAN, wallet address, card merchant id, etc.")
64
+ amount: float = Field(ge=0, allow_inf_nan=False)
65
+ currency: str
66
+ method: PaymentMethod
67
+ context_text: str | None = Field(
68
+ default=None, description="Messages or page text that led to this payment"
69
+ )
70
+ is_new_payee: bool | None = None
71
+ agent_claim_unbacked: bool = Field(
72
+ default=False, exclude=True,
73
+ description="Set by scamgate, not the caller: the agent called a payee known with no record of it",
74
+ )
75
+ bank_details_changed: bool | None = None
76
+ budget: float | None = Field(default=None, ge=0, allow_inf_nan=False)
77
+ vop_result: VopResult | None = None
scamgate/overrides.py ADDED
@@ -0,0 +1,90 @@
1
+ """Human-approved overrides, promoted from the ask log with `scamgate promote`.
2
+
3
+ Rules for overrides, so the gate can't be trained by an attacker:
4
+ - Only a person can add them: `add()` refuses agent environments and non-interactive callers,
5
+ whether it's called from the CLI or imported directly.
6
+ - Every line is signed (see guard.py). Lines written any other way are ignored.
7
+ - `deny` covers an exact host and its subdomains.
8
+ - `allow` is narrow: one exact host (no subdomains) AND one named rule. It waives only that rule's
9
+ points for that host. It never waives known-bad feeds, the blocklist, intent checks or your own deny.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import re
16
+ from datetime import datetime, timezone
17
+ from pathlib import Path
18
+
19
+ from . import guard
20
+ from .state import state_dir
21
+
22
+ NEVER_WAIVED = {"url.known_bad_feed", "url.blocklist", "url.override", "url.intent_mismatch"}
23
+ _HOST = re.compile(r"^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9-]{2,63}$")
24
+ _interactive = guard._interactive
25
+
26
+
27
+ def overrides_path() -> Path:
28
+ return state_dir() / "overrides.jsonl"
29
+
30
+
31
+ def _check_host(host: str) -> str:
32
+ h = (host or "").strip().lower().rstrip(".")
33
+ if not _HOST.match(h):
34
+ raise ValueError(f"not an exact host name: {host!r} (no wildcards, schemes or paths)")
35
+ return h
36
+
37
+
38
+ def add(host: str, decision: str, *, rule: str | None = None, note: str = "") -> dict:
39
+ h = _check_host(host)
40
+ if decision not in ("allow", "deny"):
41
+ raise ValueError("decision must be 'allow' or 'deny'")
42
+ if decision == "allow" and not rule:
43
+ raise ValueError("an allow override needs --rule: it waives one rule on one host")
44
+ if decision == "allow" and rule in NEVER_WAIVED:
45
+ raise ValueError(f"{rule} can't be waived: known-bad lists, your blocklist and intent checks always apply")
46
+ guard.require_human("Adding a scamgate override", interactive=lambda: _interactive())
47
+ rec = guard.sign({"host": h, "decision": decision, "rule": rule if decision == "allow" else None,
48
+ "note": note[:200], "added_by": "human-cli",
49
+ "added_at": datetime.now(timezone.utc).isoformat(timespec="seconds")})
50
+ guard.append_private(overrides_path(), json.dumps(rec))
51
+ return rec
52
+
53
+
54
+ def load() -> list[dict]:
55
+ return load_checked()[0]
56
+
57
+
58
+ def load_checked() -> tuple[list[dict], int]:
59
+ """(rules that verify, number of lines that don't). A missing or changed key fails every line,
60
+ and the engine says so rather than dropping your rules silently."""
61
+ p = overrides_path()
62
+ if not p.exists():
63
+ return [], 0
64
+ out, bad = [], 0
65
+ for line in p.read_text("utf-8").splitlines():
66
+ if not line.strip():
67
+ continue
68
+ try:
69
+ rec = json.loads(line)
70
+ except json.JSONDecodeError:
71
+ bad += 1
72
+ continue
73
+ if (isinstance(rec, dict) and rec.get("host") and rec.get("decision") in ("allow", "deny")
74
+ and guard.verify(rec)):
75
+ out.append(rec)
76
+ else:
77
+ bad += 1
78
+ return out, bad
79
+
80
+
81
+ def deny_match(host: str, items: list[dict]) -> dict | None:
82
+ for o in items:
83
+ if o["decision"] == "deny" and (host == o["host"] or host.endswith("." + o["host"])):
84
+ return o
85
+ return None
86
+
87
+
88
+ def waived_rules(host: str, items: list[dict]) -> set[str]:
89
+ return {o["rule"] for o in items
90
+ if o["decision"] == "allow" and o.get("rule") and host == o["host"]} - NEVER_WAIVED
scamgate/payees.py ADDED
@@ -0,0 +1,141 @@
1
+ """Local payee history, so payment checks don't rely on what the agent claims.
2
+
3
+ Stores salted hashes of the normalized payee name and account, never the values, plus hashes of
4
+ one-letter-deletion variants of the name's first word, so near-names ("Acme Trading Ltd",
5
+ "Acmee Ltd", Cyrillic "Acmе") still match a payee you've paid before without storing the name.
6
+ Only a person can add a payee (`scamgate record-payment`), and every line is signed.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ import re
14
+ import secrets
15
+ import unicodedata
16
+ from dataclasses import dataclass
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+
20
+ from . import guard
21
+ from .state import state_dir
22
+
23
+ _SUFFIXES = r"\b(ltd|limited|llc|inc|plc|gmbh|teoranta|dac|co|company|the|and|of)\b"
24
+ _GENERIC = {"bank", "the", "mr", "mrs", "ms", "dr", "st", "saint", "group", "services", "payments", "account"}
25
+ # Letters that look Latin in most fonts.
26
+ _CONFUSABLE = str.maketrans({
27
+ "а": "a", "е": "e", "о": "o", "р": "p", "с": "c", "у": "y", "х": "x", "і": "i", "ј": "j", "ѕ": "s",
28
+ "ԁ": "d", "ɡ": "g", "һ": "h", "ӏ": "l", "ο": "o", "α": "a", "ε": "e", "ι": "i", "κ": "k", "ν": "v",
29
+ "ρ": "p", "τ": "t", "υ": "u", "χ": "x", "ϲ": "c", "0": "o", "1": "l",
30
+ })
31
+ _interactive = guard._interactive
32
+
33
+
34
+ def history_path() -> Path:
35
+ return state_dir() / "payees.jsonl"
36
+
37
+
38
+ def _salt_path() -> Path:
39
+ return state_dir() / "payees.salt"
40
+
41
+
42
+ def _salt(create: bool = False) -> bytes | None:
43
+ p = _salt_path()
44
+ if not p.exists():
45
+ if not create:
46
+ return None
47
+ guard.create_once(p, secrets.token_hex(16))
48
+ return p.read_text("utf-8").strip().encode()
49
+
50
+
51
+ def _fold(name: str) -> str:
52
+ n = unicodedata.normalize("NFKC", name or "").lower().translate(_CONFUSABLE)
53
+ n = "".join(c for c in unicodedata.normalize("NFKD", n) if not unicodedata.combining(c))
54
+ return re.sub(_SUFFIXES, " ", n)
55
+
56
+
57
+ def norm_name(name: str) -> str:
58
+ return re.sub(r"[^a-z0-9]", "", _fold(name))
59
+
60
+
61
+ def core_word(name: str) -> str:
62
+ words = [w for w in re.split(r"[^a-z0-9]+", _fold(name)) if len(w) >= 3 and w not in _GENERIC]
63
+ return words[0] if words else norm_name(name)
64
+
65
+
66
+ def _variants(word: str) -> set[str]:
67
+ return {word} | {word[:i] + word[i + 1:] for i in range(len(word))} if len(word) >= 4 else {word}
68
+
69
+
70
+ def _salt_id(salt: bytes) -> str:
71
+ return hashlib.sha256(b"scamgate-salt-id\0" + salt).hexdigest()[:16]
72
+
73
+
74
+ def norm_account(account: str) -> str:
75
+ return re.sub(r"[^A-Z0-9]", "", (account or "").upper())
76
+
77
+
78
+ def _h(kind: str, value: str, salt: bytes | None = None) -> str:
79
+ s = salt if salt is not None else (_salt() or b"")
80
+ return hashlib.sha256(s + kind.encode() + b"\0" + value.encode()).hexdigest()[:32]
81
+
82
+
83
+ @dataclass
84
+ class PayeeFacts:
85
+ has_history: bool
86
+ known_name: bool
87
+ known_account: bool
88
+ name_seen_with_other_account: bool
89
+ near_name: bool = False
90
+ account_under_other_name: bool = False
91
+ history_broken: bool = False
92
+
93
+
94
+ def _rows() -> list[dict]:
95
+ p = history_path()
96
+ if not p.exists():
97
+ return []
98
+ out = []
99
+ for line in p.read_text("utf-8").splitlines():
100
+ try:
101
+ rec = json.loads(line)
102
+ except json.JSONDecodeError:
103
+ continue
104
+ if isinstance(rec, dict) and guard.verify(rec):
105
+ out.append(rec)
106
+ return out
107
+
108
+
109
+ def lookup(name: str, account: str) -> PayeeFacts:
110
+ p = history_path()
111
+ if p.exists() and p.stat().st_size and (_salt() is None or not guard.key_exists()):
112
+ return PayeeFacts(True, False, False, False, history_broken=True)
113
+ rows = _rows()
114
+ if not rows:
115
+ return PayeeFacts(False, False, False, False)
116
+ sid = _salt_id(_salt() or b"")
117
+ if any(r.get("salt_id") not in (None, sid) for r in rows):
118
+ return PayeeFacts(True, False, False, False, history_broken=True)
119
+ nh, ah = _h("name", norm_name(name)), _h("acct", norm_account(account))
120
+ near = {_h("core", v) for v in _variants(core_word(name))}
121
+ exact_name = [r for r in rows if r.get("name") == nh]
122
+ near_name = [r for r in rows if r.get("name") != nh and near & set(r.get("core") or [])]
123
+ same_acct = [r for r in rows if r.get("account") == ah]
124
+ known_account = any(r in exact_name or r in near_name for r in same_acct)
125
+ return PayeeFacts(
126
+ has_history=True,
127
+ known_name=bool(exact_name),
128
+ known_account=known_account,
129
+ name_seen_with_other_account=bool(exact_name) and not known_account,
130
+ near_name=bool(near_name) and not exact_name and not known_account,
131
+ account_under_other_name=bool(same_acct) and not known_account,
132
+ )
133
+
134
+
135
+ def record(name: str, account: str) -> None:
136
+ guard.require_human("Recording a payee", interactive=lambda: _interactive())
137
+ salt = _salt(create=True)
138
+ rec = guard.sign({"v": 2, "salt_id": _salt_id(salt), "name": _h("name", norm_name(name), salt), "account": _h("acct", norm_account(account), salt),
139
+ "core": sorted(_h("core", v, salt) for v in _variants(core_word(name))),
140
+ "ts": datetime.now(timezone.utc).isoformat(timespec="seconds")})
141
+ guard.append_private(history_path(), json.dumps(rec))