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/feeds.py
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
"""Known-bad-link feeds: download into a local cache, then look up offline.
|
|
2
|
+
|
|
3
|
+
`update_feeds()` is the only code path that touches the network (`scamgate update-feeds`, and the
|
|
4
|
+
Hermes plugin's refresh in refresh.py). Checks only read the cache. Each feed is stored as its own normalized file, so every entry keeps its source,
|
|
5
|
+
and `meta.json` records when each feed was last fetched successfully.
|
|
6
|
+
|
|
7
|
+
Cache dir: $SCAMGATE_CACHE_DIR, else $XDG_CACHE_HOME/scamgate, else ~/.cache/scamgate.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import csv
|
|
13
|
+
import io
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import time
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from functools import lru_cache
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Callable, Iterable
|
|
22
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
23
|
+
|
|
24
|
+
from .hostnorm import canon_host
|
|
25
|
+
|
|
26
|
+
ENV_CACHE_DIR = "SCAMGATE_CACHE_DIR"
|
|
27
|
+
ENV_URLHAUS_KEY = "SCAMGATE_URLHAUS_AUTH_KEY"
|
|
28
|
+
USER_AGENT = "scamgate/0.3 (+https://github.com/odaiameera/scamgate)"
|
|
29
|
+
STALE_AFTER_HOURS = 24
|
|
30
|
+
REFRESH_HINT = ("Refresh them with the update_feeds tool or `scamgate update-feeds`, or with `/scamgate update` "
|
|
31
|
+
"(`hermes scamgate update-feeds` in a terminal) when scamgate is a Hermes plugin.")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def cache_dir() -> Path:
|
|
35
|
+
if env := os.environ.get(ENV_CACHE_DIR):
|
|
36
|
+
return Path(env).expanduser()
|
|
37
|
+
base = os.environ.get("XDG_CACHE_HOME") or str(Path.home() / ".cache")
|
|
38
|
+
return Path(base) / "scamgate"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ---------------------------------------------------------------- normalization
|
|
42
|
+
|
|
43
|
+
def normalize_url(url: str) -> str | None:
|
|
44
|
+
"""Canonical form for exact matching: lowercase scheme+host, no default port,
|
|
45
|
+
no fragment, no trailing slash on the path. Path and query are kept as-is."""
|
|
46
|
+
raw = url.strip()
|
|
47
|
+
if not raw or raw.startswith("#"):
|
|
48
|
+
return None
|
|
49
|
+
if "://" not in raw:
|
|
50
|
+
raw = "http://" + raw
|
|
51
|
+
try:
|
|
52
|
+
p = urlsplit(raw)
|
|
53
|
+
host = canon_host(p.hostname or "")
|
|
54
|
+
port = p.port
|
|
55
|
+
except ValueError:
|
|
56
|
+
return None
|
|
57
|
+
if not host:
|
|
58
|
+
return None
|
|
59
|
+
scheme = p.scheme.lower()
|
|
60
|
+
netloc = host
|
|
61
|
+
if port and not ((scheme == "http" and port == 80) or (scheme == "https" and port == 443)):
|
|
62
|
+
netloc = f"{host}:{port}"
|
|
63
|
+
path = p.path.rstrip("/")
|
|
64
|
+
# http and https variants of the same URL are treated as the same link.
|
|
65
|
+
if scheme in ("http", "https"):
|
|
66
|
+
scheme = "http"
|
|
67
|
+
return urlunsplit((scheme, netloc, path, p.query, ""))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def normalize_host(value: str) -> str | None:
|
|
71
|
+
v = value.strip().lower()
|
|
72
|
+
if not v or v.startswith("#"):
|
|
73
|
+
return None
|
|
74
|
+
if "://" in v:
|
|
75
|
+
try:
|
|
76
|
+
v = urlsplit(v).hostname or ""
|
|
77
|
+
except ValueError:
|
|
78
|
+
return None
|
|
79
|
+
v = canon_host(v.split("/", 1)[0].split(":", 1)[0])
|
|
80
|
+
if not v or "." not in v or " " in v:
|
|
81
|
+
return None
|
|
82
|
+
return v
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def host_of(url: str) -> str | None:
|
|
86
|
+
return normalize_host(url if "://" in url else "http://" + url)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ---------------------------------------------------------------- parsers
|
|
90
|
+
|
|
91
|
+
def _lines(text: str) -> Iterable[str]:
|
|
92
|
+
for line in text.splitlines():
|
|
93
|
+
line = line.strip()
|
|
94
|
+
if line and not line.startswith("#"):
|
|
95
|
+
yield line
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def parse_url_list(text: str) -> tuple[set[str], set[str]]:
|
|
99
|
+
"""One URL per line -> (urls, hosts)."""
|
|
100
|
+
urls, hosts = set(), set()
|
|
101
|
+
for line in _lines(text):
|
|
102
|
+
if (u := normalize_url(line)) and (h := host_of(line)):
|
|
103
|
+
urls.add(u)
|
|
104
|
+
hosts.add(h)
|
|
105
|
+
return urls, hosts
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def parse_domain_list(text: str) -> tuple[set[str], set[str]]:
|
|
109
|
+
"""One domain per line -> (no urls, hosts)."""
|
|
110
|
+
return set(), {h for line in _lines(text) if (h := normalize_host(line))}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def parse_urlhaus_csv(text: str) -> tuple[set[str], set[str]]:
|
|
114
|
+
"""URLhaus CSV dump: id,dateadded,url,url_status,... (comment lines start with #)."""
|
|
115
|
+
body = "\n".join(line for line in text.splitlines() if line and not line.startswith("#"))
|
|
116
|
+
urls, hosts = set(), set()
|
|
117
|
+
for row in csv.reader(io.StringIO(body)):
|
|
118
|
+
if len(row) > 2 and (u := normalize_url(row[2])) and (h := host_of(row[2])):
|
|
119
|
+
urls.add(u)
|
|
120
|
+
hosts.add(h)
|
|
121
|
+
return urls, hosts
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ---------------------------------------------------------------- feed registry
|
|
125
|
+
|
|
126
|
+
@dataclass(frozen=True)
|
|
127
|
+
class Feed:
|
|
128
|
+
name: str
|
|
129
|
+
title: str
|
|
130
|
+
parser: Callable[[str], tuple[set[str], set[str]]]
|
|
131
|
+
url: Callable[[], str | None] # None -> feed not configured (e.g. missing key)
|
|
132
|
+
license: str
|
|
133
|
+
default: bool = True
|
|
134
|
+
# Host matches from URL feeds can be shared hosting; only exact host hits count then.
|
|
135
|
+
hosts_are_listed_domains: bool = True
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _urlhaus_url() -> str | None:
|
|
139
|
+
key = (os.environ.get(ENV_URLHAUS_KEY) or "").strip()
|
|
140
|
+
if not key or key.startswith("${"): # unset, or a plugin setting placeholder nobody filled in
|
|
141
|
+
return None
|
|
142
|
+
return f"https://urlhaus-api.abuse.ch/v2/files/exports/{key}/csv_online"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
FEEDS: dict[str, Feed] = {f.name: f for f in [
|
|
146
|
+
Feed("openphish", "OpenPhish community feed", parse_url_list,
|
|
147
|
+
lambda: "https://openphish.com/feed.txt",
|
|
148
|
+
"OpenPhish Terms of Use: personal / non-commercial research only, no redistribution",
|
|
149
|
+
hosts_are_listed_domains=False),
|
|
150
|
+
Feed("phishing_database_domains", "Phishing.Database (active domains)", parse_domain_list,
|
|
151
|
+
lambda: "https://raw.githubusercontent.com/Phishing-Database/Phishing.Database/master/phishing-domains-ACTIVE.txt",
|
|
152
|
+
"MIT (Phishing.Database contributors)"),
|
|
153
|
+
Feed("phishing_database_links", "Phishing.Database (active links)", parse_url_list,
|
|
154
|
+
lambda: "https://raw.githubusercontent.com/Phishing-Database/Phishing.Database/master/phishing-links-ACTIVE.txt",
|
|
155
|
+
"MIT (Phishing.Database contributors)", default=False, hosts_are_listed_domains=False),
|
|
156
|
+
Feed("urlhaus", "abuse.ch URLhaus (online malware URLs)", parse_urlhaus_csv, _urlhaus_url,
|
|
157
|
+
"abuse.ch fair-use terms; free Auth-Key required; commercial use may need a paid plan",
|
|
158
|
+
hosts_are_listed_domains=False),
|
|
159
|
+
]}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# ---------------------------------------------------------------- update
|
|
163
|
+
|
|
164
|
+
@dataclass
|
|
165
|
+
class FeedResult:
|
|
166
|
+
name: str
|
|
167
|
+
ok: bool
|
|
168
|
+
urls: int = 0
|
|
169
|
+
hosts: int = 0
|
|
170
|
+
message: str = ""
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
MAX_FEED_BYTES = 256 * 1024 * 1024 # GitHub serves files up to 100 MB; far bigger isn't a feed
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _download(url: str, timeout: int = 120) -> str:
|
|
177
|
+
import urllib.request # lazy: the hook never downloads, so it skips this import
|
|
178
|
+
|
|
179
|
+
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
|
180
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310
|
|
181
|
+
data = resp.read(MAX_FEED_BYTES + 1)
|
|
182
|
+
if len(data) > MAX_FEED_BYTES:
|
|
183
|
+
raise ValueError(f"feed is larger than {MAX_FEED_BYTES // (1024 * 1024)} MB; kept the old cache")
|
|
184
|
+
return data.decode("utf-8", "replace")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _meta_path(d: Path) -> Path:
|
|
188
|
+
return d / "meta.json"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def read_meta(d: Path | None = None) -> dict:
|
|
192
|
+
p = _meta_path(d or cache_dir())
|
|
193
|
+
try:
|
|
194
|
+
return json.loads(p.read_text("utf-8"))
|
|
195
|
+
except (OSError, ValueError):
|
|
196
|
+
return {}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _atomic_write(path: Path, text: str) -> None:
|
|
200
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
201
|
+
tmp.write_text(text, "utf-8")
|
|
202
|
+
tmp.replace(path)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def update_feeds(names: list[str] | None = None, *, directory: Path | None = None,
|
|
206
|
+
fetch: Callable[[str], str] | None = None) -> list[FeedResult]:
|
|
207
|
+
"""Download feeds into the cache. A failed feed keeps its previous cache files."""
|
|
208
|
+
fetch = fetch or _download # looked up per call, so tests and the CI smoke job can serve fixtures
|
|
209
|
+
d = directory or cache_dir()
|
|
210
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
211
|
+
meta = read_meta(d)
|
|
212
|
+
selected = names or [n for n, f in FEEDS.items() if f.default]
|
|
213
|
+
results = []
|
|
214
|
+
for name in selected:
|
|
215
|
+
feed = FEEDS.get(name)
|
|
216
|
+
if feed is None:
|
|
217
|
+
results.append(FeedResult(name, False, message="unknown feed"))
|
|
218
|
+
continue
|
|
219
|
+
url = feed.url()
|
|
220
|
+
if url is None:
|
|
221
|
+
results.append(FeedResult(name, False, message=f"skipped: set {ENV_URLHAUS_KEY} to enable"))
|
|
222
|
+
continue
|
|
223
|
+
try:
|
|
224
|
+
urls, hosts = feed.parser(fetch(url))
|
|
225
|
+
if not urls and not hosts:
|
|
226
|
+
raise ValueError("feed parsed to zero entries")
|
|
227
|
+
except Exception as e: # network, HTTP, parse: keep the old cache
|
|
228
|
+
old = meta.get(name, {}).get("fetched_at")
|
|
229
|
+
results.append(FeedResult(name, False, message=f"{type(e).__name__}: {e}; kept cache from {old or 'never'}"))
|
|
230
|
+
continue
|
|
231
|
+
_atomic_write(d / f"{name}.urls.txt", "\n".join(sorted(urls)) + "\n")
|
|
232
|
+
_atomic_write(d / f"{name}.hosts.txt", "\n".join(sorted(hosts)) + "\n")
|
|
233
|
+
meta[name] = {"fetched_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
234
|
+
"source_url": url.replace(os.environ.get(ENV_URLHAUS_KEY) or "\0", "<key>"),
|
|
235
|
+
"urls": len(urls), "hosts": len(hosts), "license": feed.license}
|
|
236
|
+
_atomic_write(_meta_path(d), json.dumps(meta, indent=2))
|
|
237
|
+
results.append(FeedResult(name, True, len(urls), len(hosts)))
|
|
238
|
+
load_index.cache_clear()
|
|
239
|
+
return results
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# ---------------------------------------------------------------- lookup
|
|
243
|
+
|
|
244
|
+
@dataclass
|
|
245
|
+
class FeedHit:
|
|
246
|
+
kind: str # "url" | "host" | "parent"
|
|
247
|
+
feeds: list[str]
|
|
248
|
+
matched: str
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@dataclass
|
|
252
|
+
class FeedIndex:
|
|
253
|
+
urls: dict[str, list[str]] = field(default_factory=dict) # normalized url -> feeds
|
|
254
|
+
hosts: dict[str, list[str]] = field(default_factory=dict) # host -> feeds
|
|
255
|
+
listed_domains: dict[str, list[str]] = field(default_factory=dict) # parent-match ok
|
|
256
|
+
fetched_at: dict[str, str] = field(default_factory=dict)
|
|
257
|
+
|
|
258
|
+
def lookup(self, url: str) -> FeedHit | None:
|
|
259
|
+
if (u := normalize_url(url)) and u in self.urls:
|
|
260
|
+
return FeedHit("url", self.urls[u], u)
|
|
261
|
+
host = host_of(url)
|
|
262
|
+
if not host:
|
|
263
|
+
return None
|
|
264
|
+
if host in self.hosts:
|
|
265
|
+
return FeedHit("host", self.hosts[host], host)
|
|
266
|
+
labels = host.split(".")
|
|
267
|
+
for i in range(1, len(labels) - 1):
|
|
268
|
+
parent = ".".join(labels[i:])
|
|
269
|
+
if parent in self.listed_domains:
|
|
270
|
+
return FeedHit("parent", self.listed_domains[parent], parent)
|
|
271
|
+
return None
|
|
272
|
+
|
|
273
|
+
def stale_feeds(self, hours: float = STALE_AFTER_HOURS, now: float | None = None) -> list[str]:
|
|
274
|
+
now = now or time.time()
|
|
275
|
+
out = []
|
|
276
|
+
for name, ts in self.fetched_at.items():
|
|
277
|
+
try:
|
|
278
|
+
age = now - datetime.fromisoformat(ts).timestamp()
|
|
279
|
+
except ValueError:
|
|
280
|
+
age = float("inf")
|
|
281
|
+
if age > hours * 3600:
|
|
282
|
+
out.append(name)
|
|
283
|
+
return out
|
|
284
|
+
|
|
285
|
+
def __bool__(self) -> bool:
|
|
286
|
+
return bool(self.urls or self.hosts)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _read_set(p: Path) -> list[str]:
|
|
290
|
+
try:
|
|
291
|
+
return [x for x in p.read_text("utf-8").splitlines() if x]
|
|
292
|
+
except OSError:
|
|
293
|
+
return []
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
class LazyFeedIndex:
|
|
297
|
+
"""Same lookups as FeedIndex, without building dicts: searches the cached files directly.
|
|
298
|
+
|
|
299
|
+
For short-lived processes (the enforcement hook runs once per tool call), loading ~400k
|
|
300
|
+
domains into dicts costs ~0.6s; a substring search over the raw file costs a few ms.
|
|
301
|
+
"""
|
|
302
|
+
|
|
303
|
+
def __init__(self, directory: Path | str | None = None):
|
|
304
|
+
self.dir = Path(directory) if directory else cache_dir()
|
|
305
|
+
self.meta = read_meta(self.dir)
|
|
306
|
+
self.fetched_at = {n: i.get("fetched_at", "") for n, i in self.meta.items() if n in FEEDS}
|
|
307
|
+
self._blobs: dict[str, bytes] = {}
|
|
308
|
+
|
|
309
|
+
def _blob(self, name: str, kind: str) -> bytes:
|
|
310
|
+
key = f"{name}.{kind}"
|
|
311
|
+
if key not in self._blobs:
|
|
312
|
+
try:
|
|
313
|
+
data = (self.dir / f"{name}.{kind}.txt").read_bytes().replace(bytes([13]), b"")
|
|
314
|
+
except OSError:
|
|
315
|
+
data = b""
|
|
316
|
+
self._blobs[key] = b"\n" + data + (b"" if data.endswith(b"\n") else b"\n")
|
|
317
|
+
return self._blobs[key]
|
|
318
|
+
|
|
319
|
+
def _has(self, name: str, kind: str, value: str) -> bool:
|
|
320
|
+
return b"\n" + value.encode() + b"\n" in self._blob(name, kind)
|
|
321
|
+
|
|
322
|
+
def lookup(self, url: str) -> FeedHit | None:
|
|
323
|
+
names = list(self.fetched_at)
|
|
324
|
+
if u := normalize_url(url):
|
|
325
|
+
if hit := [n for n in names if self._has(n, "urls", u)]:
|
|
326
|
+
return FeedHit("url", hit, u)
|
|
327
|
+
host = host_of(url)
|
|
328
|
+
if not host:
|
|
329
|
+
return None
|
|
330
|
+
if hit := [n for n in names if self._has(n, "hosts", host)]:
|
|
331
|
+
return FeedHit("host", hit, host)
|
|
332
|
+
listed = [n for n in names if FEEDS[n].hosts_are_listed_domains]
|
|
333
|
+
labels = host.split(".")
|
|
334
|
+
for i in range(1, len(labels) - 1):
|
|
335
|
+
parent = ".".join(labels[i:])
|
|
336
|
+
if hit := [n for n in listed if self._has(n, "hosts", parent)]:
|
|
337
|
+
return FeedHit("parent", hit, parent)
|
|
338
|
+
return None
|
|
339
|
+
|
|
340
|
+
stale_feeds = FeedIndex.stale_feeds
|
|
341
|
+
|
|
342
|
+
def __bool__(self) -> bool:
|
|
343
|
+
return any(len(self._blob(n, k)) > 2 for n in self.fetched_at for k in ("urls", "hosts"))
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
@lru_cache(maxsize=4)
|
|
347
|
+
def load_index(directory: str | None = None) -> FeedIndex:
|
|
348
|
+
d = Path(directory) if directory else cache_dir()
|
|
349
|
+
idx = FeedIndex()
|
|
350
|
+
for name, info in read_meta(d).items():
|
|
351
|
+
feed = FEEDS.get(name)
|
|
352
|
+
if feed is None:
|
|
353
|
+
continue
|
|
354
|
+
idx.fetched_at[name] = info.get("fetched_at", "")
|
|
355
|
+
for u in _read_set(d / f"{name}.urls.txt"):
|
|
356
|
+
idx.urls.setdefault(u, []).append(name)
|
|
357
|
+
for h in _read_set(d / f"{name}.hosts.txt"):
|
|
358
|
+
if feed.hosts_are_listed_domains:
|
|
359
|
+
idx.listed_domains.setdefault(h, []).append(name)
|
|
360
|
+
idx.hosts.setdefault(h, []).append(name)
|
|
361
|
+
return idx
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def current_index() -> FeedIndex:
|
|
365
|
+
"""The cached index, reloaded when `update-feeds` has rewritten meta.json
|
|
366
|
+
(so a long-running MCP server picks up refreshed feeds without a restart)."""
|
|
367
|
+
d = cache_dir()
|
|
368
|
+
try:
|
|
369
|
+
mtime = _meta_path(d).stat().st_mtime
|
|
370
|
+
except OSError:
|
|
371
|
+
mtime = 0.0
|
|
372
|
+
return _index_at(str(d), mtime)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
@lru_cache(maxsize=2)
|
|
376
|
+
def _index_at(directory: str, mtime: float) -> FeedIndex:
|
|
377
|
+
return load_index.__wrapped__(directory)
|
scamgate/guard.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Who may change scamgate's state, and how its records are signed.
|
|
2
|
+
|
|
3
|
+
Overrides and payee history change what scamgate allows, so only a person may write them:
|
|
4
|
+
- `require_human()` refuses when the caller is non-interactive or runs inside a known agent
|
|
5
|
+
(Claude Code, Hermes, Codex). The CLI calls it, and so do the library functions, so an agent
|
|
6
|
+
importing `scamgate.overrides` directly is refused too.
|
|
7
|
+
- Each record is signed with an HMAC key in the state dir (created 0600). A line an agent appends
|
|
8
|
+
with any text editor fails verification and is ignored.
|
|
9
|
+
|
|
10
|
+
This is tamper-evidence against an agent using its normal tools, not protection against code
|
|
11
|
+
running as you: that code can read the key. See README "Limits".
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import hashlib
|
|
17
|
+
import hmac
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import secrets
|
|
21
|
+
import sys
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from .state import ensure_state_dir, state_dir
|
|
25
|
+
|
|
26
|
+
AGENT_ENV_MARKERS = ("CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT", "HERMES_SESSION_ID", "HERMES_GATEWAY_SESSION",
|
|
27
|
+
"HERMES_INTERACTIVE_AGENT", "CODEX_SANDBOX", "CODEX_SANDBOX_NETWORK_DISABLED",
|
|
28
|
+
"CURSOR_AGENT", "SCAMGATE_AGENT")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _interactive() -> bool:
|
|
32
|
+
try:
|
|
33
|
+
return sys.stdin.isatty() and sys.stdout.isatty()
|
|
34
|
+
except (AttributeError, ValueError):
|
|
35
|
+
return False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def agent_marker() -> str | None:
|
|
39
|
+
return next((m for m in AGENT_ENV_MARKERS if os.environ.get(m)), None)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def require_human(action: str, *, interactive=None) -> None:
|
|
43
|
+
if m := agent_marker():
|
|
44
|
+
raise PermissionError(f"{action} must be done by a person, not an agent (running under {m}).")
|
|
45
|
+
if not (interactive or _interactive)():
|
|
46
|
+
raise PermissionError(f"{action} must be run by a person in an interactive terminal.")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _private_write(p: Path, data: str) -> None:
|
|
50
|
+
ensure_state_dir()
|
|
51
|
+
fd = os.open(p, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
52
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
53
|
+
f.write(data)
|
|
54
|
+
try:
|
|
55
|
+
os.chmod(p, 0o600)
|
|
56
|
+
except OSError:
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def create_once(p: Path, data: str) -> None:
|
|
61
|
+
"""Write a new private file only if none exists. Two processes starting together keep one key:
|
|
62
|
+
the full file is written aside, then hard-linked into place, which fails if another got there first."""
|
|
63
|
+
ensure_state_dir()
|
|
64
|
+
tmp = p.with_name(f".{p.name}.{os.getpid()}.{secrets.token_hex(4)}.tmp")
|
|
65
|
+
_private_write(tmp, data)
|
|
66
|
+
try:
|
|
67
|
+
os.link(tmp, p)
|
|
68
|
+
except FileExistsError:
|
|
69
|
+
pass
|
|
70
|
+
except OSError: # no hard links on this filesystem: exclusive create instead
|
|
71
|
+
try:
|
|
72
|
+
fd = os.open(p, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
73
|
+
except FileExistsError:
|
|
74
|
+
pass
|
|
75
|
+
else:
|
|
76
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
77
|
+
f.write(data)
|
|
78
|
+
finally:
|
|
79
|
+
tmp.unlink(missing_ok=True)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _key() -> bytes:
|
|
83
|
+
p = state_dir() / "state.key"
|
|
84
|
+
if not p.exists():
|
|
85
|
+
create_once(p, secrets.token_hex(32))
|
|
86
|
+
return p.read_text("utf-8").strip().encode()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def key_exists() -> bool:
|
|
90
|
+
return (state_dir() / "state.key").exists()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _canonical(rec: dict) -> bytes:
|
|
94
|
+
return json.dumps({k: v for k, v in rec.items() if k != "sig"}, sort_keys=True, separators=(",", ":")).encode()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def sign(rec: dict) -> dict:
|
|
98
|
+
return {**rec, "sig": hmac.new(_key(), _canonical(rec), hashlib.sha256).hexdigest()}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def verify(rec: dict) -> bool:
|
|
102
|
+
sig = rec.get("sig")
|
|
103
|
+
if not isinstance(sig, str) or not key_exists():
|
|
104
|
+
return False
|
|
105
|
+
return hmac.compare_digest(sig, hmac.new(_key(), _canonical(rec), hashlib.sha256).hexdigest())
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def append_private(p: Path, line: str) -> None:
|
|
109
|
+
ensure_state_dir()
|
|
110
|
+
fd = os.open(p, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
|
111
|
+
with os.fdopen(fd, "a", encoding="utf-8") as f:
|
|
112
|
+
f.write(line + "\n")
|
|
113
|
+
try:
|
|
114
|
+
os.chmod(p, 0o600)
|
|
115
|
+
except OSError:
|
|
116
|
+
pass
|