| Version | Security fixes |
|---|---|
| 1.6.x (latest) | Yes |
| 1.5.x and older | No. Upgrade; the report format is compatible (see STABILITY.md) |
Pin an exact tag in CI (jianruntech/geo-score@v1.6.0) if you need a fixed version, and move
the pin when a security release comes out. Every security fix is listed under Security in the
changelog.
A scoring rubric, a skill definition, a stdlib-only CLI, a GitHub Action and a stdio MCP server. There is no hosted service and nothing is sent to Jianrun: no telemetry, no update checks.
- Level 1 (the score) fetches the site you name, following its redirects; the URLs that site's own
markup and robots.txt point to (the
sameAsprofiles, the logo, the declared sitemap), which can be on any host; and Wikidata and Wikipedia search. It needs no key and writes only the files you ask for. - Levels 2 and 3 (
--ask,watch) call the AI providers whose keys you set, and writegeo-score-watch.json,queries.csvand.geo-score/in a directory you pick.
Two inputs are treated as adversarial, because neither is under the user's control:
- The audited site. Its pages, robots.txt, sitemap, llms.txt and JSON-LD can say anything, including text written to steer an AI agent that reads the report.
- AI answers. An engine's answer can quote hostile pages it found while searching.
What the code does about it:
- Quoted text is fenced. Evidence that quotes the site (an answer passage, the brand name read
from the page, a Wikidata description) carries it inside
«site text: …». Control characters, zero-width characters and bidi overrides are removed from every evidence string and from quoted AI answers, and a site cannot close the fence early. The MCP tools say that fenced text is data. - Nothing a page says changes what the tool does. Caps, keys, output paths and the question set come from the user's command line and config, never from fetched content.
- Under MCP, every connection goes to a public address. The scored URL, each redirect hop and every
URL the audited site points to (sameAs, logo, sitemap) must resolve to a globally routable address;
loopback, private, link-local, carrier-grade NAT (where cloud metadata services such as 100.100.100.200
live) and reserved ranges are refused. A direct connection goes to the address that was checked, so a
name cannot resolve to something else a moment later. Through an HTTP proxy the proxy resolves names,
and that last step is the proxy's. NAT64 and 6to4 prefixes, which carry an IPv4 address inside, are
refused too. This covers
score_site;askandruncall only the AI providers configured ingeo-score-watch.json, whosebase_urlis the user's choice. The command-line tool applies none of this: it scores whatever its user names, intranet sites included. - Keys stay out of reach. Keys are read from environment variables only. They are never written
to the config, run files or reports; a key that appears in a provider's error message is replaced
with
[redacted](including escaped and split forms) before it is stored or printed; a malformed key is refused without being echoed. - Provider calls never follow redirects. A redirecting endpoint is reported as an error rather
than handed the key. A custom
base_urlmust behttps://(orhttp://localhost). - An agent can only lower spending. Over MCP,
max_callsandbudget_usdcan be lowered, never raised, and run ids are validated before any file is read. - The GitHub Action passes inputs as environment variables, never pasted into the script, so a crafted URL or path cannot run shell commands in your workflow.
- CSV output escapes cells a spreadsheet would treat as formulas.
- Piped installs load nothing extra.
curl … | python3 -runs level 1 only and never imports ageo_watch.pyfrom the current directory.
geo_score.py mcp (installed as geo-score-mcp) talks MCP over stdio to the client that started it and
opens no port. stdout carries MCP messages only; diagnostics go to stderr. What each tool touches:
| Tool | Network | Reads | Writes | Spends |
|---|---|---|---|---|
score_site |
The named public site, the URLs its markup and robots.txt point to, Wikidata and Wikipedia search | Nothing | Nothing | Nothing |
ask |
The AI providers whose keys the server has | geo-score-watch.json, if there is one |
Nothing | One call per engine |
run |
The AI providers in geo-score-watch.json |
The config and queries.csv |
A run file in .geo-score/watch/runs/, never for a cancelled run |
Up to max_calls; budget_usd plus at most one call per engine |
list_runs, report, diff |
None | The config and saved runs | Nothing | Nothing |
status |
None | The config, queries.csv, the run directory, and whether each key variable is set (never its value) |
Nothing | Nothing |
- Annotations are hints, not guarantees. Every tool declares
readOnlyHint,destructiveHint,idempotentHintandopenWorldHint, and some clients approve read-only tools without asking.askandrunare marked as not read-only so that clients ask before they spend: keep that confirmation on. - A server that cannot spend. Start it with
--read-only(orGEO_SCORE_MCP_READ_ONLY=1) to dropaskandrun, or with--toolsets scoreto serve scoring only. Neither can be undone by an agent. - Cancelling stops spending. On
notifications/cancelled,askandrunmake no further calls. A call already sent completes and is billed (at most one per engine); a cancelled run is not saved, and its calls and cost are printed to stderr. Nothing more is sent for a cancelled request. At end of input the paid calls stop the same way. Onerunat a time: a second is refused while one is in progress. - Where the keys live. The server reads keys from its environment only. A key written into a
client's config file (an
envblock) sits there in plain text. The Claude Desktop bundle declares its key fields as sensitive, so the host masks them and keeps them as secrets; the Claude Code plugin maps no keys and uses the variables exported in your shell. - User agents.
score_sitefetches with a desktop browser's user agent and repeats the reachability check with the user agents of the ten retrieval crawlers in reference/ai-crawlers.md (OAI-SearchBot, ChatGPT-User, Claude-SearchBot, Claude-User, PerplexityBot, Perplexity-User, Googlebot, Bingbot, Applebot and Amazonbot), sent from the machine running it. Provider calls sendgeo-score-watch/<version>. - The address guard, the fencing of site text and the spending caps above apply to every MCP call.
Every release attaches SHA256SUMS covering geo_score.py, geo_watch.py, the wheel and the sdist,
and from 1.4.0 the Claude Desktop bundle geo-score-X.Y.Z.mcpb and the MCP Registry's server.json.
Download it with the files and check them before running anything:
v=v1.6.0
base=https://github.com/jianruntech/geo-score/releases/download/$v
curl -sSfLO "$base/geo_score.py" -O "$base/geo_watch.py" -O "$base/SHA256SUMS"
grep -E ' geo_(score|watch)\.py$' SHA256SUMS | shasum -a 256 -cThe .mcpb is a zip of the same two files (under server/), its manifest and the licence, built
reproducibly by scripts/release.py. Check it before you open it in Claude Desktop, check that
server.json names the same checksum (the registry and the clients that install from it rely on
fileSha256), and check that its server/ files are the released ones byte for byte:
mcpb="geo-score-${v#v}.mcpb"
curl -sSfLO "$base/$mcpb" -O "$base/server.json"
grep -E " ($mcpb|server\.json)\$" SHA256SUMS | shasum -a 256 -c
grep -F "\"fileSha256\": \"$(shasum -a 256 "$mcpb" | cut -d' ' -f1)\"" server.json
for f in geo_score.py geo_watch.py; do unzip -p "$mcpb" "server/$f" | cmp - "$f" && echo "server/$f: same"; doneFrom 1.5.0 the wheel and the sdist are also on PyPI as geo-score, and they are the same files:
1.5.0's were checked against its SHA256SUMS once uploaded, and from 1.6.0 scripts/release.py uploads
the ones SHA256SUMS names and stops unless PyPI serves each with that SHA-256. To check the wheel pip
downloads from PyPI against the release:
python3 -m pip download --no-deps --only-binary :all: -d . "geo-score==${v#v}"
grep -E " geo_score-${v#v}-py3-none-any\.whl\$" SHA256SUMS | shasum -a 256 -cThe one-line install in the README downloads from a release tag, never from main, so the code you
run is the code that was released.
Run files hold your questions and the full text of the AI answers, and queries.csv holds the
question list. If either is confidential, keep .geo-score/ and queries.csv out of public
repositories (the included .gitignore covers .geo-score/), and note that the weekly workflow in
examples/ci/ commits run files on purpose: use it in a private repository.
Other realistic risks:
- You choose what gets fetched. Only audit sites you are authorised to audit. Auditing at volume can look like scraping to the target.
- Reports contain data from the target site. Treat a report as you would any document about a client.
Email hello@jianruntech.com rather than opening a public issue. Include the version
(geo-score --version), what an attacker controls, and what they gain. We acknowledge within five
working days, agree a disclosure date with you, and credit you in the changelog unless you prefer
not to be named.