scamgate

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()

  1. Integrity. A wheel install is checked against its RECORD hashes and unexpected .pth files. A mismatch blocks every gated call. An editable or unpackaged install reports dev and continues. A directory plugin is not a wheel, so this check does not cover those files.
  2. Own check tools. A tool name containing scamgate and check_url or check_payment returns silence. Asking the gate about a link must not be blocked for naming it.
  3. 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.
  4. Extra checks (harden.gate_extra). Reading state.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, and set_config_value(…, approvals…). Quiet tools (notes, chat) are not scanned, so quoting the command in a message is fine. hermes plugins install|enable and hermes -p / --profile ask.
  5. Files other tools will run. A write or patch of a Makefile, package.json, script, git hook, CI workflow, or $HERMES_HOME/scripts file is scored for literal URLs. Decode-and-run asks. A shell script with a network command and a hidden destination asks. A .md note that quotes a scam URL is not a visit.
  6. Cron scripts. script and monitor_script are read from $HERMES_HOME/scripts when the job is created, because no_agent jobs run later with no hook. A missing file asks. A readable clean script is silent.
  7. Write-then-run. bash, sh, source, ., python, node, pwsh -File, uv run and ./file.sh are resolved against workdir and any cd, 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 -c and python -m are inline, not file reads.
  8. Extract and score. https:/host and https:///host are rewritten to https://host before 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, unless SCAMGATE_NO_FEEDS=warn. A destination that cannot be read asks. In program text, only a literal URL counts as readable.
  9. 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

ClassExamplesWhat is read
URL toolsbrowser_navigate, web_extractURL fields, and any URL elsewhere in the arguments
Code toolsterminal, execute_code, powershellcommand / code, then any script file that command runs
Write toolswrite_file, patchThe path, always. The body, if the path is a file something else will run
Quiet toolsmemory, send_message, web_searchNot a visit. read_file / search_files of the state dir is still blocked
Unknown MCPmcp__server__toolAny 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_result is 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.key or 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/fetch to 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=warn relaxes 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, patch included) to scamgate's code, state, feed cache and virtualenv (a planted .pth file also fails the integrity check), to ~/.claude and 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.

Before enablingAgent tool calls that edit Hermes config, profiles, plugins or hooks, or disable approvals, are blocked. With approvals off, an 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.

Install a pinned copyRun the hook from 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
  • promote shows 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.

FeedDefaultTerms
openphishyesOpenPhish terms: personal, non-commercial use only, no redistribution
phishing_database_domainsyesPhishing.Database, MIT
phishing_database_links--allPhishing.Database active links (~65 MB), MIT
urlhausif key setabuse.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.

VariableWhat it sets
SCAMGATE_CONFIGPath to your own rules.yaml
SCAMGATE_BLOCKLISTPath to your own blocklist
SCAMGATE_CACHE_DIRFeed cache. Default $XDG_CACHE_HOME/scamgate, else ~/.cache/scamgate
SCAMGATE_STATE_DIRAsk log, overrides, payee history. Default $XDG_STATE_HOME/scamgate, else ~/.local/state/scamgate
SCAMGATE_URLHAUS_AUTH_KEYEnables the URLhaus feed
SCAMGATE_PROTECTExtra paths the hook should protect from agent writes (path-separator list)
SCAMGATE_RDAPoff (default), cache (cached domain ages only) or live (look up new domains over RDAP and cache them)
SCAMGATE_NO_FEEDSask (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

CommandWhat it does
check-url URL [--intent]Check one link and print the verdict as JSON
serveRun the MCP server over stdio
update-feeds [-f NAME] [--all]Download feeds into the cache; failed feeds keep their old cache
feeds-statusCached feeds, entry counts and age
hookSame 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 --accountRecord 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.