Docs · scamgate
Documentation
Install it, point your agent at it, then make it impossible to skip. Everything here matches the code in the repository.
01Quickstart
Requires Python 3.11+. Installing with uv tool gives you two commands:
scamgate and scamgate-hook.
uv tool install git+https://github.com/odaiameera/scamgate@v0.3.0
scamgate update-feeds # download known-bad-link feeds (the only command that goes online)
scamgate check-url "https://paypa1.com/login" --intent "buy a ticket"
Or with pip: pip install "git+https://github.com/odaiameera/scamgate.git@v0.3.0". To hack on it,
clone the repo and run uv sync, then prefix commands with uv run.
The verdict is JSON: a decision, a risk_score, every reason with its
points, a one-line summary, any warnings (for example, no feeds cached yet) and an
advisory note. See real verdicts on the home page.
02How it decides
- Deterministic rules, no LLM. The same input always gets the same verdict.
- Points add up. Each rule that fires adds points and a plain-English reason. The total is capped at 100.
- Thresholds: under 25 is allow, 25 to 69 is ask, and 70 or more is deny.
- Offline. Checks read a local feed cache. Nothing is sent anywhere.
allow means no known red flags, not “safe”. The full rule list with current weights is in Rules.
03Mechanics
hook.handle(payload) is the enforcement function. The shell hook, scamgate hook, and the Hermes plugin all call it. MCP check_url and check_payment do not: those run only if the agent calls them.
Only pre-events are gated: Hermes pre_tool_call and Claude Code PreToolUse. A missing event name is treated as a pre-event. Post-events only record whether the call ran. Any other event returns {}.
Order inside handle()
- Integrity. A wheel install is checked against its RECORD hashes and unexpected
.pthfiles. A mismatch blocks every gated call. An editable or unpackaged install reportsdevand continues. A directory plugin is not a wheel, so this check does not cover those files. - Own check tools. A tool name containing
scamgateandcheck_urlorcheck_paymentreturns silence. Asking the gate about a link must not be blocked for naming it. - Tamper. Writes to the package, state dir, feed cache, the install's site-packages and scripts, Claude settings, and Hermes config, hooks, plugins and profiles are blocked. So are commands that remove the hook, uninstall scamgate, or call
promote/record-payment. Paths are normalised:~,$HOME,%USERPROFILE%, git-bash/c/Users. - Extra checks (
harden.gate_extra). Readingstate.key,payees.salt, or the state directory is blocked. Safety switches in non-quiet tool arguments are blocked:hermes config set approvals…,hermes config edit,hermes plugins uninstall|disable|remove,--yolo,HERMES_YOLO_MODE=,--safe-mode,--ignore-user-config,--dangerously-skip-permissions,bypassPermissions, Codex's approval bypass, andset_config_value(…, approvals…). Quiet tools (notes, chat) are not scanned, so quoting the command in a message is fine.hermes plugins install|enableandhermes -p/--profileask. - Files other tools will run. A write or patch of a Makefile,
package.json, script, git hook, CI workflow, or$HERMES_HOME/scriptsfile is scored for literal URLs. Decode-and-run asks. A shell script with a network command and a hidden destination asks. A.mdnote that quotes a scam URL is not a visit. - Cron scripts.
scriptandmonitor_scriptare read from$HERMES_HOME/scriptswhen the job is created, becauseno_agentjobs run later with no hook. A missing file asks. A readable clean script is silent. - Write-then-run.
bash,sh,source,.,python,node,pwsh -File,uv runand./file.share resolved againstworkdirand anycd, then read. Nested calls are followed to depth 4, up to 256 KB. Too big to read asks. A path that does not exist is silent.bash -candpython -mare inline, not file reads. - Extract and score.
https:/hostandhttps:///hostare rewritten tohttps://hostbefore matching. Hosts are NFKC-normalised, percent-decoded, and numeric IPv4 (3405803781,0xcb007105) becomes a dotted quad. Commands are split on newlines,;,&&,||and|, and each segment is judged alone. Text-only commands (grep,echo,git log) are skipped unless they substitute a command. Lockfile installs are skipped; a URL inside them is still checked. Each URL is scored with and without the agent's stated reason; the higher score wins. Deny beats ask beats silence. More than 20 clean URLs asks. No feeds cached asks, unlessSCAMGATE_NO_FEEDS=warn. A destination that cannot be read asks. In program text, only a literal URL counts as readable. - Merge. The extra-check result replaces the URL result only if it is strictly worse. A payment result wins a tie, so the message says it is a payment. A URL deny still beats a payment ask. Silence is
{}, not allow.
What the harness receives
// Hermes. approve means ask the human. rule_key is the "always" grain.
{"action": "block"|"approve", "message": "…", "rule_key": "scamgate:host:…"}
// Claude Code
{"hookSpecificOutput": {"hookEventName": "PreToolUse",
"permissionDecision": "deny"|"ask", "permissionDecisionReason": "…"}}
rule_key is the host plus the rule ids that fired, or a hash of the opaque command. One "always" does not cover another host. The hook never emits allow, so it cannot loosen a harness that already restricts the tool.
Which arguments are scanned
| Class | Examples | What is read |
|---|---|---|
| URL tools | browser_navigate, web_extract | URL fields, and any URL elsewhere in the arguments |
| Code tools | terminal, execute_code, powershell | command / code, then any script file that command runs |
| Write tools | write_file, patch | The path, always. The body, if the path is a file something else will run |
| Quiet tools | memory, send_message, web_search | Not a visit. read_file / search_files of the state dir is still blocked |
| Unknown MCP | mcp__server__tool | Any URL in the arguments. Network-ish names also get their code-like fields scanned |
Plugin and shell hook
Same handle(). The shell hook is one process per call, about 0.5–0.7 s here. The plugin calls it in-process, about 10 ms on the same Windows machine, one benchmark, not a guarantee. Needs Hermes 0.19.1+.
Hermes auto-approves an approve when /yolo, HERMES_YOLO_MODE, or approvals.mode: off is on. The plugin sees that session state and turns the ask into a block, unless plugins.entries.scamgate.settings.when_approvals_off is allow. The shell hook cannot: it only sees the command string, and Hermes approves the ask for it.
What this does not see
- A redirect after the request has already been sent.
transform_tool_resultis not wired. - The behaviour of a compiled binary.
- A script that is not named in the tool call, and is not called from one that is.
- Same-user code outside the agent. It can read
state.keyor rewrite installed files. The signature is tamper-evidence, not isolation. - A chat where the hook or plugin is not installed, or has been disabled outside the paths above.
04Add it to your agent
scamgate runs as an MCP server over stdio with scamgate serve.
Hermes
# ~/.hermes/config.yaml
mcp_servers:
scamgate:
command: "scamgate"
args: ["serve"]
Claude Code
claude mcp add scamgate -- scamgate serve
Claude Desktop
{
"mcpServers": {
"scamgate": { "command": "scamgate", "args": ["serve"] }
}
}
Running from a source checkout instead? Use uv with
["--directory", "/path/to/scamgate", "run", "scamgate", "serve"].
Then tell the agent: “Call check_url before opening any link and check_payment
before any payment. On ask, stop and show me the reasons. On deny, don't proceed.” That's a request, not
a guarantee. To guarantee it, add the hook.
05Enforce it with a hook
The MCP tools only work when the agent chooses to call them, and a prompt injection can tell it not to.
scamgate-hook runs in the harness instead: it reads one hook event as JSON on stdin and writes a
decision on stdout. It speaks Hermes shell hooks and Claude Code PreToolUse/PostToolUse.
- Links
- Every URL in a web, browser, image/video, terminal, code or unknown MCP tool call is checked, including disguised forms (
evilsh%6fp.com,http://3405803781). Deny blocks the call; ask sends it to you. - Payments
- Money-moving tool calls (wallet, banking or payments MCP tools, or any tool whose arguments carry an amount and an IBAN, wallet or account) get the same check as
check_payment, with your payee history. A payee or amount it can't read, crypto CLIs (cast send,bitcoin-cli sendtoaddress) and write calls to payment APIs (Stripe, PayPal, Wise…) go to you. - Hidden code
- Code that's decoded or downloaded and then run (
… | base64 -d | bash,exec(b64decode(…)),curl … | sh,powershell -enc) goes to you, network keyword or not. - Hidden destinations
- A network step scamgate can't read goes to you, judged per command, so a harmless link can't hide a second fetch:
curl "$URL",$(…), string-built, base64/hex-decoded or reversed links, installs of named packages,git clone, new git remotes. In code, only a literal URL counts as readable. - Quiet dev work
- Installs from a lockfile, manifest or local path (
pip install -r requirements.txt,uv sync,npm ci),git push/pull/fetchto remotes you already have, and commands that only read text pass silently. Any URL in them is still checked. - Bulk
- Any known-bad link blocks the whole call. Otherwise more than 20 links in one call goes to you.
- Running blind
- With no feeds cached, links go to you (
SCAMGATE_NO_FEEDS=warnrelaxes this). Stale feeds are mentioned in every ask and block. - Stated reason
- The agent's reason is scored with and without it; the higher score wins, so it can only add risk.
- Narrow approvals
- Approval keys include the host and the rules that fired (or a hash of the hidden command), so one “always” can't cover other destinations.
- Tampering
- Blocks agent writes (editors like vim/ed/nano,
find -exec,rsync,patchincluded) to scamgate's code, state, feed cache and virtualenv (a planted.pthfile also fails the integrity check), to~/.claudeand project.claude/settings*.json, and to the Hermes config, however the path is spelled (~,$HOME, globs,cd+ relative). Also blocks commands or code that revoke the hook, remove the server, promote rules, record payees or reach scamgate's state APIs. - Fails closed
- Bad input, input over 2 MB, deep nesting or an internal error blocks the call with exit code 2.
- Never says allow
- Silence means “no objection”. The hook can only tighten your agent's permissions.
Hermes
# ~/.hermes/config.yaml
hooks:
pre_tool_call:
- matcher: ".*" # non-network tools pass straight through
command: "scamgate-hook"
timeout: 15
fail_closed: true
post_tool_call: # optional: records whether you approved each ask
- matcher: ".*"
command: "scamgate-hook"
timeout: 15
Hermes asks you to approve a new hook the first time it runs in an interactive session. Check it with
hermes hooks list or hermes hooks doctor. You need a Hermes build whose shell hooks
understand "action": "approve"; older builds dropped it, turning every ask into a pass (issue #92553,
Sep 2026). With the shell hook, /yolo or approvals.mode: off auto-approves every ask; blocks still hold.
The Hermes plugin turns those asks into blocks unless when_approvals_off is allow.
Hermes plugin — available in the catalog
scamgate is now a community plugin in the Hermes Plugin Catalog. The catalog contribution was merged by Teknium. It is an optional install, not a built-in Hermes feature.
This is the preferred Hermes integration: the same checks run in-process, and the plugin can see a
per-chat /yolo. Needs Hermes 0.19.1+ and a catalog containing the scamgate entry.
hermes plugins install scamgate
hermes plugins enable scamgate
Run these in your own terminal and follow the installer prompts. The catalog installs its reviewed, pinned commit, not the latest repository head. See the Hermes catalog guide for installation and updates.
The plugin registers pre_tool_call and post_tool_call,
scamgate_check_url, scamgate_check_payment and /scamgate status.
You do not need the separate shell hook for the same Hermes instance.
ask becomes a block by default;
plugins.entries.scamgate.settings.when_approvals_off: allow restores asks, which Hermes may auto-approve.
State and feed caches are shared across profiles by default. Feed downloads are explicit;
optional SCAMGATE_RDAP=live sends checked domains to their RDAP registries.
Read Mechanics, Feeds and Limits before use.Claude Code
Needs Claude Code v2.1.295+ for onFailure. Without it, a hook that crashes, times out
or can't start lets the tool call through.
// .claude/settings.json
{"hooks": {
"PreToolUse": [{"matcher": ".*",
"hooks": [{"type": "command", "command": "/absolute/path/to/scamgate-hook",
"timeout": 15, "onFailure": "block"}]}],
"PostToolUse": [{"matcher": ".*",
"hooks": [{"type": "command", "command": "/absolute/path/to/scamgate-hook"}]}]
}}
Use the absolute path (uv tool dir --bin shows it) so nothing earlier on PATH can stand in for the hook.
uv tool install git+https://github.com/odaiameera/scamgate@v0.3.0,
not a source checkout. An installed copy checks its own files against the hashes recorded at install time on
every call, and blocks everything if they changed. A source checkout has nothing to check against.Each gated call costs one process start, about 0.7 s on Windows. Calls that don't touch the network or scamgate's files pass straight through.
06Ask log, review & promote
Every ask and deny is logged to the state folder with redacted targets (no passwords, query values, card numbers, IBANs, emails or keys), the rules that fired, the score, the agent's stated reason and whether you then let the call run. The stated reason is stored for you to read and marked untrusted: an injected page can write it. In scoring it can only add risk, never remove it.
scamgate review --days 7 # which hosts keep tripping it, and what you did
scamgate promote --host shop.example --decision deny
scamgate promote --host shop.example --decision allow --rule url.userinfo
promoteshows the exact rule and asks for confirmation. It refuses to run without an interactive terminal or inside a known agent (Claude Code, Hermes, Codex), even if an agent imports scamgate's code directly.- Every override line is signed with a key in the state dir. A line added to the file any other way is ignored.
- A deny covers the host and all its subdomains.
- An allow is narrow: it waives one rule on one exact host. It can never waive known-bad feeds, your blocklist, intent checks or your own deny rules, so an attacker can't train the gate into trusting a whole domain.
07Payee history
check_payment doesn't take the agent's word for is_new_payee. It looks the payee
up in a local history of people you've actually paid, stored as salted hashes, never names or account numbers.
- Never paid: treated as a new payee, whatever the agent said. If it claimed otherwise, that adds risk.
- Paid before, same account: a known payee.
- Paid before, different account: treated as changed bank details, the invoice-redirection pattern.
- Near-name, new account (“Acme Trading Ltd”, “Acmee Ltd”, a Cyrillic “е”): also changed bank details.
- An account you've paid under a different name: flagged.
- History key or salt missing: every payee is treated as new with changed details, not silently forgotten.
scamgate record-payment --payee "Acme Ltd" --account IE29AIBK93115212345678
Only you add to it, after a real payment, the same way as overrides: in an interactive terminal, not inside an agent, with signed lines.
08Rules
Current rules and points, read from src/scamgate/config/rules.yaml when this site was built.
| Loading the rule list… |
Feed matches: an exact listed URL scores 100, an exact listed host 75, and a subdomain of a listed domain
(domain feeds only) 50. Brand look-alikes score 60, a brand name sitting in an unrelated domain 45, and a real
brand domain used as a prefix (paypal.com.secure-login.xyz) 80.
09Known-bad-link feeds
Only scamgate update-feeds touches the network. Checks read the local cache. No feed data is
bundled. A failed download keeps that feed's previous cache, and verdicts warn when no feed is cached or a feed
is older than 24 hours. A running MCP server picks up a refreshed cache without a restart.
| Feed | Default | Terms |
|---|---|---|
openphish | yes | OpenPhish terms: personal, non-commercial use only, no redistribution |
phishing_database_domains | yes | Phishing.Database, MIT |
phishing_database_links | --all | Phishing.Database active links (~65 MB), MIT |
urlhaus | if key set | abuse.ch URLhaus: free Auth-Key required (SCAMGATE_URLHAUS_AUTH_KEY), fair-use terms |
You're responsible for following each feed's terms. Refresh on a schedule, for example every 6 hours:
0 */6 * * * scamgate update-feeds >> ~/.cache/scamgate/update.log 2>&1
False positives on shared platforms
Feeds list single bad pages on big shared platforms (Google Docs and Sites, Microsoft login, Discord CDN,
link shorteners, Vercel). For hosts under lists.feed_host_ignore and official brand domains, only
an exact URL listing counts, so one phishing page on x.vercel.app never flags
vercel.app. The trade-off: a scam on a subdomain of an ignored platform is only caught by exact URL.
10Configuration
Weights, thresholds, brand lists and word lists live in
rules.yaml.
Your own blocklist is $SCAMGATE_BLOCKLIST (example: src/scamgate/data/blocklist.txt); a listed domain also blocks its subdomains.
| Variable | What it sets |
|---|---|
SCAMGATE_CONFIG | Path to your own rules.yaml |
SCAMGATE_BLOCKLIST | Path to your own blocklist |
SCAMGATE_CACHE_DIR | Feed cache. Default $XDG_CACHE_HOME/scamgate, else ~/.cache/scamgate |
SCAMGATE_STATE_DIR | Ask log, overrides, payee history. Default $XDG_STATE_HOME/scamgate, else ~/.local/state/scamgate |
SCAMGATE_URLHAUS_AUTH_KEY | Enables the URLhaus feed |
SCAMGATE_PROTECT | Extra paths the hook should protect from agent writes (path-separator list) |
SCAMGATE_RDAP | off (default), cache (cached domain ages only) or live (look up new domains over RDAP and cache them) |
SCAMGATE_NO_FEEDS | ask (default): with no feeds cached, links go to you. warn: only mention it |
To add a rule, write a function in src/scamgate/rules/ decorated with
@register("url.my_rule", kind="url"), give it an entry under rules: in
rules.yaml, and add tests and a scenario.
11CLI
| Command | What it does |
|---|---|
check-url URL [--intent] | Check one link and print the verdict as JSON |
serve | Run the MCP server over stdio |
update-feeds [-f NAME] [--all] | Download feeds into the cache; failed feeds keep their old cache |
feeds-status | Cached feeds, entry counts and age |
hook | Same as scamgate-hook: one hook event on stdin, decision on stdout |
review [--days] [--limit] | Hosts that keep tripping the gate, and what you did with each ask |
promote --host --decision [--rule] | Add a human-confirmed allow or deny override |
record-payment --payee --account | Record a payee you've actually paid |
bench [--strict] [-v] | Run the example scenarios and report caught, missed and false positives |
12MCP tools
check_url(url: str, intent: str | None = None) -> Verdict
check_payment(payee_name: str, payee_account: str, amount: float, currency: str,
method: "card" | "bank_transfer" | "crypto" | "gift_card" | "other",
context_text: str | None = None, is_new_payee: bool | None = None,
bank_details_changed: bool | None = None, budget: float | None = None,
vop_result: "match" | "close_match" | "no_match" | "unavailable" | None = None) -> Verdict
Verdict = decision, risk_score, reasons[]
(rule_id, explanation, weight), summary,
warnings[], advisory.
13Limits
- Advisory over MCP. Without the hook, the agent has to call it and respect the answer. Even with the hook it only sees tool calls: a redirect after the page opens, or a destination hidden in a variable, gets your approval rather than a check.
- Heuristics miss things. Rules miss scams that don't match them and sometimes flag legitimate actions.
- Known trade-offs: a word one letter off a long brand (
stripes.com) still asks. A bank transfer to a new payee asks (25) unless Verification of Payee matched the name. - Arguments, plus scripts it can open. The hook reads tool arguments, and a script file the command names, plus scripts that file calls, a few levels deep. A compiled binary, a file that is not named, or a redirect after the request has already left is not inspected. Decode-and-run still asks even when the payload is not a file.
- Domain age needs opting in (
SCAMGATE_RDAP=cache|live). The brand pack covers about 50 brands; the suffix list is curated, not the full PSL. - Payee history only knows payees you've recorded. scamgate doesn't verify IBANs or call your bank.
- Not a sandbox. Tamper protection stops an agent's normal tools. Anything with full control of your account can still change scamgate's files, and code can be written to slip past pattern matching. For the strongest setup, give the agent no direct shell access.
- Hermes trusts a hook by its command string, not its file hash. The integrity check covers edits to the installed files.
scamgate isn't financial, legal or security advice, and doesn't replace your bank's fraud controls, Verification of Payee, or your own judgement.
14Roadmap
- IBAN checksum validation and Verification of Payee integration
- Redirect after-check: re-check the page a browser actually landed on
- Optional local model as a tie-breaker on vague cases only, never overruling a hard rule
- Full Public Suffix List (optional, cached)
15Contributing
Issues and pull requests are welcome, especially new rules, scenarios (scam and legit) and false-positive
reports. Include a test or scenario with each rule change, and keep checks offline and deterministic.
scamgate bench --strict runs 22 hand-written example scenarios (15 scam, 7 legit): a regression
check, not a real-world detection rate.
git clone https://github.com/odaiameera/scamgate && cd scamgate
uv sync
uv run pytest
uv run scamgate bench --strict
See AGENTS.md for repo conventions.