Skip to main content

SafeEasyAPI (safeeasyapi)

Quickstart

pipx install safeeasyapi          # Python 3.10+, no other dependencies
safeeasyapi scan                  # find keys an AI agent can read (works offline)
safeeasyapi hook install          # stop Claude Code reading your .env files
safeeasyapi list                  # the services it knows (36 built in)
safeeasyapi connect github        # opens the key page, shows the exact steps
#   paste the key when asked (typing is hidden), or use --clipboard and
#   click the vendor's Copy button; the key is tested with one API call
#   and only then saved to .env (owner-only) - or to the OS keychain:
safeeasyapi connect openai --store keychain
safeeasyapi run openai -- python app.py     # keychain key goes to that command only
safeeasyapi forget openai         # remove it from the keychain and .env

# a whole project at once (0.6.0):
safeeasyapi init --write          # list the services the project uses (safeeasyapi.json, no secrets)
safeeasyapi setup                 # connect every service that has no key yet
safeeasyapi status                # where each key lives, how old it is

The key is never displayed, never logged, never in shell history.

Why scan comes first. AI coding agents read your project's instruction files and, if you let them, your .env. A key pasted into one of those is in the agent's memory and its conversation log. Radware (August 2026) looked at about 100,000 agent instruction files: roughly 0.69% held a real credential, and 60% of the keys it could test still worked. safeeasyapi scan finds them in seconds and tells you what to do about each.

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.

One flow: from the vendor's dashboard into your project

The promise: a key goes from the vendor's page into your project in one flow - no Notepad, no pasting into a terminal, never on screen, never in shell history, never in an AI agent's chat. Here is exactly what that means, what you do, and what the tool does.

safeeasyapi connect openai --clipboard
You The tool
Run the command. Shows the vendor's steps and which exact address your key will be tested against, then opens the vendor's key page in your browser.
Sign in to the vendor and click their "Create key" button (the tool cannot do this for you - see below). Waits. Nothing is shown.
Click the vendor's own Copy button. Sees a new key-shaped value on the clipboard, takes it, and empties the clipboard.
Nothing. Tests the key with one harmless read-only request. A key that is real but limited (for example a Stripe restricted key, a Resend "sending only" key) is accepted and told so, not called dead.
Nothing. Saves it to .env (owner-only, written so a crash cannot leave half a file) or, with --store keychain, to your OS keychain. It prints where, never the key.

Without --clipboard the same flow ends with a hidden prompt (what you type or paste is not shown). That is the default because it works everywhere; --clipboard is the "no pasting" mode. --via-cli (GitHub) is the zero-copy mode: GitHub's own gh signs you in and hands the token over.

Then, in your project:

safeeasyapi init --write      # writes safeeasyapi.json: service names + variable names, no secrets, commit it
safeeasyapi setup             # walks every service that has no key yet; safe to run again
safeeasyapi run --all -- python app.py   # the program gets the keys; its output is scrubbed of them
safeeasyapi export groq --to github      # copy a key into GitHub Actions secrets, through gh's standard input
safeeasyapi status            # which keys exist, where, how old; says when to rotate

AI agents. Claude Code (or any MCP client) never handles a key. The capture_key tool (0.7.0) lets the agent do the whole job: it drives the browser, makes the key and clicks the vendor's own Copy button; safeeasyapi takes the key from the clipboard, empties it, tests it and stores it, and the agent is told only {state: "stored", env_vars, stored_in, key_length}. request_connect answers {service, env_var, status, instructions, browser, rules}, and needs_keys lists which keys a project lacks. Only when capture_key is unavailable (no clipboard access, headless, or the person has not allowed it) is the person asked to run safeeasyapi connect <service> in their own terminal. No tool can return a secret value: answers are built from names and status words only, and a second filter blanks any secret the server can reach (a variable in its environment, a value in the project's .env), also in base64, hex and URL-encoded form. A test plants canary keys everywhere and searches every tool answer and every JSON output for them. The agent skill (the same rules, written for the agent) ships inside the package. Install it with safeeasyapi skill install (for you) or safeeasyapi skill install --project (this project only); it never overwrites a different file without asking.

What it cannot do

  • It cannot make the key for you. Vendor dashboards sit behind your login and none offers a way to mint a key without it. You sign in and click "Create"; the tool does everything after that. The steps shown come from each vendor's own documentation, and anything the docs do not confirm is listed in the recipe as unverified and shown to you.
  • It cannot keep a copy out of a clipboard history. --clipboard empties the clipboard, but Windows clipboard history (Win+V, and its cloud sync) and clipboard managers (Maccy, CopyQ, Klipper ...) record a copy the moment you press the vendor's Copy button, and only the program that copies can ask Windows to leave something out of history - here that is the vendor's web page. Windows has settings for that (CanIncludeInClipboardHistory, ExcludeClipboardContentFromMonitorProcessing); they are set by the writer, not by a reader like this tool, so it cannot use them. What it does instead: on Windows, when the registry says clipboard history is on, --clipboard switches to the hidden prompt and tells you why (--allow-clipboard-history overrides). On macOS and Linux it cannot detect a clipboard manager: if you run one, use the hidden prompt.
  • It cannot stop the vendor's page, a browser extension or malware that can read the page or your clipboard from seeing the key.
  • An agent that can run safeeasyapi run can still get the key. The key lives in the child program's environment, readable by the same user, and a program started by the agent can print it reversed, hashed, split or encoded in a way redaction does not know. The hook refuses the obvious routes (run --no-redact, secret-tool, security find-generic-password, cmdkey, Get-Secret, keyring, /proc/*/environ, ~/.safeeasyapi), and --no-redact refuses to work unless you are at a terminal. None of that is a wall. For a sensitive service, run agent sessions without permission to use safeeasyapi run (a deny rule in Claude Code), and start the program yourself in your own terminal: the agent writes the code, you run it. The MCP instructions tell agents to ask you, not to run it themselves.
  • Redaction is not a vault. run scrubs the exact key and its common re-writings (base64, hex, URL-encoded, JSON-escaped, upper and lower case) from the program's output. It cannot catch a key the program hashes, truncates, reverses or splits across lines, one written to a file or sent over the network, or one shorter than 6 characters. With redaction on, the program's output is a pipe, not a terminal, so some programs lose colours or stop prompting: use --no-redact for those (it works only when input and output are both a terminal; with a pipe it is refused).
  • Copy-and-go empties the clipboard, and asks when it cannot be sure. A key-shaped value already on the clipboard when waiting starts is emptied, so copying it again is noticed. For a service with no documented key shape, it tells you what it took (length and kinds of characters, never the characters) and asks you to confirm. Ctrl-C cancels; the clipboard is emptied after the hidden-prompt fallback too.
  • Two runs at once are safe. Writes to .env take a short lock, so two connect runs at the same time cannot drop each other's key.
  • The .env file is plain text (owner-only). The OS keychain is better.
  • The hook is a guardrail, not a wall (see below), and scan finds keys by their shape: a key with no recognisable shape can slip past it.
  • It was not run against real vendor dashboards for this release: the recipes follow the vendors' documentation, and safeeasyapi doctor-recipes (also a weekly check on GitHub) tells you when an address has moved.
  • export works only through gh and vercel (installed and signed in); the key reaches them on standard input, never on a command line. Other destinations are not offered because no other vendor tool was confirmed to read secrets that way.

Install

Zero dependencies — Python 3.10+ stdlib only.

pipx install safeeasyapi        # or: pip install safeeasyapi
safeeasyapi --version

From source: pip install git+https://github.com/calasdan-sketch/safeeasyapi.

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

Scan: find keys an AI agent can read

safeeasyapi scan                  # this folder, offline, nothing leaves your computer
safeeasyapi scan ~/projects/app   # another folder (or one file)
safeeasyapi scan --validate       # ALSO ask each key's service if it still works
safeeasyapi scan --json           # or --sarif, for scripts and code-scanning tools
safeeasyapi scan --all            # every text file, not just the agent-readable ones

It reads .env files (.env, .env.local, prod.env ...), CLAUDE.md, AGENTS.md, GEMINI.md, .cursorrules, CLAUDE.local.md, .windsurfrules, .mcp.json and everything inside a .claude folder. UTF-8 and UTF-16 files (what PowerShell's > writes) are both read. It skips .git, node_modules, build output (target, build, dist, .next ...), binary files, pipes and devices, and links that lead outside the folder; it never opens anything but a regular file, so it cannot hang on one.

What it recognises: the key format of each built-in recipe (OpenAI, Anthropic, Stripe, GitHub, AWS ...), a few well-known prefixes for services without a recipe (Slack tokens and webhook URLs, Stripe whsec_ signing secrets, npm, Shopify, Google, a PEM private key - also when it sits on one line inside JSON), Bearer <token>, a password inside a connection URL (postgres://user:PASSWORD@host/db), and any NAME=value where the name ends in KEY, TOKEN, SECRET or PASSWORD and the value looks random. A few prefixes are marked "format is a guess" in the output because the vendor documents them thinly. Stand-ins such as your_key_here, xxxx, <token> and example are ignored - by word, so a real key that happens to contain abc123 in the middle is not thrown away.

It cannot be made to hang, and it does not call a partial look clean. Every pattern runs on pieces of a line (a 1 MB minified line is scanned in 8 KB pieces, not cut off), each file has a 10-second budget, and the walk counts only the files it will read, so an agent file behind 50,000 empty files is found. If a limit is hit anyway (too many files or folders, more than 12 folders deep, a folder or an agent file it could not open or read - a .env over 1 MB - or out of time) the report starts with an INCOMPLETE line, says what was not looked at, and the exit status is 2 (1 if keys were found; with --fail-on live it is never 0 when incomplete).

A finding looks like this (the key is never shown in full; at most its first 4 and last 2 characters):

  .env:3   OpenAI   sk-p...Wx   not checked
      next: Rotate it at https://platform.openai.com/api-keys, then run
      `safeeasyapi connect openai` to save the new one safely.

Each finding has one of four states: unchecked (the default; nothing was sent anywhere), live (the service still accepts it), dead (the service answered 401: it rejected it - it may be revoked or may not be that service's key at all, so the advice is "rotate it if you are unsure", never "delete the line") or unknown (it could not be checked, with the reason; a 403 is unknown, because it can mean "valid key, no permission").

--validate sends keys over the internet, so it asks first. It lists the exact host each kind of key will go to, then needs a y at the prompt or --yes. With no terminal to ask on and no --yes, it sends nothing and still prints the report (every key not checked), with the exit status the findings give. Only https checks run, one read-only request per distinct key, through the same code as connect. A check on a different domain than the key's page still needs a typed yes (--yes does not replace that). Keys of services that need a second value (AWS, Twilio), keys whose format several services share (Google), services without a recipe, and a key found only by its NAME whose value does not have that service's key format (an OPENAI_API_KEY holding something that is not an sk- key) are listed as unknown and never sent. Names containing webhook, anon, maps or public are never matched to a service.

Only the recipes shipped inside the package are used. A recipe in ~/.safeeasyapi/recipes brings its own regular expression and names the host a key is sent to, so scan ignores it - and cannot be made to send a key to it - unless you pass --my-recipes. With that flag its key format is searched for under a 0.25-second timer per piece of text (where the system has no timer signal, such as Windows, its pattern is not used), and with --validate its keys need a typed y at a terminal every time, even with --yes.

Exit status: 0 nothing found, 1 keys found, 2 could not run (bad path, bad option) or the scan was incomplete and found nothing. --fail-on live exits 1 only for a key that still works (it needs --validate); a key that could not be checked (unknown, or not checked) is not live, so --fail-on live exits 0 for it - it says so on stderr. --json and --sarif print only the report on stdout (with an incomplete flag, and a SARIF notification when the scan was incomplete); questions and the host list go to stderr. SARIF is version 2.1.0, with the redacted key in each message. The same file reached through a link is reported once, under its real path.

Hook: stop Claude Code reading .env

safeeasyapi hook install            # for you, in ~/.claude/settings.json
safeeasyapi hook install --project  # for this project, in .claude/settings.json
safeeasyapi hook status             # is it there, and does it work?
safeeasyapi hook uninstall          # take it out again

This adds a Claude Code "PreToolUse hook": a small check Claude Code runs before every Read, Grep, Bash, PowerShell, Monitor, Edit, Write, MultiEdit and NotebookEdit call. If the call touches a .env file (.env, .env.local, prod.env, .envrc) it says no and tells the agent why, in plain words. Templates (.env.example, .env.sample, .env.template) are allowed, for reading and writing.

Writing is refused too (since 0.5.2): a real key an agent types into .env has passed through the agent, which is what this tool exists to prevent, and a Write can overwrite your keys. The agent is told: "Claude can't write to .env - your keys stay out of the chat. Run: safeeasyapi connect (or edit the file yourself)." If your .env holds nothing secret and you want the agent to edit it, start Claude Code with SAFEEASYAPI_HOOK_ALLOW_WRITE=1 in its environment (only writes are allowed; reads stay refused). Glob is not matched: it lists names only.

Upgrading from 0.5.0 or 0.5.1: run safeeasyapi hook install again. It updates our own entry's matcher in place and touches nothing else; hook status says "OUTDATED" until you do.

Your other hooks and settings are kept. The hook always sits in a matcher group of its own: another group's matcher or handlers are never edited, and uninstall removes only our handler (found by a statusMessage marker or by the exact command an older version wrote - a hook that merely mentions safeeasyapi is not ours). Every change first copies the old file to a new backup in ~/.safeeasyapi/backups (the last 20 per file are kept; private to you; never inside a project, where a .claude/ folder is often committed). A settings file that is a symbolic link is changed through the link; one that is not valid JSON is left alone (exit 2). Installing twice changes nothing.

hook status never runs a command it reads from a settings file (a cloned repository's .claude/settings.json is not yours). It tests only the command this installation would write, and says when the file's command is a different one.

How the command is written: on macOS and Linux a shell string with the installed safeeasyapi by its full path (or this Python with -I, which ignores the current folder). On Windows the documented exec form - "command" is the program and "args" is ["hook", "check"], no shell - so a backslash or a space in the path survives Git Bash and PowerShell. Claude Code's documentation does not say which version introduced args; for a client that predates it use safeeasyapi hook install --shell-form (forward slashes, quoted; works in Git Bash, not in PowerShell). The timeout is 10 seconds. safeeasyapi hook check is the hook itself: it reads Claude Code's JSON on stdin (all of it, however big) and prints the answer. Restart Claude Code after installing.

If the hook itself fails (it cannot start, crashes, times out, or is sent something it cannot parse), Claude Code lets the tool call through: that is "fail-open", the default. Input that is too big or not valid JSON is first searched for a .env file name as plain text and refused if one is there. If you would rather block everything when the hook is broken, install with --fail-closed (it sets "onFailure": "block"; needs Claude Code 2.1.295 or later; a missing or broken safeeasyapi then blocks every call until you fix or uninstall it).

What this hook does NOT stop

It is a guardrail, not a wall. It looks at the file names in the call, so it does not stop:

  • a name built from pieces: cat .e""nv, cat $(echo .en)v, python -c "open('.'+'env')";
  • a folder sweep that never names the file: grep -r KEY ., rg KEY, cat *, cat .[e]nv, find . -name '.env*' -exec cat {} +;
  • a link to the file under another name (ln -s .env notes.txt; cat notes.txt) or a copy made before the hook was installed;
  • a program that prints the settings, such as docker compose config, printenv or npm run env;
  • tools this hook is not attached to: an MCP filesystem server, Glob (names only), a subagent in another session without the hook, and a file you @-mention in your prompt (Claude Code inserts it without a tool call
    • add a Read deny rule for that);
  • anything when the hook fails and you did not use --fail-closed.

And it blocks some harmless things: a virtual environment folder named .env (source .env/bin/activate), and a command that merely names the file (echo .env >> .gitignore). Keep keys out of the files too: safeeasyapi connect <service> --store keychain.

Commands

safeeasyapi scan [PATH]                 # find keys an AI agent can read (offline)
safeeasyapi scan --validate [--yes]     # also check if they still work (sends them; asks first)
safeeasyapi scan --json | --sarif       # machine-readable reports
safeeasyapi scan --fail-on live         # exit 1 only for keys that still work
safeeasyapi scan --all                  # every text file, not only agent-readable ones
safeeasyapi hook install [--project]    # stop Claude Code reading .env files
safeeasyapi hook status | uninstall     # check it / remove it (--settings FILE for another file)
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 stored key (keychain, else .env) in its env only
safeeasyapi run --all -- <command>      # ... every service in safeeasyapi.json
safeeasyapi run <service> --no-redact -- <command>   # don't scrub the output (interactive programs)
safeeasyapi run <service> --redact -- <command>      # scrub it (the default)
safeeasyapi run <service> --env-file FILE -- <command>   # read keys from another .env
safeeasyapi --project-dir DIR <command>   # work as if started in DIR (manifest and .env live there)
safeeasyapi skill install [--project] [--yes]   # copy the agent skill to ~/.claude/skills (never overwrites unasked)
safeeasyapi init [--write] [--add] [--force]   # write safeeasyapi.json from the project's variable names
safeeasyapi setup [service ...] [--dry-run]    # connect every missing service in order (resumable)
safeeasyapi setup --env-file FILE | --no-browser | --clipboard | --allow-clipboard-history
safeeasyapi status [--json] [--max-age DAYS] [--env-file FILE]   # where keys live, how old
safeeasyapi export <service> --to github|vercel [--repo OWNER/REPO] [--environment NAME] [--overwrite] [--yes]
safeeasyapi about                       # who made it and how to check it is the real one
safeeasyapi allow-agent <service>       # your one-time yes (terminal route; the normal way is the window the server shows)
safeeasyapi allow-agent <service> --env-file .env.local   # let the agent write to this .env-style file (default: .env only; terminal route only)
safeeasyapi setup-agent claude-code|claude-desktop|cursor|codex|gemini [--write]   # the MCP entry, with this Python's absolute path
safeeasyapi skill install --for cursor|codex|gemini|all   # the rules file for that client (marked block, never overwrites yours)
safeeasyapi mcp --default-store keychain   # where agent-captured keys go (setup-agent sets it for clients without the .env hook)
safeeasyapi allow-agent <service> --allow-clipboard-history   # ... and accept Windows' clipboard history (terminal route only; with a window, turn history off)
safeeasyapi allow-agent --list | --revoke [service]   # what is allowed in this project / take it back
safeeasyapi doctor-recipes [service ...] [--json] [--timeout S] [--my-recipes]   # have recipe addresses rotted?
safeeasyapi connect <service> --allow-clipboard-history   # keep copy-and-go even if Windows keeps a history
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, check the mode: test keys (sandbox) contain _test_,
       live keys contain _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 says owner-only ACL only when icacls actually succeeded; otherwise it says could not restrict ACL so 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 — no keychain: asks [y/N] before using the .env file (nothing is saved without a yes, or with no terminal)

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 asks [y/N] (default no) before putting the key in the plain-text .env file instead, and with no terminal to ask on saves nothing and exits 1; the stored entry is read back to make sure it is really there; 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 directory
  • get_public_api {name} — details plus wiring: no-auth APIs get a call-it-directly note; apiKey APIs get the exact safeeasyapi add + safeeasyapi connect flow (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 accept pattern (when the recipe has one; it names only a shape the vendor documents, and only its start, so a valid key is never refused) 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).

What a recipe says (0.6.0)

Beyond the steps and the check, a recipe can carry:

  • accept / detect: two different jobs for a key's shape. accept is the copy-and-go gate: only a shape the vendor documents, matched from the start of the value, or nothing (then any one-line value of 8-4096 visible characters is taken). detect is what scan looks for; a commonly used but undocumented shape (Groq's gsk_, SendGrid's SG. ...) lives only there and is reported as "format is a guess". The older single pattern still works and does both.
  • validate.limited: statuses that mean "real key, missing a permission" (a 403 from a Stripe restricted key, a Supabase token without projects-read, a scoped Vercel token ...). connect stores the key and says what it cannot do, instead of calling it dead.
  • validate.body_rules: the same idea when the status alone is not enough (Resend answers 401 for a "sending only" key too; the body says restricted_api_key). Only a phrase from the recipe is looked for in the first 4 KB of the answer; none of it is kept or shown.
  • least_privilege: the smallest scope or permission that works, shown by connect.
  • unverified: parts the vendor's docs do not confirm, shown by connect.

PayPal has two recipes, paypal (live) and paypal-sandbox, so you choose explicitly.

AWS's check is the one POST besides OAuth token calls: AWS documents STS GetCallerIdentity as a POST, it only says who the key is, and the recipe loader refuses a POST to anything else.

doctor-recipes visits each recipe's key page, docs link and check server over HTTPS with no key at all and prints a table: ok, moved (redirected), warn (403, 429, 5xx: look by hand) or ROT (404/410, name not found, broken certificate, refused connection, too many redirects, a redirect to plain HTTP). It exits 1 on rot. A weekly GitHub Action (.github/workflows/recipes.yml, read-only except for one issues: write in the job that opens or updates a single issue) runs it. To fix a recipe, see CONTRIBUTING.md.

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, request_connect, capture_key and needs_keys (the agent handshake, above). The recipe tells the agent the exact steps and ends with: call capture_key (or, if that is unavailable, ask the person to run safeeasyapi connect <service>) — never ask the user to paste the key into chat.

Agent-driven capture (capture_key, experimental)

The person says "connect GitHub". The agent calls capture_key (action: start, status, cancel), opens the key page with its own browser tools, chooses the smallest permissions and a short expiry, and clicks Copy. The MCP server — a program running as you, on your computer — sees a new key-shaped value on the clipboard, takes it, empties the clipboard and checks that it is empty, asks the vendor whether it works (the same check as connect, including "real but limited"), and stores it in .env or the OS keychain. The agent gets back only the state, the variable name, where it was stored, a length class and — for GitHub — the account's login name, so you can see which account was connected. A key the vendor rejects is kept nowhere.

States (state): consent_pending (a window is on your screen), consent_needed (no window possible: use allow-agent; or the window failed or timed out, which is not a refusal: ask again), consent_expired (the window closed by itself after 5 minutes: tell the person, they can ask again), consent_denied, consent_granted (then call start), replace_granted (then call start with replace=true), confirming (a window asks you to confirm the account), unavailable (no clipboard, SSH, WSL ...: the reason is given), unsupported_gateless (the service has no documented key format), refused (clipboard history, a recipe of your own, limits), busy, already_stored, waiting, validating, storing, stored, failed, timeout, cancelled, idle. status waits up to 25 seconds and returns as soon as the state changes. cancel during storing answers cancel: too_late.

Rules that make this safe enough to allow:

  • Your yes is asked in a window of the operating system (a Windows message box that stays open 5 minutes, macOS osascript, Linux zenity/kdialog) shown by the server itself — not by anything the agent can type into. When a window can be shown, only that window counts: any permission file on disk is ignored, even a well-formed one, so a file an agent wrote cannot start a capture. Your yes is kept in the server's memory for that session only (it ends when the MCP server stops), never on disk. A window that fails to open, crashes, or is not answered is not a "no": the agent is told to ask again (only an explicit No is remembered, for 10 minutes). After the key is checked a second window shows which account it belongs to (GitHub: the login name) and asks you to confirm before anything is stored. Replacing a stored key needs its own window.
    • What the window protects against: an agent that works through chat, files and shell commands and cannot run code as you — it cannot answer the window and cannot plant a consent.
    • What it does not protect against: an agent that can run programs as your user (it can reach the same operating-system facilities: it could, for example, drive the screen or tamper with this very program), one that controls your screen and presses Yes, and a browser tool that reads the key field.
  • Who is named, and who made the tool. The window says which AI program is asking ("Claude Code", "Cursor", "Codex" ...). That name is what the program reports about itself when it connects (the MCP clientInfo.name); safeeasyapi cannot prove it, so it is a label, not an identity check. An unknown name is shown cleaned (plain letters, 30 characters) and a missing one as "An AI program". The first line of every window says the tool is free key-safe tool, made by Calas Automations, installed on this computer. To check that this really is the program you installed: safeeasyapi about, pip show safeeasyapi (author Calas Automations Inc.), the package page https://pypi.org/project/safeeasyapi/ and the source https://github.com/calasdan-sketch/safeeasyapi.
  • A closed window is not a No. Only the No button, where the operating system lets safeeasyapi tell (Windows, macOS, Linux zenity), counts as a refusal. A window that closes by itself or is closed without choosing is reported as expired/closed and can simply be asked again; on kdialog or an old zenity, where "No" and "closed" look the same, the answer is passed on as a No but never remembered for 10 minutes.
  • A dead stored key is not protected. Before a REPLACE window is shown, the stored key is tested with the vendor (never shown). If the vendor rejects it, no replace question is asked - it is handled as a normal first capture and the agent is told the stored key no longer works. If it can't be tested, the replace window is shown.
  • Helper programs are trusted ones only. zenity, kdialog, osascript, security, secret-tool and the clipboard tools are looked up only in /usr/bin, /bin and /usr/local/bin, only when root owns them and nobody else can write to them or their folder, and they run with a fixed PATH and a minimal environment. That is deliberate and not relaxed for the clipboard tools either (the key passes through them). The cost: a Homebrew or Nix copy, which your user owns, is refused — and the result says so (for example found xclip at /opt/homebrew/bin/xclip but it is not a trusted install ... use safeeasyapi connect) instead of failing quietly.
  • safeeasyapi allow-agent <service> in a terminal is the fallback only where no window can be shown (SSH, headless), or when the server is started with SAFEEASYAPI_NO_WINDOW=1. On a computer that can show a window the server does not use it. It is advisory: it is kept in ~/.safeeasyapi/agent-consent.json (in a 0700 folder) with an HMAC and a counter (an old copy of the file does not verify). The signing secret is in Windows DPAPI, the macOS keychain or the Linux Secret Service; where those work they are the authority and a file is never preferred over them; otherwise it is a 0600 file. Where the keychain is empty or absent, a file planted by a program that can write files is accepted on this route, and a program running as you can still read that secret or fake a terminal, so this route proves little. An agent that can edit your MCP client's config can also set SAFEEASYAPI_NO_WINDOW=1 for the server, so that switch is a convenience, not a lock. A file that does not verify counts as no consent. The hook's rules about these files (including wildcards such as ~/.safe*/*) are a speed bump, not a lock; a known false positive is that a word such as ls .s* is refused too, since it could expand to the folder.
  • Built-in services that have a documented key format only (GitHub, Stripe, Anthropic, AWS, Cloudflare, Replicate, Twilio, Vercel). Other services answer unsupported_gateless. One capture at a time; 10 to 180 seconds; six starts an hour.
  • The agent writes only to the project's .env (or the one file you named with --env-file), never to a folder a web server publishes.
  • Only a value that changes the clipboard after arming and has the service's key format — one line, no spaces — is taken. A stale copy shaped like that key is removed and not used (said in the result); anything else on the clipboard is left alone. A copy that is not that service's key is ignored and counted.
  • Clipboard history (Win+V) keeps its own copy that nothing can remove. While it is on, or cannot be checked, capture_key refuses (with a window: turn history off; on the terminal route you may accept it with --allow-clipboard-history). Clipboard-manager apps may do the same and cannot be detected.
  • If the clipboard cannot be emptied the result says so (clipboard_warning) and never claims it was.
  • The agent's rules (also in the skill): never look at the key; never type it; only click Copy; never enter a password, 2FA code or CAPTCHA; with no browser tool, stop and use connect.

What still needs you: signing in, 2FA, CAPTCHAs and GitHub's "sudo mode" password prompt. If the agent's browser tools read the key field (page text, a screenshot), the agent sees the key — the rules forbid it and nothing here can stop a browser tool.

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.

What is enforced where

Claude Code Claude Desktop, Cursor, Codex, Gemini
MCP tools never return a key yes yes
Permission window (session only) and advisory signed terminal consent yes yes
Hook blocks reading .env, clipboard readers, keychain readers yes (hook install) no hook exists: the agent can read a .env file
Where keys go .env (or keychain) keychain by default (setup-agent)
Rules file skill install skill install --for cursor|codex|gemini

Security model

  • Key input via getpass (no echo); never printed, never logged.
  • .env written atomically through a random, exclusive, 0600 temp file + rename (owner-only ACL on Windows); keychain storage never passes the key on a command line.
  • A .env that 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 .env file gets exactly one NAME=value line 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_url the recipe names and shown before anything is asked for; it and a query request 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.
  • scan is offline unless --validate is given; a key is shown as at most its first 4 and last 2 characters in the text, JSON and SARIF output, in errors, and in every message; the full value is held in memory only for a --validate check. Before anything is sent, the host for each service is printed and a y (or --yes) is required.
  • scan opens only regular files of at most 1 MB, without blocking, and never follows a link out of the folder it was pointed at.

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.7.5)

  • scan finds keys by their shape. It misses a key with no recognisable prefix under a name that does not end in KEY, TOKEN, SECRET or PASSWORD, and a key split over several lines (a PEM private key is the exception); it can flag a random-looking value that is not a key. A value longer than about 1.7 KB after NAME= is looked at only up to that length. live means "the service accepted it", dead means "the service said 401": a network failure, a 403 or an unexpected answer is unknown. Build folders such as dist/ are not scanned.

  • hook: the settings shape, the exec form (command + args), onFailure and the answer format were checked against Claude Code's hooks documentation (code.claude.com/docs/en/hooks); the Read, Grep, Bash and PowerShell tool-input field names are in that page too. The Edit, Write, MultiEdit and NotebookEdit field names (file_path; notebook_path for NotebookEdit) could not be confirmed there, so the hook reads file_path, notebook_path and path for all four. The Monitor tool's input is not documented field by field, so the hook reads every text field of it except description. The documentation does not say which Claude Code version added args (hence --shell-form). The hook was exercised by feeding it Claude Code's JSON, not inside a running Claude Code. Nothing here was run on Windows: the Windows command (exec form, forward-slash shell form), the junction check in the folder walk and the "no timer, so no patterns from your own recipes" path were written from the documentation and tested with the platform faked on Linux, not on a Windows machine.

  • 0.6.0: doctor-recipes was run once for real against the 36 recipes' addresses before release; its tests, like every test, use no network beyond 127.0.0.1. The limited and body rules follow the vendors' docs where they speak (Stripe, Supabase, Vercel, GitHub, Resend, SendGrid); for ElevenLabs the docs do not say how a restricted key answers, so that rule is flagged unverified. export was checked against the gh and vercel manuals and run against a stand-in gh, not a real GitHub or Vercel account. Nothing of the Windows clipboard-history check was run on Windows (it reads the EnableClipboardHistory registry value). The agent handshake was exercised over the MCP wire format and with a stand-in client, not inside a running Claude Code.

  • Keychain storage is best effort and opt-in (--store keychain); the .env file 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 mcp returns recipes; the interactive key paste stays in the terminal by design.

  • forget removes 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 .env file, exiting 1 because the key is still there.

  • Windows: run finds npm.cmd and other .cmd/.bat commands through PATHEXT. That lookup is tested with a stand-in for the Windows search, not on a Windows machine, and cmd.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-tool is driven through stand-ins in the tests, not against a real Secret Service (GNOME Keyring, KWallet): none was available. That includes forget, which relies on secret-tool lookup finding 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 empty temporary file before the key is written into it, and is checked again once the file is in place. This is read from the code and checked against a stand-in for icacls, not on a Windows machine.

Metadata

Release files for safeeasyapi 0.7.5

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for safeeasyapi 0.7.5
File Size Uploaded Provenance
safeeasyapi-0.7.5.tar.gz 452.7 kB With provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for safeeasyapi 0.7.5
File Interpreter ABI Platform
safeeasyapi-0.7.5-py3-none-any.whl Python 3 none any Details

Total release size: 684.4 kB

Release files / safeeasyapi-0.7.5.tar.gz

Download URL safeeasyapi-0.7.5.tar.gz
Size 452.7 kB
Tags Source
SHA-256 checksum
How to use checksums
990eef303b0b9b5bde8b5d290894826d2662ca58265b7bc2333a595e8fc3b4e9
BLAKE2b-256 checksum
How to use checksums
e7994d7e753232aeeda0be54de201371499303f618b5e7a8a5e1ce17a3af73d0
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

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.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 26, 2026.

Transparency log

Release files / safeeasyapi-0.7.5-py3-none-any.whl

Download URL safeeasyapi-0.7.5-py3-none-any.whl
Size 231.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
09479a42e19a20a03d51cde2d69ef325650347212360eca1d97399a6ac5f6e2c
BLAKE2b-256 checksum
How to use checksums
01d01e3e99c1ab62c8ae148e89348948f1b0546a2812ad2648707812db3adc9b
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

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.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 26, 2026.

Transparency log

Security for safeeasyapi 0.7.5

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.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for safeeasyapi-0.7.5.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 26, 2026.

Transparency log

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.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page