Hide .env secrets from prAIng eyes.
(Note: this project was previously called enveil and has been renamed to enject)
AI coding tools like Claude Code, Copilot, Cursor, and others can read files in your project directory, which means a plaintext .env file is an accidental secret dump waiting to happen. This isn’t theoretical. It is a known issue that has happened to me several times (even after explicitly telling Claude not to peek in Claude Code’s settings.json file). enject solves this by ensuring plaintext secrets never exist on disk at all. Your .env file contains only symbolic references; the real values live in an encrypted local store and are injected directly into your subprocess at launch.
This project is inspired by Filip Hric’s solution/blog post, which uses a similar concept leveraging 1Password. I wanted a self-contained solution that didn’t rely on a third party services giving rise to this solution. And yes, this project was built almost entirely with Claude Code with a bunch of manual verification and testing.
This project is primarily designed to mitigate the known issue of AI/LLM tools accidentally reading .env secrets in your project. Additional benefits include preventing secret leakage if a .env is accidentally committed to a repository, the ability to share .env files containing references instead of plaintext secrets, and the option to share the encrypted store itself.
This project is not a silver bullet for preventing an AI agent from obtaining your secrets. For example, an agent can still write code (by accident or via prompt injection) that exfiltrates secrets to terminal output or a file at runtime. We strongly advise against relying on this tool, or .env files in general, to store production secrets
Your .env file looks like this:
DATABASE_URL=en://database_url
STRIPE_KEY=en://stripe_key
PORT=3000
Technically it is safe to commit (maybe don’t do that, though), and more importantly: safe for any AI tools accidentally (or perhaps not-so-accidentally) snooping in on it.
When you run enject run -- npm start, it:
- Prompts for your master password (never echoed, never in shell history)
- Derives a 256-bit AES key from your password using Argon2id (64 MB memory, 3 iterations)
- Decrypts the local store with AES-256-GCM — the store file is a 12-byte random nonce followed by authenticated ciphertext
- Resolves every
en://reference against the decrypted map - Zeroizes the key and password bytes from memory
- Spawns your subprocess with the resolved values injected into its environment
The store file is a binary blob. Without the master password, it is indistinguishable from random noise. The nonce is freshly generated on every write, so AES-GCM nonce reuse is impossible. Any modification to the ciphertext — even a single flipped bit — causes authentication to fail and decryption to be refused.
This release is still in alpha, so requires appending the latest version to install when calling cargo install
cargo install enject --version 0.2.0-alpha Requires Rust 1.70+.
git clone https://github.com/greatscott/enject
cd enject
cargo build --releaseThe compiled binary is at target/release/enject. Install it once to a location on your PATH so you can run it from any project:
macOS / Linux (bash or zsh)
# Option A: ~/.local/bin (no sudo required, common on Linux)
mkdir -p ~/.local/bin
cp target/release/enject ~/.local/bin/
# Option B: /usr/local/bin (requires sudo, available system-wide)
sudo cp target/release/enject /usr/local/bin/
# Option C: ~/.cargo/bin (already on PATH if you used rustup)
cp target/release/enject ~/.cargo/bin/If you used option A and ~/.local/bin is not already on your PATH, add this to your shell config (~/.zshrc, ~/.bashrc, or ~/.bash_profile):
export PATH="$HOME/.local/bin:$PATH"Then reload it:
source ~/.zshrc # or ~/.bashrcVerify it worked:
enject --versionThe binary is installed globally — you never reinstall it. But each project gets its own encrypted store:
cd your-project
enject initThis creates .enject/ in the current directory with the project's config and encrypted store. Add it to .gitignore — it should never be committed.
Run this once per project, in the project root:
enject initThis generates a random 32-byte salt, writes .enject/config.toml, creates an empty encrypted store at .enject/store, and prompts you to set a master password. Add .enject/ to your .gitignore — the store should never be committed.
enject set some_database_url
# prompts: Value for 'database_url': (hidden)
enject set some_api_keyValues are always entered interactively. There is no way to pass a value as a command-line argument — this prevents secrets from appearing in shell history or ps output.
DATABASE_URL=en://some_database_url
MY_API_KEY=en://stripe_key
PORT=3000
Plain KEY=VALUE lines pass through unchanged. Only en:// references are resolved.
enject run -- npm start
enject run -- python manage.py runserver
enject run -- cargo runEverything after -- is passed verbatim to the OS. The subprocess inherits your full shell environment (so PATH, HOME, etc. are present) with .env values layered on top.
enject list # print stored key names (never values)
enject delete <key> # remove a secret
enject import <file> # encrypt all values in a plaintext .env, rewrite it as en:// template
enject rotate # re-encrypt the store with a new master passwordThere is no get and no export. Printing a secret value to stdout creates an AI-readable leakage vector — the entire point of enject is to keep values off disk and out of any readable output stream.
Every security invariant has a corresponding automated test and a manual inspection path.
cargo test31 tests, all covering the claims below.
Automated: store::password::tests::test_encrypt_decrypt_roundtrip
Saves a secret, persists the store, reloads it from disk, decrypts, and checks the value round-trips correctly. Only passes if the bytes on disk are valid ciphertext — plaintext would fail decryption.
cargo test store::password::tests::test_encrypt_decrypt_roundtripManual inspection:
enject init # password: test123
enject set mykey # value: my-super-secret
xxd .enject/store | head -5
strings .enject/storexxd will show binary data. strings will return nothing — there are no ASCII sequences to extract. The first 12 bytes are the random nonce; everything after is AES-GCM ciphertext with a 16-byte authentication tag appended.
Automated: store::password::tests::test_nonce_changes_on_each_save
Saves the store twice in a row, reads the first 12 bytes of the file each time, and asserts they differ.
cargo test store::password::tests::test_nonce_changes_on_each_saveManual inspection:
xxd .enject/store | head -1 # note the first 12 bytes
enject set anotherkey # any write rotates the nonce
xxd .enject/store | head -1 # first 12 bytes are now differentAutomated: store::password::tests::test_wrong_password_returns_err
Creates a store with one password, then attempts to unlock it with a different password and asserts Err is returned.
cargo test store::password::tests::test_wrong_password_returns_errManual:
enject list # enter the wrong password
# output: "Wrong master password or corrupted store."
# exit code: 1AES-GCM produces a 16-byte authentication tag over the ciphertext. Any modification — even a single flipped bit — causes verification to fail before decryption proceeds. The plaintext is never exposed.
Automated: store::password::tests::test_tampered_ciphertext_returns_err
Flips one byte in the ciphertext region of the store file (past the 12-byte nonce), then attempts decryption and asserts Err.
cargo test store::password::tests::test_tampered_ciphertext_returns_errManual:
# Flip byte 20 (inside ciphertext, past the nonce)
python3 -c "
data = open('.enject/store', 'rb').read()
bad = data[:20] + bytes([data[20] ^ 0xFF]) + data[21:]
open('.enject/store', 'wb').write(bad)
"
enject list
# output: "Wrong master password or corrupted store."If a reference in .env has no matching key in the store, enject run exits immediately with a non-zero code. The subprocess is never launched.
Automated: env_template::tests::test_unknown_ev_ref_returns_err
Calls resolve() with a reference that has no matching entry and asserts Err.
cargo test env_template::tests::test_unknown_ev_ref_returns_errManual:
echo "DB=en://nonexistent_key" > .env
enject run -- env
# output: Secret 'nonexistent_key' not found in store. Add it with: enject set nonexistent_key
# exit code: 1 (the `env` subprocess never ran)Implement optional/additional system-wide store for easier maintenance of secrets used across multiple projects.
Reduce the need to to manually enter the store's password whenever making updates