A macOS-only toolkit for replacing local plaintext .env files with SOPS-encrypted dotenv files, plus an agent skill that helps Codex and Claude apply the pattern safely.
This repo gives you three things:
- a macOS workflow for keeping the age private identity in Keychain while committing only encrypted dotenv files
- a reusable agent skill that teaches Codex and Claude-style agents how to audit, migrate, and validate real repositories
- supporting scripts, templates, docs, examples, and article drafts so the workflow is easy to adopt and easy to explain
If you have a pile of local repos with plaintext .env, .env.local, or other .env.* files, this repo helps you move them to a safer default:
- commit ciphertext, not plaintext
- keep the private age identity outside the repo
- decrypt into process environment at command start
- validate with a real smoke command instead of assuming encryption alone means the repo still works
This repo is intentionally macOS only. The local helper scripts use the macOS security CLI and Keychain as the default place to store the private age identity.
SOPS is a tool for encrypting config files. In this repo, it encrypts dotenv files so the values on disk look like ENC[...] ciphertext instead of real API keys, tokens, or database URLs.
age is the key format SOPS uses here. It has two parts:
- a public recipient, which looks like
age1...and can be committed - a private identity, which looks like
AGE-SECRET-KEY-...and must stay secret
This toolkit stores the private identity in macOS Keychain. When you run a wrapped command, SOPS asks Keychain for the private identity, decrypts the dotenv values into the child process environment, and exits. Your app still reads normal env vars. The repo stores ciphertext.
Plaintext dotenv files are convenient, but they are also easy to forget about. They get swept up by backups, editor indexing, broad rg searches, shell helpers, and coding agents doing repo-wide inspection. They are often gitignored, which helps with accidental commits, but it does nothing for the fact that the secrets still sit on disk in a long-lived plaintext file.
The approach here is simple:
- generate an age identity
- store the private identity in macOS Keychain
- commit only encrypted dotenv files plus
.sops.yaml - run apps, tests, and scripts through
sops exec-envor the included wrapper - validate the runtime path with a real command
That makes local repos less likely to expose secrets during broad scans, backups, and agent work without pretending this is a full enterprise secrets platform.
| Path | Purpose |
|---|---|
skills/sops-age-env-migration/ |
Agent skill for Codex and Claude-style agents |
scripts/setup-age-keychain |
Generate an age identity, store it in Keychain, write the public recipient |
scripts/install.sh |
Install the skill into Codex and/or Claude skill directories |
scripts/audit-envs |
Find plaintext .env and .env.* files |
scripts/encrypt-env |
Encrypt one repo's dotenv file and create .sops.yaml when needed |
scripts/validate-repo |
Decrypt-check, ignore-check, and run a real smoke command |
templates/ |
Copyable .env.sops wrapper scripts for target repos |
docs/ |
Installation, usage, and security guidance |
examples/ |
Before/after migration patterns you can adapt |
articles/ |
Drafts for social posts, blog posts, or longer explainers |
brew install sops agegit clone https://github.com/intertwine/sops-encrypted-envs-mac
cd sops-encrypted-envs-mac
./scripts/setup-age-keychainThat stores the private age identity in macOS Keychain and writes the public recipient to age-recipient.txt. The recipient is safe to commit or share. The private identity is not.
The first decrypting command may trigger a macOS Keychain approval dialog for /usr/bin/security. Click Always Allow if you want local dev commands to stop prompting. If you are working over SSH or another headless session, approve access once from a local GUI session first.
./scripts/install.shDefaults:
- installs into both Codex and Claude skill directories
- uses symlinks so local edits to the skill are picked up immediately
Useful flags:
--copy--codex-only--claude-only--uninstall--dry-run
./scripts/validate-localUse this when the app does not auto-load .env as plaintext at startup.
./scripts/audit-envs /path/to/repo
./scripts/encrypt-env /path/to/repo .env
# update .gitignore so encrypted .env can be committed
./scripts/validate-repo /path/to/repo .env -- uv run pytestFrameworks like Vite, Next.js, Rails, and many Python CLIs will eagerly read .env. For those, prefer .env.sops and a wrapper boundary:
mv /path/to/repo/.env /path/to/repo/.env.sops
./scripts/encrypt-env /path/to/repo .env.sops
mkdir -p /path/to/repo/scripts
cp templates/sops-env /path/to/repo/scripts/sops-env
cp templates/read-age-key-from-keychain /path/to/repo/scripts/read-age-key-from-keychain
chmod +x /path/to/repo/scripts/sops-env /path/to/repo/scripts/read-age-key-from-keychain
./scripts/validate-repo /path/to/repo .env.sops -- npm run test:rawPass the raw command that expects environment variables. If the public script already wraps SOPS, validate the corresponding internal command like test:raw, dev:raw, or serve-raw to avoid double-wrapping.
The point is to stop ignoring the encrypted file while still ignoring any plaintext fallbacks you intentionally keep.
Standard .env path:
# before
.env
.env.*
# after
.env.local
.env.development
.env.test.env.sops path:
# before
.env
.env.*
# after
.env
.env.*
!.env.sopsOnce installed, you can hand a repo to your coding agent and be explicit about the runtime command you want proven:
Migrate this repo from plaintext dotenv files to SOPS + age, prefer
.env.sopsif the framework auto-loads.env, and validate withnpm run test:raw.
The skill is designed to teach agents to:
- inspect the repo before editing
- choose
.envvs.env.sopscorrectly - update wrapper commands instead of writing decrypted files back to disk
- validate with a real smoke command
- report the exact command that proved the migration
Once a repo is migrated, developers should run the commands that decrypt at the outer boundary: npm run dev, npm test, make test, scripts/sops-env .env.sops -- uv run pytest, sops exec-env .env 'uv run pytest', or whatever wrapper the repo exposes. You do not run validate-repo on every edit. Use validate-repo when you first migrate a repo, after changing wrapper commands, or after rotating recipients.
This repo improves local secret hygiene. It does not solve every secret-management problem.
- It helps with plaintext-on-disk risk, accidental commits, broad local searches, and coding-agent inspection of repos.
- It does not protect you from malware or an attacker already running as your macOS user.
- It does not prevent secrets from leaking into logs, crash reports, shell history, or test output.
- It does not erase historical exposure if plaintext
.envfiles were already committed. - It is intentionally a local developer workflow, not a full team secret distribution platform.
Read docs/security.md before publishing this pattern inside a team.
This repo is deliberately scoped. If you need stronger guarantees, add controls outside the scope of this toolkit:
- Per-person or per-device recipients so one stolen key does not unlock every repo
- Secret rotation after migration, especially if the plaintext file ever lived in git history
- Push protection and secret scanning on GitHub or your git host
- 1Password if you want a more user-friendly human secret store than raw Keychain usage
- Vault, Infisical, Doppler, AWS Secrets Manager, GCP Secret Manager, Azure Key Vault if you need shared access, RBAC, audit trails, dynamic secrets, or production-grade service-to-service secret delivery
The rule of thumb: this repo is for making local repos less brittle and less leaky. When you need team policy, central auditability, or dynamic credentials, graduate to a real secret manager.
CI is intentionally secondary to the local workflow here. If you need CI decryption, use a dedicated CI recipient or SOPS_AGE_KEY secret, not the macOS Keychain helper. docs/security.md includes a minimal GitHub Actions example plus recipient-rotation guidance.
./scripts/validate-local
shellcheck scripts/* templates/sops-env templates/read-age-key-from-keychainMIT - see LICENSE.
