SafeEasyAPI (safeeasyapi)
The safe tunnel for API keys. Connecting a website tool usually means 40
minutes of hunting through dashboards, imprecise docs, and fumbling a
secret into the right file. safeeasyapi turns it into one command:
safeeasyapi connect github
It opens the exact key page, walks the precise saved steps, takes the key
with hidden input, validates it with a test API call, and writes it
straight to .env with owner-only permissions — or, with
--store keychain, into your OS keychain. The key is never displayed,
never logged, never in shell history.
Install
Zero dependencies — Python 3.10+ stdlib only.
pip install git+https://github.com/calasdan-sketch/safeeasyapi
safeeasyapi --version
That installs the safeeasyapi command (py -m safeeasyapi / python -m safeeasyapi works too). From a checkout: pip install .
Windows: run it from PowerShell or Windows Terminal. Under Git Bash /
mintty the hidden key prompt (getpass) may echo what you type or appear
to hang, because mintty is not a Windows console; if you must use Git
Bash, prefix the command with winpty (winpty safeeasyapi connect stripe).
Commands
safeeasyapi list # services with saved recipes
safeeasyapi connect <service> # guided key setup -> .env
safeeasyapi connect <service> --env-file /path/to/.env
safeeasyapi connect <service> --store keychain # OS keychain instead of .env
safeeasyapi connect <service> --clipboard # copy-and-go: click Copy, no pasting
safeeasyapi connect github --via-cli # zero-copy: the vendor CLI's browser sign-in
safeeasyapi connect <service> --no-browser # don't open the key page (its address is printed)
safeeasyapi run <service> -- <command> # run with a keychain-stored key in its env
safeeasyapi rotate <service> # replace a stored key
safeeasyapi forget <service> # remove the key: OS keychain entry + .env line(s)
safeeasyapi add # capture a new service recipe interactively
safeeasyapi find <query> # find free public APIs for a task
safeeasyapi find <query> --category <name> # only that category (part of its name)
safeeasyapi find <query> --refresh # download the directory again now
safeeasyapi mcp # stdio MCP server (for Claude Code)
Output can be piped (safeeasyapi list | head -1): when the reader
stops early the command ends quietly with exit status 1 — no traceback,
no "Exception ignored" noise at exit. On Windows Python reports that
closed pipe as a plain OSError 22 instead of BrokenPipeError (a
short output only fails when it is flushed); that is recognised too, for
every command and for the MCP server.
A connect, annotated
$ safeeasyapi connect stripe
Connect Stripe (key opens in your browser) # 1. your browser opens
# the exact key page
1. If it asks, make sure you are in the right mode: Test keys start
with sk_test_, live keys with sk_live_
...
Key page: https://dashboard.stripe.com/apikeys
This key will be validated with: GET https://api.stripe.com/v1/balance
# 2. EXACT validation
# endpoint shown BEFORE
# you ever paste the key
Paste your API key (hidden): # 3. hidden paste —
validating… # key never displayed
✓ added STRIPE_SECRET_KEY in .env (mode 0600 - never displayed, never logged)
(On Windows the line says owner-only ACL instead of mode 0600 — see
Where the key goes.)
--no-browser (for connect and rotate) does not open the key page in
your browser. Its address is still printed under Key page:, so you can
open it yourself: on another machine, over SSH, in a container.
The validation preview is the security hinge: you see exactly where the
key will be sent, and the recipe must use https:// for both the key page
and the validation URL (a plain-http:// recipe is rejected). If the
validation URL belongs to a different owner than the key page,
safeeasyapi stops and requires you to type yes — anything else aborts,
and no key is ever asked for or sent:
This key will be validated with: GET https://third-party-validator.example/check
⚠ The validation URL is on a DIFFERENT domain than the key page above (third-party-validator.example vs acme.test).
Your key will be sent to both domains. Make sure you trust both.
Type 'yes' to continue: # Enter alone aborts
"Different owner" means a different registrable domain, not a
different hostname: dashboard.stripe.com and api.stripe.com are both
stripe.com, so Stripe, GitHub (github.com / api.github.com) and
Resend connect without a prompt — the transcript above is the whole
flow. Multi-part public suffixes are understood (a.co.uk and b.co.uk
are two owners; so are a.github.io and b.github.io, a.blogspot.com and
b.blogspot.com, a.run.app and evil.run.app). Case, a trailing
dot, a port and user@ in front of the host never change the answer, and
names with non-English letters are compared in their xn-- (punycode)
form, so bücher.example and xn--bcher-kva.example are one owner. When
the domains differ and either one is such a name, the warning shows both
spellings (аpple.com [xn--pple-43d.com] vs apple.com, where the first
а is Cyrillic) so a look-alike letter cannot hide. A few vendors
genuinely keep their API on a second domain (Anthropic's console is
platform.claude.com, its API api.anthropic.com; Mailgun .com /
.net; Google AI Studio / googleapis.com; Notion .so / .com):
their built-in recipes name that host in validate.trusted_hosts
and connect without a prompt, with a one-line note. Only recipes shipped
inside the package get that shortcut — a recipe from
~/.safeeasyapi/recipes (yours, or one somebody sent you) always prompts
when the domains differ, whatever it claims.
Which names count as "anyone can register a sub-domain here" is a compact
snapshot, not the full Public Suffix List: country registries by one rule
(co, com, org, net, gov, edu, ac, or, ne, go, gob,
nom, mil, sch, ltd or plc under a two-letter country code, as in
co.uk, com.au, com.gr, gob.mx) plus a few named ones; a list of the
hosting and site-builder platforms that hand a sub-domain to anyone
(github.io, vercel.app, pages.dev, blogspot.com, myshopify.com,
...); and a short list of clouds where every single host belongs to one
customer (*.amazonaws.com, *.run.app). A registry or platform on none of
them - the many other country registries (info.pl, gc.ca), dynamic-DNS
and small hosting services - is treated as an ordinary domain: two
sub-domains there look like one owner and connect does not ask. The
validation preview above, which always shows the full URL, is the check that
does not depend on the lists.
A recipe is somebody else's text, so it cannot drive your terminal. One
that holds a control or invisible character anywhere (an ESC that would
hide the lines below it, a newline that forges one, a right-to-left
override, a zero-width space) is refused when it is loaded - the other
recipes still load, and a one-line warning on stderr names the file and
the field - and whatever of a recipe's words reaches the screen is
printed so that such a character is shown as a visible escape
(\x1b[8m) instead of being sent. Accented and CJK letters and emoji
are ordinary text and pass untouched. So does what writing needs in the
recipe's own sentences (its name, its steps and its scopes): a space of
another width - the no-break space of French typography (Clé API : copiez), the narrow no-break space, the ideographic space - and the
zero-width joiner or non-joiner between two visible characters, which
glue an emoji such as the woman technologist together and shape Persian
and Indic words.
What is refused, exactly: everything Python's str.isprintable() refuses,
that is the control characters (ESC, NUL, tab, carriage return, newline
and the C1 range), the line and paragraph separators (U+2028, U+2029),
the invisible "format" characters (the bidirectional marks and overrides
U+200E, U+200F, U+202A to U+202E, U+2066 to U+2069 and U+061C, the
zero-width space, the word joiner, the byte order mark, the soft hyphen
and tag characters), lone surrogates, private-use code points and code
points that are unassigned - except the spaces and joiners above, and
those only in a name, a step or a scope. In a URL, a host name, a header
or any other field a space of another width or a joiner is refused too,
and so is a joiner at the start or the end of a text, or next to a blank
or another joiner. An emoji built with tag characters (the flag of
England) is refused. What is unassigned is up to the Unicode tables of
the Python that runs safeeasyapi: Python 3.10 knows Unicode 13, so an
emoji from a later version passes on Python 3.13 and not on 3.10.
In the transcript above the key is shown as sk-… — in reality it is
never printed at all, not even redacted.
If the validation endpoint redirects somewhere else — another host,
another port, or from https:// down to http:// — the credential
header is stripped before the redirect is followed (what browsers and
requests do) and the connect stops with validation endpoint redirected
to a different origin instead of guessing from a response that proves
nothing about the key. A redirect within the same origin (/me →
/me/) is followed normally.
A key the request cannot carry is turned back too, before any
request is made. An HTTP header holds Latin-1 text only, so a key with a
character outside it (a curly quote copied along with the key, an emoji)
or with one you cannot see (a line break, a tab) is refused right after
the paste, with ✗ that key contains ... Nothing was sent, and you can
paste it again. A recipe that uses HTTP Basic puts the key through base64
first, so any character you can see passes there. The message never
repeats the key, and neither does the one for anything else the HTTP
library might refuse to send.
Run it on your own computer. The check only means something when it
is sent straight from your machine. Cloud coding agents, sandboxes and
some company proxies add their own credentials to outgoing requests, so
the API can answer "valid" for any key, even a made-up one. If a key you
know is wrong gets accepted, that is the likely reason: run
safeeasyapi connect in a terminal on your own computer instead.
Where the key goes
.env file (default). ENV_VAR=<key> is written to .env (or
--env-file; a missing folder is created), private from the first byte:
the content goes into a randomly named temp file created with
O_CREAT|O_EXCL and mode 0600 in the same directory, is fsync'd, and
is then atomically renamed over the target. No reader ever sees a
half-written or world-readable key, a crash leaves the old file
untouched, and a file somebody pre-planted at a guessable name is never
written through. An existing ENV_VAR= or export ENV_VAR= line is
replaced in place (keeping export). A value made only of
A-Z a-z 0-9 _ . + / = : - is written as it is; any other value is
single-quoted (ACME_API_KEY='ab#c d"e'). A shell that sources the
file takes every character inside single quotes literally, so $(...),
backticks and $VAR in a value can never run or expand (inside double
quotes they do), and an unquoted # or space cannot cut the value
short. A key with a single quote, a backslash, ${ or a character you
cannot see (a tab, a line break, a zero-width or no-break space) is
refused: the readers disagree about those, real API keys contain
none of them, and a pasted key that has one is nearly always a copy
accident (a quote mark copied along with the key, say). connect says
so right after the paste, before the key is sent anywhere, and lets you
paste it again (the OS keychain, --store keychain, does not apply this
rule). If the file cannot be written (--env-file in
a folder that is a file, or unwritable), the connect ends with
✗ the key was validated but could NOT be saved and exit 1 — the key is
never printed, and nothing is written.
What was checked. The tests source the file in a real sh and
bash (skipped where there is no POSIX shell) and split every line with
Python's shlex: none of the awkward values ($(...), backticks, ;,
&, $HOME, ~, spaces, #, quote marks) runs anything or comes back
different. The other readers were checked by hand on Linux in October
2026 with about sixty awkward values, written by the real code: Node
dotenv 17.4.2, python-dotenv 1.2.3 and docker compose 5.5.1 all gave
every storable value back exactly. That is why ${ and a backslash are refused rather
than written: python-dotenv expands ${NAME} and reads \\ as one
backslash even inside single quotes, and docker compose rejects a value
that ends in a backslash. Tools that expand $ after reading the file
(frameworks that add dotenv-expand, for example) were not checked and
may treat a $ in a value specially.
Older files. Versions 0.3.1 and earlier wrote double quotes, inside
which a shell still runs $(...) and backticks and expands $VAR. A key
made only of letters, digits and _ . + / = : - was never quoted, so
those are unaffected. Double-quoted values are still read, and such a
line is rewritten in the new form the next time its key is saved:
safeeasyapi rotate <service> does it, and nothing else in the file
moves.
If the same variable is set more than once in the file, the last
line is the one that is updated: python-dotenv, Node dotenv, docker
compose and a shell source all let the last assignment win, so
changing the first would save a key that never takes effect. Earlier
lines are left exactly as they were, and the success line says so
(ACME_API_KEY appears twice in .env; I updated the one that takes effect).
A quoted value that runs over several lines (a PEM block in single
quotes, say) belongs to its variable, the way python-dotenv, Node dotenv
and a shell read it: a line inside it that happens to look like
ACME_API_KEY=... is part of that value, so it is never rewritten or
removed, and if the variable's own value runs over several lines, they are
replaced (or removed by forget) together. A quote that is never closed,
or whose closing line has other text after it, is read as a one-line
value, as those readers do.
Only your key's line changes; every other byte of an existing file is
kept exactly as it was. A file saved in a Windows code page (cp1252: an
accented comment from Notepad) is not valid UTF-8, and a key is still
saved into it without complaint. A CRLF file stays CRLF and an LF file
stays LF: the line that is added or changed uses the file's own style, a
missing final newline is added in that style, and a brand-new file uses
LF. The byte-order mark that PowerShell and some Windows editors put at
the start of a UTF-8 file is kept, and does not hide a key on the first
line. Two things are refused with one plain sentence and exit 1,
before the browser opens or any key is asked for: a .env that is a
link to another file (a key is never written through a link, and a
link is never silently replaced — name the real file with --env-file
if you mean it), and a file that is not plain text, such as the UTF-16
that Windows PowerShell's > creates (appending UTF-8 to it would wreck
it — re-save it as UTF-8).
- Linux / macOS: the file is
chmod 0600(owner read/write only). - Windows: POSIX mode bits don't exist, so the file's ACL is reduced to
the current user (
icacls <file> /inheritance:r /grant:r <user>:(F), best effort). The success line saysowner-only ACLonly whenicaclsactually succeeded; otherwise it sayscould not restrict ACLso you know the file has the folder's default permissions.
safeeasyapi forget <service> undoes a connect: it deletes the keychain
entry (when this OS has a keychain) and removes the ENV_VAR= line from
.env (--env-file to point elsewhere), and says what it removed. If
the variable is set more than once, every one of those lines goes —
otherwise an older one would take over again — and it says how many.
On Linux, secret-tool clear exits 0 whether or not it found anything, so
forget looks the entry up first and clears it only when it is there. Run
twice, the second forget says nothing stored instead of claiming to have
removed the entry again; an entry that is there and cannot be cleared is
reported as such, with exit status 1.
forget also looks for the temp file an interrupted write (a kill -9,
a power cut) can leave next to the .env file - ..env. and eight
random characters and .tmp, a complete copy of the file - and deletes
it when it still holds the key; the success line names it. Other names of
the file are another matter: a hard link (a .env.bak made with ln)
keeps the old contents, key included, however the file is rewritten.
forget cannot change that and does not delete files it did not make: it
names the hard link, says the key is still there, and exits 1 instead of
claiming it is gone. Copies it cannot know about - an editor's backup
(.env~), version-control history, a shell history, a backup of the
folder - are yours to remove.
OS keychain (--store keychain). No keyring package; each OS
uses what it ships with, and the key never goes through argv:
| OS | backend | how |
|---|---|---|
| macOS | login Keychain, via the security command |
commands fed over stdin (security -i), generic password safeeasyapi:<service> / account <ENV_VAR> |
| Windows | Credential Manager (Windows Vault), via ctypes → advapi32 CredWriteW/CredReadW/CredDeleteW |
generic credential safeeasyapi:<service>, persisted for the local machine, UTF-16-LE blob like cmdkey |
| Linux | Secret Service (GNOME Keyring / KWallet) via secret-tool, when installed |
key on stdin; attributes service=safeeasyapi account=<ENV_VAR> slug=<service> |
| anything else, or the tool is missing | — | falls back to the .env file and says so |
A keychain-stored key is consumed with safeeasyapi run <service> -- <command>: the key is read back and placed in that one child process's
environment, never printed. (Your app does not need to change — it
still reads $STRIPE_SECRET_KEY.) Without a keychain backend run
exits 1 and tells you the key lives in .env.
run hands back the command's own exit status. A command that cannot
be started is reported in one line, with no traceback and before the
keychain is asked for the key: exit status 127 for a command that is not
on your PATH (command not found), 126 for one that is there but
cannot be run (not an executable file, a directory). A command that a
signal stops gives 128 plus the signal number (137 for a kill), as in a
shell. Ctrl-C ends run, the MCP server or any other command with
interrupted. and exit status 130. On Windows the command is looked up
the way the shell does it (PATHEXT), so npm finds npm.cmd.
A keychain tool that does not answer within 30 seconds, or cannot be
started, is handled like any other keychain failure. connect --store keychain falls back to the .env file and says so, so a key that was
just validated is never lost; forget still removes the .env line(s),
then says the keychain entry may still be there and exits 1 (run it again
when the keychain answers); run exits 1 with a message.
forget says what it removed before it says what it could not do. With
a .env that is a link (never edited, see Security model) it still
removes the keychain entry and any leftover copies of an interrupted
write, then names the link and exits 1: the key may still be in the
file the link points to.
Find public APIs
SafeEasyAPI is also a discovery front door. safeeasyapi find searches the public
APIs directory (~2,100 free APIs, cached locally for 7 days):
safeeasyapi find "weather" --auth No # only keyless APIs
safeeasyapi find "crypto price" --limit 5
safeeasyapi find "facts" --category animals # only that category
safeeasyapi find "weather" --refresh # download the directory again now
--auth takes the directory's own word (No, apiKey, OAuth, ...) and
--limit caps the list (10 by default). --category keeps one category: a
part of its name, in any case (animals finds Animals). --refresh
downloads the directory again now, whatever the age of the copy.
A copy older than 7 days is downloaded again, and so is one dated in the future by more than an hour (a clock that was wrong when it was saved): without that, the old list would be served until that date. A copy dated a few seconds or minutes ahead is normal - two clocks never agree exactly - and is used. The file is swapped in whole, so an interrupted download never leaves half a list behind.
APIs needing a key point straight back at the tunnel:
Weatherstack [apiKey]
Real-time weather
https://weatherstack.com/ (Weather)
needs a key — save its recipe with `safeeasyapi add`.
MCP co-work skills
The MCP server exposes the directory to Claude Code as tools, so the agent discovers free APIs instead of improvising or reaching for paid services:
find_public_apis {query, auth?, category?, limit?}— search the directoryget_public_api {name}— details plus wiring: no-auth APIs get a call-it-directly note;apiKeyAPIs get the exactsafeeasyapi add+safeeasyapi connectflow (the agent never asks the user to paste a key)
No secrets ever pass through MCP — only public how-to.
Copy-and-go, two-value keys, every kind of check (0.4.0)
Copy-and-go (--clipboard): instead of pasting, click the vendor's own
Copy button. safeeasyapi notices the new, key-shaped value on the
clipboard, takes it and empties the clipboard, then validates and
stores it as usual; nothing is typed, pasted or shown. It looks only while
it is waiting for a key, takes only a value that matches the field's
pattern (when the recipe has one) or looks like a key (one line, no
spaces, 8-4096 visible characters, different from what was already on the
clipboard), and falls back to the hidden prompt after 3 minutes or when no
clipboard tool exists (Windows needs nothing; macOS uses pbpaste; Linux
wl-paste, xclip or xsel). Windows clipboard history (Win+V) and
clipboard managers can keep their own copy, which safeeasyapi cannot clear.
Zero-copy (--via-cli): for a built-in recipe whose vendor has a CLI
with a browser sign-in (github uses gh auth login --web), safeeasyapi
runs that sign-in and reads the token from the CLI, then validates and
stores it. The programs a recipe may run are fixed in the code
(recipes.CLI_PROGRAMS); a recipe from ~/.safeeasyapi cannot use this.
Two-value credentials: a recipe may list several fields (an App ID
and a secret, an access key and its secret). Each is collected hidden (or
copied), they are validated together, and each is stored under its own
variable; run injects them all and forget removes them all.
Every common check: besides bearer, header and basic, a recipe can
use headers (several headers from {field} templates), basic with a
field as the username (username_field), query (the key as a URL
parameter; such a request follows no redirect), oauth2_client_credentials
(the client id and secret go in ONE POST to the vendor's token_url, which
follows no redirect, is shown in the preview, must be https:// and needs
a typed yes on another domain; the short-lived token then makes the
GET, or with token_only the token answer is the verdict), and
aws_sigv4 (a signed STS GetCallerIdentity; the secret signs and is
never sent, and the signature is tested against botocore's). A recipe is
refused at load time when it names a field it does not collect, collects
one it never checks, or puts its token endpoint on http://.
Why it exists
Agents (and humans) forget per-service key flows and improvise imprecise
steps. Recipes are the memory: plain JSON files with the key URL, the
exact click path, scopes, and a validation endpoint. Teach it once with
safeeasyapi add, use it forever — and Claude Code can pull the exact recipe via
safeeasyapi mcp instead of guessing.
Ships with 35 recipes inside the package (so pip install carries
them), each checked against the provider's own docs for the key page,
the validation endpoint and the auth header, and smoke-tested live with
a fake key (every one answers with a clean "key rejected"):
| area | services |
|---|---|
| LLM / AI | openai, anthropic, google-gemini, groq, mistral, cohere, huggingface, together, openrouter, replicate |
| speech | elevenlabs, deepgram, assemblyai |
| infra | github, supabase (Management API token), vercel, netlify, cloudflare, pinecone |
| email / payments | stripe, resend, sendgrid, mailgun, postmark (server token) |
| productivity / search | notion, airtable, discord (bot token), tavily, brave-search |
| two values / other checks (0.4.0) | ebay and spotify (OAuth2 client credentials), paypal (OAuth2, token only), twilio (Account SID + token), aws (SigV4), openweather (key in the URL) |
Each validation call is a free, read-only request (list models, "who am
I", verify token) — except brave-search, whose only authenticated
endpoint is the search itself, so validating costs one tiny query (the
recipe says so). The 0.4.0 recipes were written from each provider's docs,
checked against a local fake API, and smoke-tested live with fake
credentials (each answers "rejected"). Not included: Slack (auth.test
answers HTTP 200 even to a bad token), Linear (GraphQL needs a POST
body), Shopify (per-store URL), Perplexity/Exa/Serper (no free GET
endpoint), Fireworks (the documented list-models URL is per account).
safeeasyapi add walks you through capturing your own (Headroom, RPX,
Ponytail…); those are saved to ~/.safeeasyapi/recipes/
(%USERPROFILE%\.safeeasyapi\recipes\ on Windows) and override a
built-in recipe with the same slug. --recipes-dir writes elsewhere.
A recipe file may be saved with Windows (CRLF) line ends and a UTF-8
byte-order mark, as Windows PowerShell and older Notepad write them: a
recipe saved with one loads like any other.
A slug is [a-z0-9][a-z0-9_-]{0,63} — it becomes the file name, so
../../evil is refused, and so is a name Windows keeps for a device
(con, prn, aux, nul, com0 to com9, lpt0 to lpt9: a file called
nul.json cannot be saved there, and the refusal says so in one line) —
and the expected status codes must be numbers
(200,201). The validation call must be a GET or HEAD: it only
reads, so a recipe cannot make connect send your key with a DELETE
or a POST. Besides bearer / header / basic, a recipe's
validate block may set auth.scheme (Token for Deepgram, Bot for
Discord), auth.username for Basic auth with the key as the password
(Mailgun's api:<key>), static headers (anthropic-version,
Notion-Version), and reject — the statuses that mean "bad key" when
a provider does not answer 401/403 (Google and Resend say 400, Brave
422).
The MCP server
Add to Claude Code (claude mcp add) or ~/.claude.json:
{ "mcpServers": { "safeeasyapi": {
"command": "python", "args": ["-m", "safeeasyapi", "mcp"] } } }
(python is the Windows launcher's name; on macOS/Linux use whatever
runs your Python 3.) Tools: list_services, get_recipe,
find_public_apis, get_public_api. The recipe tells the agent the
exact steps and ends with: run safeeasyapi connect <service> in a
terminal — never ask the user to paste the key into chat. The server
speaks JSON-RPC 2.0 properly: notifications (notifications/cancelled,
notifications/initialized, anything without an id) are never
answered, a non-object message gets -32600 Invalid Request, bad JSON
(a hundred thousand open brackets included) -32700 Parse error, and params that is not an object (null, a list,
a string…), arguments that is not an object, or a missing or wrongly
typed required argument gets -32602 Invalid params — never a crash or
-32603 — after which the server simply carries on. (A tool-level miss,
such as a service that has no recipe, is an ordinary result with
isError.) If standard input or standard output is closed
(safeeasyapi mcp >&-; pythonw on Windows) there is no client to talk
to: one error: line on stderr and exit status 2, before any request is
read, not a traceback.
Security model
- Key input via
getpass(no echo); never printed, never logged. .envwritten atomically through a random, exclusive,0600temp file + rename (owner-only ACL on Windows); keychain storage never passes the key on a command line.- A
.envthat is a link is refused, never written through or replaced, and the permission step (chmod / icacls) never follows a link to change something else. - Whatever a recipe or a paste contains, the
.envfile gets exactly oneNAME=valueline from it: a name is a plain identifier (a trailing newline does not slip through), and a value is single-quoted or refused, so sourcing the file runs nothing. - Validation is a single minimal HTTPS call; only the status is read,
response bodies are discarded. The credential never follows a
redirect to another origin or to plain
http://. - Rejected keys are scrubbed immediately; 3 failed attempts aborts.
- The exact validation URL and HTTP method are printed BEFORE the key is
asked for; a validation URL under another registrable domain needs a
typed
yes(built-in recipes may declare their vendor's second API domain; user recipes cannot). - A reply that is not HTTP at all (a captive portal, a TLS mismatch, a non-HTTP service on the port) is reported as a validation error, not a traceback.
- A recipe with a control or invisible character in it is refused at load time, and any recipe text that reaches the screen is shown as a visible escape, so a recipe cannot hide or forge the validation preview. (The one leeway: a space of another width and a joiner between two visible characters are text in a name, a step or a scope - the list of what is refused is in A connect, annotated.)
- The only POST safeeasyapi sends is the OAuth2 token request, to the
token_urlthe recipe names and shown before anything is asked for; it and aqueryrequest follow no redirect. Copy-and-go empties the clipboard as soon as it takes a value. - Recipes must use
https://for the key page and the validation URL — plain-http://recipes (and ones whose URL is malformed) are rejected at load time. - Recipes contain no secrets — only public how-to.
Testing
python -m unittest discover -s tests -v
python -m pytest -q # same suite
The tests use only the standard library and no network beyond
127.0.0.1 (nothing is downloaded, not even to build a wheel). Two groups
skip, with a reason, where they cannot run: the tests that build the
wheel and the sdist need a setuptools 77 or newer, with a packaging 24.2
or newer, installed in the Python that runs them; the tests that read
pyproject.toml need tomllib, so they skip on Python 3.10. What they
cover: recipe schema,
HTTPS-URL rejection and malformed URLs, slug validation, every shipped
recipe (parses, https, file name = slug, credential header built),
.env create/update/permissions (random exclusive temp file, crash
safety, never world-readable, value quoting (sourced in a real shell,
split with shlex, values the readers disagree on refused), export lines,
an unwritable path, a cp1252 file, a byte-order mark, CRLF/LF/mixed
endings and a variable set twice (the last line wins) kept
byte-for-byte, a UTF-16 file and a symbolic link refused, permissions
that never follow a link), redirect credential stripping against a local
redirecting server pair, full connect flow against a local fake HTTP
service (bearer/header/basic auth, custom schemes and static headers,
401 rejection, custom reject statuses, network failure, a non-HTTP
reply), validation-URL preview ordering, registrable-domain matching
(punycode names, trailing dots, ports, user@, a look-alike letter
shown in the warning) and typed-yes on a real mismatch, keychain
dispatch for every OS with the real keychain faked (the Windows path
through a fake advapi32), the .env fallback, forget, the honest
Windows ACL line, MCP provenance labels and quarantine of poisoned
directory entries, JSON-RPC notification, invalid-request and
invalid-params handling, MCP protocol round-trip over real stdio, a
wheel build that must contain the recipes and an sdist that must carry the
tests (both built from a clean copy of the folder, so a leftover build/
cannot reach them),
the package metadata and one version number everywhere, output into a pipe that closes early (the
real thing, and the different error Windows gives, simulated), and
never-log assertions on every output path. Tests point
HOME/USERPROFILE/APPDATA at a throwaway directory, so they never
read your real ~/.safeeasyapi or .env.
Windows 11 is a first-class target: the wire format of the MCP server is UTF-8 bytes regardless of console code page, console output is reconfigured to replace unencodable characters instead of crashing, and every text file is opened as UTF-8.
Scope / limits (v0.4.1)
- Keychain storage is best effort and opt-in (
--store keychain); the.envfile is the default and the fallback. No Doppler / Vault backends. - Recipe catalog is curated manually — verify a new service's key URL and validation endpoint before trusting it. Provider dashboards move: if a key page URL redirects, follow it and send a fix.
safeeasyapi mcpreturns recipes; the interactive key paste stays in the terminal by design.forgetremoves what safeeasyapi wrote and what a killed write left next to it; it does not find other copies of the file (editor backups, version-control history), and it names - rather than deletes - another hard link to the.envfile, exiting 1 because the key is still there.- Windows:
runfindsnpm.cmdand other.cmd/.batcommands throughPATHEXT. That lookup is tested with a stand-in for the Windows search, not on a Windows machine, andcmd.exe, which starts such a file, reads& | < > ^ %in its arguments as its own syntax: do not pass one text you do not trust. - "Same owner" (the cross-domain prompt) knows a compact snapshot of country registries and hosting platforms, not the whole Public Suffix List: a platform it does not list is treated as an ordinary domain, so two tenants of it do not trigger the prompt. The validation preview always shows the full URL.
- Linux:
secret-toolis driven through stand-ins in the tests, not against a real Secret Service (GNOME Keyring, KWallet): none was available. That includesforget, which relies onsecret-tool lookupfinding nothing (exit status 1) when no entry matches. - Windows: the refusal of device names (
nul,con,com1...) as recipe slugs is a plain list of names, tested on Linux, not on a Windows machine. - Windows: the owner-only ACL goes on the
.envfile right after it is moved into place, not before, so for that moment it has the ACL its folder gives it. This is read from the code and checked against a stand-in foricacls, not on a Windows machine. - The repository's own text files are committed with Windows (CRLF) line
endings and there is no
.gitattributes: keep them when you edit a file. On Linux and macOSgit diffflags them as trailing whitespace.
Metadata
Release files for safeeasyapi 0.4.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| safeeasyapi-0.4.1.tar.gz | 197.8 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| safeeasyapi-0.4.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 295.9 kB
Release files / safeeasyapi-0.4.1.tar.gz
| Download URL | safeeasyapi-0.4.1.tar.gz |
|---|---|
| Size | 197.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
c936dcf43fce537a6eabec71523a9858ec3703766b7ee7a5bf74fa21218aac47
|
|
BLAKE2b-256 checksum How to use checksums |
59a32aa1ccb433f137e60c83e7065c4af9722f83decb6d44f228376496b021ad
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.11.15
|
Release files / safeeasyapi-0.4.1-py3-none-any.whl
| Download URL | safeeasyapi-0.4.1-py3-none-any.whl |
|---|---|
| Size | 98.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
5849e2ab085e4ab201fee1f11b5a4da572782a46051b929291a788c32404ff89
|
|
BLAKE2b-256 checksum How to use checksums |
565978a8950c305201908e8a4abfd8963197ca6bb3c55f051ae72f71c4ca0d5a
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.11.15
|
Security for safeeasyapi 0.4.1
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.