Execute commands with secrets loaded encrypted from your system keyring
Project description
kleys 🔐
κλείς (kleís) — Greek for "key". Because your secrets should live in a keyring, not a .env file.
Execute commands with secrets fetched encrypted from your keyring, eliminating permanent .env files on disk. Works on Linux, macOS, and Windows.
kleys is a CLI tool that stores secrets encrypted (Fernet/AES-128-CBC, enabled by default) in your system's keyring, then loads them at runtime to run your commands — eliminating .env files from disk. Perfect for developers who want to keep credentials off the filesystem while maintaining a smooth development workflow.
Quick Example
Instead of this (storing secrets on disk):
# ❌ Dangerous: secrets exposed on filesystem
cat .env # DATABASE_PASSWORD=super_secret
python app.py
Do this (secrets from keyring):
# ✅ Secure: secrets loaded from keyring, never persisted to disk
kleys python app.py
Under the hood: kleys retrieves your secrets from the system keyring and passes them to your command — no permanent .env files on disk. It has three modes: file mode (default) writes a temp .env with secure permissions and deletes it after; file descriptor mode (@SECRETS@) passes secrets via an in-memory FD with zero disk writes; export mode (--export) exports secrets as real environment variables without writing any file.
🔒 Encryption by default: All secrets are encrypted with Fernet (AES-128-CBC + HMAC-SHA256) before being stored in the keyring. An attacker who enumerates your keyring gets ciphertext, not plaintext. The decryption key is never stored in the keyring. Use
--unencryptedonly when necessary.
Why You Need This
Four threats this eliminates at once. Security and usability — no trade-off.
1. File-harvesting malware. Supply-chain attacks and post-exploitation tools scan disk for .env files and exfiltrate them. With --export or @SECRETS@, the file never exists on disk — nothing to steal.
2. The .env in git accident. One wrong git add . and credentials are in your repository history forever. No .env file on disk means nothing to stage, commit, or push.
3. Process-table leaks. The common pattern export $(cat .env | xargs) spawns cat, xargs, and echo subprocesses whose command-line arguments expose your secrets to any user running ps aux. --export passes secrets directly in the subprocess environment — no intermediate processes, no command-line arguments, no process-table leaks.
4. LLM data exposure. AI coding agents with filesystem access can read .env files and act on credentials — even with safety instructions in place. And secrets included in LLM prompts may be retained in training data or exposed through breaches. No file on disk means nothing for agents to find and nothing to leak through a prompt.
kleys --export ansible-playbook site.yml # no file = no malware, no git risk
kleys --export ./deploy.sh # no subprocess = no ps leaks
kleys --export npm run dev # all four, every time
Prerequisites
- Python 3.11+
- A keyring service: GNOME Keyring, KWallet, macOS Keychain, or Windows Credential Manager
Installation
From PyPI (Recommended)
pip install kleys
Or with uv:
uv tool install kleys
Or with pipx:
pipx install kleys
From source
git clone https://github.com/hugobatista/kleys.git
cd kleys
pip install .
Docker
kleys also runs on Docker — see Docker Guide for standalone and host keyring workflows.
Usage
kleys [OPTIONS] COMMAND [ARGS...]
kleys show [--key KEY] [--password PASSWORD]
kleys clear [--key KEY]
Subcommands
| Subcommand | Aliases | Description |
|---|---|---|
run |
— | Execute a command with secrets from the keyring (default when no subcommand given) |
show |
list |
Display all stored secrets for a key |
| clear | delete, rm | Delete all stored secrets for a key |
Run Options
| Option | Description |
|---|---|
--secrets-file FILE, -f FILE |
Path used to expose secrets to the subprocess in file mode (default mode). A temp file is created at this path with the secrets and SECRETS_FILE env var points to it. If FILE already exists, kleys offers to import it into the keyring instead (default: .env) |
--key KEY, -k KEY |
Keyring entry identifier (default: current folder name) |
--export, -e |
Export secrets as environment variables directly to the subprocess (overrides file mode, no temp file created). The secrets content must be in valid KEY=VALUE format per line — the user is responsible for correct formatting. |
--password PASSWORD |
Encrypt secrets with a password (Fernet/AES-128-CBC). If omitted, resolves from KLEYS_PASSWORD env var or prompts. |
--unencrypted, -u |
Disable encryption, store/retrieve secrets as plaintext (default: encryption enabled). |
Show / Clear Options
| Option | Description |
|---|---|
--key KEY, -k KEY |
Keyring entry identifier (default: current folder name) |
--password PASSWORD |
Decryption password (prompts if omitted and needed) |
Environment
| Variable | Description |
|---|---|
KLEYS_PASSWORD |
Encryption password used automatically for encrypt/decrypt when set, unless overridden by --password PASSWORD. |
Encrypted by default (Fernet/AES-128-CBC). Use
--unencryptedto opt out (not recommended).
Modes of Operation
kleys has three modes for passing secrets to your command:
| Mode | How to enable | How secrets arrive | Writes to disk? |
|---|---|---|---|
| File (default) | No flag | Temp .env file, SECRETS_FILE points to it |
Temp file, auto-deleted |
| File Descriptor | @SECRETS@ token in args |
In-memory FD as /dev/fd/9, SECRETS_FILE=/dev/fd/9 |
Never (Unix only) |
| Export | --export / -e flag |
Exported as environment variables in the subprocess environment | Never |
Pick the mode that matches how your tool reads secrets. The examples below show each mode in action.
For detailed data flow and how secrets traverse each mode, see Architecture.
Examples
Example 1: Python development with uv
kleys uv run pywrangler dev
What happens:
- Loads
.envfrom keyring for current folder - Creates temporary
.envfile - Runs
uv run pywrangler devwith secrets available - Deletes
.envafter command completes
Example 2: Python project with hatch
kleys hatch run dev
Perfect for running development servers where you need environment variables but don't want them persisted on disk.
Example 3: Ansible playbook with environment variables
kleys --export ansible-playbook site.yml
Before kleys:
source .env && ansible-playbook site.yml
What happens with --export:
- Loads secrets from keyring (or uses local
.envfile if it exists) - Parses every
KEY=VALUEpair and sets them in the subprocess environment - Runs
ansible-playbook site.ymlwith all env vars available - No temp file is needed when loading from keyring — secrets stay in memory
Useful for any tool that expects secrets as environment variables — Ansible, Terraform, custom scripts, etc.
Example 4: GitHub Actions local testing with act
kleys --secrets-file .secrets act --secret-file .secrets
What happens:
- Uses custom file name
.secretsinstead of.env - Loads or prompts for secrets under that filename
- Runs
actwith the secrets file - Cleans up
.secretsafter execution
This is especially useful for testing GitHub Actions workflows locally while keeping production secrets secure.
Example 5: Multiple environments with custom key names
# Development environment
kleys --key myproject-dev npm start
# Production environment
kleys --key myproject-prod npm start
Each --key name is a separate keyring entry, allowing you to manage different secret sets (dev, staging, prod) for the same project.
Example 6: Docker commands
kleys docker-compose up
Great for docker-compose files that source .env for configuration.
Example 7: Inspecting the secrets file path
# File mode: SECRETS_FILE points to the temp file
kleys bash -c 'echo "Secrets file: $SECRETS_FILE"'
# FD mode: SECRETS_FILE points to the file descriptor
kleys bash -c 'echo "Secrets FD: $SECRETS_FILE"' --secret-file @SECRETS@
The SECRETS_FILE environment variable is set in both file and FD modes.
Example 8: File descriptor mode (no disk I/O)
Note: FD mode (
@SECRETS@) is Unix/macOS only — not supported on Windows. Use--exportor--secrets-fileon Windows instead.
kleys act --secret-file @SECRETS@
What happens:
- Detects
@SECRETS@token in arguments - Loads secrets from keyring into memory
- Creates file descriptor at
/dev/fd/9(no disk write) - Replaces
@SECRETS@with/dev/fd/9 - Runs
actwhich reads secrets from the file descriptor - FD automatically closes — no cleanup needed
Perfect for:
- GitHub Actions local testing with
act - Docker with
--env-file - Any tool that can read from file descriptors
Won't work for:
- Shell sourcing (
source $SECRETS_FILE) - Tools that verify file exists with stat checks
- Tools that need to read the file multiple times
Example 9: Docker with file descriptor mode
kleys docker run --env-file @SECRETS@ myimage
Secrets are loaded from keyring and passed to Docker without ever touching the disk. The @SECRETS@ token automatically enables zero-disk-I/O mode.
Example 10: View stored secrets
kleys show
kleys show --key myproject
Displays all secrets stored for the current (or specified) app. Decrypts automatically when needed.
Example 11: Delete stored secrets
kleys clear
kleys clear --key myproject
Deletes all secrets (both encrypted and plaintext) for the app. Useful for resetting or rotating credentials.
Advanced Features
Custom Secrets File Locations
# Use a different file name
kleys --secrets-file .env.production npm run build
# Use a path in a different directory
kleys --secrets-file /tmp/my-secrets ./deploy.sh
SECRETS_FILE Environment Variable
In file mode and FD mode, your command receives SECRETS_FILE pointing to the secrets source:
# File mode: points to temp .env
kleys bash -c 'echo "Secrets are at: $SECRETS_FILE"'
# FD mode: points to /dev/fd/9
kleys bash -c 'echo "Secrets are at: $SECRETS_FILE"' --secret-file @SECRETS@
In export mode (--export), SECRETS_FILE is not set — the secrets are already in the environment.
File Descriptor Mode (No Disk I/O)
Note: FD mode is Unix/macOS only — not supported on Windows. Use
--exportor--secrets-fileinstead.
For maximum security, use the @SECRETS@ token in your command to pass secrets via file descriptor without writing to disk:
kleys act --secret-file @SECRETS@
How it works:
- kleys detects the
@SECRETS@token in your command arguments - Loads secrets from keyring into memory only
- Creates file descriptor at
/dev/fd/9(no disk write) - Replaces
@SECRETS@token with/dev/fd/9in all arguments - Your command reads from the FD as if it were a file
- No temp file created, no cleanup needed
- FD automatically closes when command completes
Security benefits:
- Zero disk I/O — secrets never touch the filesystem
- No directory entry visible in
ls - Automatic cleanup (pipe closes on exit)
- No permission race conditions
- Simple, explicit syntax — just use
@SECRETS@where you need it
Compatibility:
✅ Works with these tools:
kleys act --secret-file @SECRETS@
kleys docker run --env-file @SECRETS@ image
Replaced tokens work just like file paths:
kleys mycommand --config @SECRETS@ --output results.txt
# All @SECRETS@ tokens are replaced with /dev/fd/9
Export Mode (Environment Variable Export)
For tools that expect secrets as actual environment variables (like Ansible, shell scripts, or tools that call os.getenv), use the --export flag:
kleys --export ansible-playbook site.yml
How it works:
- Before running your command, kleys parses the secrets into
KEY=VALUEpairs - These pairs are injected into the subprocess environment — your command sees them as real env vars
- No temp file is written — secrets are loaded directly from keyring into memory
- Works with
@SECRETS@too — exports from keyring directly into env without touching disk - When a local
.envfile already exists (not loaded from keyring), it is read directly from disk
Which tools benefit from --export?
| Tool | Without --export | With --export |
|---|---|---|
| Ansible | source .env && ansible-playbook ... |
kleys --export ansible-playbook ... |
| Terraform | source .env && terraform plan |
kleys --export terraform plan |
| Shell scripts | source .env && ./deploy.sh |
kleys --export ./deploy.sh |
Any os.getenv/$VAR consumer |
needs vars in environment | vars are exported automatically |
Key difference: without --export, secrets are written to a temp file and SECRETS_FILE env var is set.
With --export, secrets are loaded directly into memory — no temp file, no SECRETS_FILE, just real env vars.
Combined with @SECRETS@:
kleys --export ansible-playbook --vault-password-file @SECRETS@ site.yml
This both exports secrets into the environment AND passes one via file descriptor — maximum flexibility with zero disk writes.
Encrypted Mode (Default, Password-Protected Secrets)
Encryption is enabled by default. All secrets are encrypted with Fernet (AES-128-CBC + HMAC-SHA256) before being stored in the keyring:
# Default: prompts for password (with confirmation) on first use
kleys npm start
# Password from environment variable
KLEYS_PASSWORD=hunter2 kleys npm start
# Explicit password (visible in ps — use with care)
kleys --password hunter2 npm start
# Opt out of encryption
kleys --unencrypted npm start
How it works:
- Encrypted entries are stored under a separate keyring key:
app_name-encrypted(distinct from the plaintext keyapp_name). - On lookup, the tool tries the encrypted key first. If found, it resolves the password and decrypts.
- On first run (no existing entry), you'll be prompted for a password (with confirmation) unless
KLEYS_PASSWORDor--password VALUEis set. - Existing plaintext entries remain readable with a warning:
ℹ Found plaintext entry — not encrypted. New entries will be encrypted. - Use
--unencryptedto disable encryption entirely (e.g., for CI/CD scripts that can't provide a password).
Password resolution priority (encrypt and decrypt):
| Priority | Source |
|---|---|
| 1 | --password VALUE (explicit) |
| 2 | KLEYS_PASSWORD env var |
| 3 | Interactive prompt (with confirmation when storing) |
Password confirmation: When prompted interactively to create a new encrypted entry, the password is asked twice to prevent typos. The decrypt path (loading existing entries) prompts once without confirmation.
Auto-detection: If an encrypted entry exists and no password flags are passed, the tool resolves via env var or prompt automatically.
Security: Encrypted secrets resist D-Bus GetSecret attacks — an attacker who enumerates the keyring gets ciphertext, not plaintext. The decryption key is never stored in the keyring.
Key derivation: Uses PBKDF2-HMAC-SHA256 with 600,000 iterations and a random 16-byte salt for each encryption operation.
CI/CD note: If you run kleys in automation without a password, you must add --unencrypted or set KLEYS_PASSWORD:
# After (encryption is default — choose one):
kleys --unencrypted deploy.sh
# OR
KLEYS_PASSWORD=$(cat /etc/secret.txt) kleys deploy.sh
First-Run Setup
On first use (when secrets aren't in keyring):
- kleys prompts: "Paste your secrets content..."
- Paste your
.envcontent (KEY=VALUE format) - Press
Ctrl-D(Unix) /Ctrl-Z + Enter(Windows) to finish (orCtrl-Cto cancel) - Secrets are encrypted and stored in system keyring
- Future runs load automatically
Security Notes
- Keyring encryption: Secrets stored in your system's encrypted keyring service
- File permissions (file mode): Temporary files created with
600permissions (owner read/write only) - Short-lived exposure (file mode): Files on disk exist only during command execution
- Zero disk I/O: Use
@SECRETS@(FD mode) or--export(export mode) — secrets never touch disk - No git commits: No
.envfiles left behind to commit accidentally - Session isolation: Each terminal session can use different secrets with
--keyflag - Encrypted payloads (Fernet/AES-128-CBC): Secret content is encrypted with Fernet (AES-128-CBC + HMAC-SHA256) before keyring storage by default, protecting against D-Bus
GetSecretenumeration attacks. See the section below for details. Use--unencryptedto disable.
Why Encrypted Mode Matters: The Keyring Enumeration Attack
The Linux Secret Service API (D-Bus) that powers GNOME Keyring, KWallet, and similar services is accessible to any process running under your user account — no authentication required. This means any code on your machine (malware, a compromised pip install, a malicious Node.js package, or even a curious colleague) can enumerate every item in your keyring:
import secretstorage
bus = secretstorage.dbus_init()
col = secretstorage.get_default_collection(bus)
for item in col.get_all_items():
print(f' Label: {item.get_label()}')
print(f' Attributes: {item.get_attributes()}')
print(f' Secret: {item.get_secret().decode(errors="replace")}')
print()
This is not a vulnerability in the keyring — it is by design. The keyring service provides session-level isolation (secrets are encrypted at rest when the session is locked), but once you unlock your keyring at login, any process sharing your D-Bus session can retrieve every secret in plaintext.
This is why kleys encrypts by default. When using encrypted mode (Fernet/AES-128-CBC, enabled by default):
- An enumerating attacker sees only ciphertext — meaningless without the decryption password
- The decryption password is never stored in the keyring (resolved via interactive prompt, environment variable, or
--passwordflag) - Even if an attacker dumps every keyring entry, your secrets remain confidential
See Security Architecture for a detailed analysis of the D-Bus attack surface and encryption protocol.
When --unencrypted is used, secrets in the keyring are as exposed as .env files on disk — any process with D-Bus access can read them. Reserve --unencrypted for ephemeral or isolated environments (e.g., CI containers where D-Bus access is restricted).
⚠️ Important: While kleys improves security, temporary files are still written to disk briefly in file mode. For maximum security:
- Use
@SECRETS@for tools that accept a file path (FD mode — zero disk I/O) - Use
--exportfor tools that need env vars (export mode — zero disk I/O) - Use encrypted home directories
- Ensure your keyring is properly locked when not in use
- Be cautious running kleys on shared systems
Troubleshooting
"Command fails with @SECRETS@"
The command may require a regular file instead of a file descriptor. Try without the @SECRETS@ token:
# If this fails:
kleys mycommand --file @SECRETS@
# Try this instead:
kleys mycommand
"No secrets found" on every run
The keyring store may have failed silently. Run with --unencrypted and --export to trigger a fresh prompt:
kleys --unencrypted --export your-command
Command fails but secrets file remains
If your command crashes before cleanup runs, manually remove the temp file:
rm .env # on Unix/macOS
del .env # on Windows (cmd.exe)
Remove-Item .env # on Windows (PowerShell)
Want to delete stored secrets
Use the clear subcommand to delete all stored secrets for a key:
kleys clear
kleys clear --key myproject
Uninstallation
pip uninstall kleys
Or with the tool installer you used:
uv tool uninstall kleys # if installed with uv
pipx uninstall kleys # if installed with pipx
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file kleys-0.1.0.tar.gz.
File metadata
- Download URL: kleys-0.1.0.tar.gz
- Upload date:
- Size: 2.3 MB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
af5a532575bfccfa5f78363e5d58eb62785472f0f97ed4002b2ac57794cdee27
|
|
| MD5 |
91defcfd2102449dbd1367bef34a43b7
|
|
| BLAKE2b-256 |
8ac06bd8937159d6589e0de901a1945285b8e3a2ab69ad157cb288805c6df77a
|
Provenance
The following attestation bundles were made for kleys-0.1.0.tar.gz:
Publisher:
pypi.yml on hugobatista/kleys
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
kleys-0.1.0.tar.gz -
Subject digest:
af5a532575bfccfa5f78363e5d58eb62785472f0f97ed4002b2ac57794cdee27 - Sigstore transparency entry: 1630623202
- Sigstore integration time:
-
Permalink:
hugobatista/kleys@0c7bd8bfdbf6ebb43f593b21912a0a89a3417561 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/hugobatista
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
pypi.yml@0c7bd8bfdbf6ebb43f593b21912a0a89a3417561 -
Trigger Event:
release
-
Statement type:
File details
Details for the file kleys-0.1.0-py3-none-any.whl.
File metadata
- Download URL: kleys-0.1.0-py3-none-any.whl
- Upload date:
- Size: 29.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4f7e15ad85fb94d114ddb8d47536bac775e942fca41ad0f98a75290defa64e8a
|
|
| MD5 |
89e97dd5a72662a4192d1da09df35f45
|
|
| BLAKE2b-256 |
6f83248849d49b8679a2faa75ce3b3df00451d6c962016300122cdf9b3163585
|
Provenance
The following attestation bundles were made for kleys-0.1.0-py3-none-any.whl:
Publisher:
pypi.yml on hugobatista/kleys
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
kleys-0.1.0-py3-none-any.whl -
Subject digest:
4f7e15ad85fb94d114ddb8d47536bac775e942fca41ad0f98a75290defa64e8a - Sigstore transparency entry: 1630623231
- Sigstore integration time:
-
Permalink:
hugobatista/kleys@0c7bd8bfdbf6ebb43f593b21912a0a89a3417561 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/hugobatista
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
pypi.yml@0c7bd8bfdbf6ebb43f593b21912a0a89a3417561 -
Trigger Event:
release
-
Statement type: