Lightweight secrets manager — encrypted vault, env injection, stdout redaction. Optional MCP (stdio) delegates to CLI. No Docker, no cloud account.
Project description
ownlock
Lightweight secrets manager — encrypted local vault, .env injection, stdout redaction.
No Docker. No cloud account. Just pip install ownlock.
Why teams use it
- Works in Cursor / Codex / Claude Code sandboxes where
export DATABASE_URL=...in your shell does not cross into the agent's isolated session —ownlock runreads the vault from disk and injects secrets into the child process. - Commit
.envwithvault("KEY")references — teammates clone the repo and runownlock initto fill in their local vault; no Slack DMs with secret lists.
Quick start
pip install ownlock
ownlock init
ownlock init creates the vault. If you already have a .env in the directory, it offers to import secrets from it (and to rewrite the file to use vault(...) references) on the spot — that's the entire onboarding.
If you'd rather drive it manually:
ownlock set api-key
# Add to .env: MY_APP_KEY=vault("api-key")
ownlock run -- python app.py
ownlock + your AI coding assistant
A surprising practical reason ownlock has stuck for me: agentic sandboxes.
Modern coding assistants (Cursor's background agents, OpenAI Codex, Claude Code, etc.) often run inside locked-down sandboxes that start with a fresh shell. Plain environment variables exported in your interactive shell rarely cross that boundary — the agent spawns its own session and your export DATABASE_URL=... is gone. export and parent-shell env vars do not reach the sandbox; the vault file on disk does.
ownlock run works inside those sandboxes because:
- The vault is a file on disk, not a shell session.
- The agent runs
ownlock run -- some-command, which reads.env, talks to the vault on disk, and injects secrets as env vars into that one child — exactly the layer your app reads from. - Stdout redaction means values that do sneak through (logs, error messages) come out as
[REDACTED:NAME]. - The MCP integration (below) lets the agent run commands without ever seeing the values itself.
Net effect: agents can run real commands against real local secrets — install from a private package registry, hit your dev database, exercise a paid API — without you handing them a long-lived secret in chat or wiring per-secret env vars into every sandbox.
If you give an agent the ability to run shell commands at all, prefer ownlock run over exporting secrets in the parent shell.
MCP (Model Context Protocol)
Optional integration. The MCP server does not decrypt the vault in its own process. It spawns the ownlock CLI as a subprocess; passphrase and secrets are handled only in that subprocess.
pip install 'ownlock[mcp]'
Configure your client to launch the stdio server:
ownlock-mcp
Tools:
ownlock_run— same asownlock run -f <file> -e <vault_env> -- <command...>; returns exit code and captured stdout/stderr (redaction applies in the child as usual).ownlock_list_secret_names— same asownlock list(names only, never values).ownlock_version— installed package version.
get and export are intentionally not exposed via MCP.
Cursor example (.cursor/mcp.json or global MCP settings):
{
"mcpServers": {
"ownlock": {
"command": "ownlock-mcp",
"args": []
}
}
}
Use the full path to ownlock-mcp if it is not on your PATH (e.g. ~/.local/bin/ownlock-mcp or your venv’s bin).
Get secrets into the vault
There's one command — ownlock import — that handles every shape of .env you might have:
ownlock import # auto-discover .env / .env.local / etc in cwd
ownlock import path/to/.env # one file
ownlock import test.env .env # multiple files (0.2+)
ownlock import -f .env -f .env.local # same via -f
ownlock import .env --rewrite # plaintext: import, then rewrite file to vault(...)
ownlock import .env --values-from values.json # non-interactive vault-ref fill
Interactive pickers (cyan numbered lists) show when you're in a TTY, did not pass --yes, and either several env files are selected or a single plaintext file has multiple keys. --yes skips all prompts. See UPGRADE.md for examples.
Upgrading from 0.1.x? See UPGRADE.md — command renames (auto/bootstrap → import), vault/KDF upgrades, and migration checklist.
import looks at the file and routes itself:
| File contents | What import does |
|---|---|
Plain KEY=VALUE lines |
Adds them to the vault. With --rewrite, also rewrites the file in place to vault("KEY") references (with a 0600 backup under .ownlock/backups/). |
Already has vault(...) references |
Prompts only for the keys that aren't in your vault yet — the teammate-onboarding case. Pair with --values-from JSON for non-interactive runs. |
ownlock init calls into this flow automatically when it sees a .env in the directory, so a teammate cloning the project and running ownlock init gets walked all the way to a working vault.
Initialize a vault
| Command | Effect |
|---|---|
ownlock init |
Project vault at ./.ownlock/vault.db. First run also creates the global vault and stores the passphrase in the keyring. If a .env is in cwd, init offers to import secrets and rewrite the file to vault(...) references. |
ownlock init --global |
Global vault only at ~/.ownlock/vault.db (passphrase in keyring). |
ownlock init
# or global only:
ownlock init --global
Teammate onboarding: commit .env with lines like API_KEY=vault("API_KEY"). After clone, each dev runs ownlock init once — ownlock detects the references and prompts only for the keys missing from their local vault.
Store, list, delete
ownlock set my-secret
ownlock set api-key=your-value
ownlock set database-url --env production
ownlock list
ownlock get my-secret
ownlock delete my-secret
For multi-line secrets (PEM keys, JSON service-account files, etc.):
ownlock set tls-key --from-file ./service.pem
ownlock set release-notes --editor # opens $EDITOR on a 0600 temp file
set and import overwrite any existing value for the same key (and env).
Onboarding a teammate
Two flows depending on whether the new dev needs to receive secrets or just fill in placeholders.
Fill in placeholders — your .env is committed with vault("...") lines and the new dev runs:
ownlock init # init detects the existing .env and walks them through
# or, after the vault already exists:
ownlock import # auto-detects vault() refs and prompts only for missing keys
Idempotent: re-running after another teammate adds a new vault reference asks for that one key only.
Hand off real values — pack a subset of your vault into an encrypted bundle, share it (Slack, email, anywhere), and let the recipient import it:
# Sender
ownlock share API_KEY DB_URL -o handoff.olbundle
# (prompts for a separate "bundle passphrase"; tell the teammate over a different channel)
# Recipient
ownlock import-share handoff.olbundle
The bundle uses its own passphrase — independent from your local vault — so the bundle file and the recipient's vault can each have different access boundaries. import-share refuses to overwrite existing keys without --overwrite.
ownlock install-hook writes a pre-commit hook (or appends to .pre-commit-config.yaml) that runs ownlock scan on every commit, so a new dev who pastes a value into a file by mistake gets caught locally.
Upgrading a vault
Your existing vault keeps working forever — but ownlock 0.2.0 raised the default PBKDF2 iteration count and added a versioned ciphertext format so future upgrades don't break anything. Two operations:
ownlock rekey --upgrade-kdf # re-encrypt at current KDF parameters, keep passphrase
ownlock rekey --rotate-passphrase # change the vault passphrase
ownlock rekey # interactive: asks which (or both)
rekey is safe to interrupt: it copies the live vault to .ownlock/backups/vault.db.backup-<timestamp> (mode 0600) before any change, then re-encrypts inside a single SQL transaction. If anything fails, the live file is unchanged. Successful runs leave the backup in place for you to delete once you're confident the new vault works.
ownlock doctor shows the current schema version + KDF iterations and prints a one-line tip when an upgrade is available.
Which vault is used?
| Situation | Vault used |
|---|---|
Inside a directory with .ownlock/vault.db (or a parent) |
Project vault |
| No project vault found | Global vault |
--global |
Global vault |
--project |
Project vault at current directory |
Commands that accept --global / --project: set, get, list, delete, import, scan, rekey, share, import-share, and export --example (template lines from vault key names only). run and plain export resolve vault references from your .env file.
.env format
Use vault("name") for secrets; they are resolved when you run commands.
API_KEY=vault("api-key")
DATABASE_URL=vault("database-url")
SUPABASE_SERVICE_KEY=vault("supabase-service-key", env="production")
To force the global vault: vault("name", global=true).
Run and export
ownlock run -- python app.py
ownlock run -f .env.local -- python app.py
ownlock export --format docker
get and export print secrets to stdout. Use only in trusted environments; prefer ownlock run to inject secrets into a process without printing them.
rewrite-env and scan
ownlock rewrite-env -f .env # rewrite an env file to use vault(...) without re-importing
ownlock scan . # compare files against the project vault's secrets
ownlock scan . --global # compare against ~/.ownlock/vault.db instead
scan walks the directory for plaintext copies of vault values. It uses the project vault (.ownlock/vault.db in cwd or a parent) when one exists; it does not silently fall back to your global vault — pass --global for that. Without a project vault, it still flags legacy *.ownlock.bak plaintext backups. Value comparison needs a vault with secrets and the correct passphrase (OWNLOCK_PASSPHRASE or keyring).
rewrite-env is useful when you've already populated the vault (e.g. via ownlock set) and just want to swap an existing .env over to references. For a fresh project, ownlock import .env --rewrite does both steps in one go.
Templates (for apps that can't read env vars)
Some apps — classic ASP.NET (web.config), older .NET / Java config, kubeconfig, appsettings.Development.json, etc. — read real files on disk and ignore environment variables. Instead of rewriting these apps, keep a template that references the vault and let ownlock materialize the real file on demand.
Name any file <stem>.template.<ext> and use {{vault("name")}} inside. ownlock render produces <stem>.<ext> in the same directory.
<!-- connectionStrings.template.config -->
<connectionStrings>
<add name="Default"
connectionString='Server=db;User=sa;Password={{vault("db-password")}};' />
</connectionStrings>
ownlock render # render every *.template.* under cwd
ownlock render web.template.config # single file
ownlock render --dry-run # preview without writing
ownlock render -e production # override vault env
Rendered outputs are written atomically and (on POSIX) with mode 0600. ownlock refuses to write a rendered file unless it appears in .gitignore — pass --force to override. The gitignore check uses git check-ignore when git is installed (so negation, anchored patterns, nested .gitignore, and .git/info/exclude are all honored); it falls back to a best-effort fnmatch scan otherwise.
Rendered values are inserted so the output file stays syntactically valid for common config formats. Use format="..." on a single {{vault(...)}} reference or pass --raw when you handle quoting yourself. See ownlock render --help for flags.
For legacy .NET apps, the least invasive pattern is configSource on web.config:
<connectionStrings configSource="connectionStrings.config" />
<appSettings file="appSettings.secrets.config" />
No C# changes, no recompile — ConfigurationManager keeps reading XML as usual; ownlock just produces the external file.
You can also chain rendering with run. For safety, run --render takes explicit template paths (it does not auto-discover — that prevents rendering untrusted templates that happen to live under the current directory):
ownlock run --render web.template.config -- dotnet MyApp.dll
ownlock run --render a.template.json --render b.template.yaml -- ./start.sh
ownlock run --render web.template.config --render-cleanup -- ./start.sh # unlink on exit
{{vault(...)}} accepts the same options as the .env form: env="production", project=true, global=true.
What about non-secret per-env config?
ownlock deliberately handles secrets only. Values that vary per environment but aren't sensitive — log levels, port numbers, hostnames, feature flags — should keep using your app's native mechanism:
| Stack | Native per-env mechanism |
|---|---|
| ASP.NET (classic) | Web.Debug.config / Web.Release.config transforms |
| ASP.NET Core / .NET | appsettings.{Environment}.json + IConfiguration |
| Java / Spring | application-{profile}.properties |
| Node / Next.js | .env.development / .env.production |
| Terraform | workspaces + terraform.tfvars per env |
| Kubernetes | ConfigMap + kustomize overlays |
Put non-secrets there, put secrets in ownlock. The two layers compose cleanly: your app reads its environment-specific config normally, and the one or two values that shouldn't be in git come from a template that ownlock renders.
Example: web.config stays untouched and relies on standard transforms for LogLevel/AppUrl; only connectionStrings is externalized via configSource and rendered by ownlock from a template. One small surface for secrets, zero disruption to the app's existing config story.
Command reference
| Command | Description |
|---|---|
ownlock init |
Create project vault (first run also creates global + keyring). Offers to import an existing .env if found |
ownlock init --global |
Create global vault only |
ownlock set KEY / KEY=VALUE |
Store secret. --from-file PATH, --editor for multi-line values |
ownlock get KEY |
Print decrypted value |
ownlock list |
List secret names (--json for machine-readable metadata, no values) |
ownlock doctor |
Environment diagnostics (versions, vault paths, KDF status, --json) |
ownlock delete KEY |
Remove a secret |
ownlock rekey |
Re-encrypt at current KDF (--upgrade-kdf) and/or rotate passphrase (--rotate-passphrase) |
ownlock run -- CMD |
Resolve .env, inject secrets, redact stdout |
ownlock export |
Print resolved KEY=VALUE pairs (--example emits KEY=vault("KEY") lines from vault names only) |
ownlock import [FILE...] |
Get secrets into the vault. Auto-detects plaintext vs. vault(...) references. --rewrite to also convert the file. --values-from JSON for non-interactive vault-ref fill |
ownlock share KEYS -o FILE |
Export an encrypted bundle for a teammate (separate bundle passphrase) |
ownlock import-share FILE |
Import an encrypted bundle into the local vault |
ownlock rewrite-env |
Rewrite an existing env file to use vault(...) (without re-importing) |
ownlock scan DIR |
Scan for leaked secret values (--max-file-bytes skips huge files before reading) |
ownlock render [TEMPLATE] |
Render *.template.* files, substituting {{vault(...)}} with decrypted values |
ownlock install-hook |
Install a pre-commit hook that runs ownlock scan |
ownlock completion {bash,zsh,fish,pwsh} |
Print a shell completion script |
Add --global or --project to set, get, list, delete, import, scan, rekey, share, import-share, and export --example to override vault selection.
How it works
- Secrets are encrypted with AES-256-GCM before storage; key derivation uses PBKDF2-HMAC-SHA256. Secret names are encrypted too (schema v3): the database stores an HMAC lookup id plus encrypted name blobs, so copying
vault.dbwithout the passphrase does not reveal key names likeAPI_KEY. Iteration counts and ciphertext format are documented in SECURITY.md. - The vault is a local SQLite file. No network; everything stays local.
ownlock runresolvesvault()in.env, injects the resolved values into one child process, and redacts those values from the child's stdout/stderr. The master passphrase is not passed to the child.
Passphrase model
There is one passphrase per vault. ownlock looks for it in this order:
OWNLOCK_PASSPHRASEenv var — wins if set. CI / scripts / agent sandboxes use this.- System keyring — macOS Keychain, Windows Credential Manager, Linux Secret Service. Populated by
ownlock initso you don't type the passphrase on every command. - Interactive prompt — last resort.
Use ownlock doctor to see which source resolved the passphrase right now.
Pairs with your CI / cloud secrets manager
ownlock is a local-developer tool. It does not replace your platform's secrets manager — it complements one:
| Layer | Tool | Where the values live |
|---|---|---|
| Local development | ownlock | ~/.ownlock/vault.db and per-project .ownlock/vault.db |
| CI / production | GitHub Actions secrets, Harness, AWS Secrets Manager, Doppler, Vault, Fly.io secrets, etc. | Your platform's encrypted store |
The shared boundary is the env vars your application reads (DATABASE_URL, STRIPE_KEY, …). ownlock injects them locally; CI / your runtime injects them in production. The app code stays the same.
.ownlock/ is gitignored by default, so the local vault never reaches CI on its own — you opt in if you want it. A typical team setup:
- Each developer runs
ownlock initafter cloning (orownlock importto fill invault(...)placeholders). - CI sets the same env var names directly from the platform's secrets store. ownlock isn't installed on the runner.
ownlock scanruns in pre-commit (ownlock install-hook) and in CI to refuse commits containing leaked vault values.
You can use ownlock in CI too — set OWNLOCK_PASSPHRASE from a runner secret and import a vault you manage outside git — but most teams find the dual-store model cleaner.
CI integration examples
The pattern is always the same: inject env vars the way your platform expects, using the same names your app reads locally via ownlock run. ownlock does not need to be on the runner unless you deliberately store a vault there.
GitHub Actions — secrets become env vars; no ownlock install required:
jobs:
test:
runs-on: ubuntu-latest
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
STRIPE_KEY: ${{ secrets.STRIPE_KEY }}
steps:
- uses: actions/checkout@v4
- run: pytest
# Optional: block commits that leak vault values into the repo
secret-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install ownlock
- run: ownlock scan . --yes
env:
OWNLOCK_PASSPHRASE: ${{ secrets.OWNLOCK_PASSPHRASE }}
The scan job needs OWNLOCK_PASSPHRASE only if you want ownlock to decrypt the vault and compare file contents against live secret values. For many teams, a lighter check (grep for sk_live_, AWS key patterns, etc.) plus ownlock install-hook locally is enough.
Harness / other CD platforms — same idea: map platform secrets to env vars in the pipeline stage. Harness doesn't expose secrets for arbitrary local dev pull (by design); that's why ownlock exists on the laptop. In CI, reference ${{ secrets.YOUR_SECRET }} or the Harness equivalent — the app never knows the difference.
Running tests with ownlock on the runner (when you want one vault file managed outside git):
- run: |
echo "${{ secrets.OWNLOCK_VAULT_B64 }}" | base64 -d > .ownlock/vault.db
chmod 600 .ownlock/vault.db
- run: ownlock run -- pytest
env:
OWNLOCK_PASSPHRASE: ${{ secrets.OWNLOCK_PASSPHRASE }}
Store the encrypted vault.db as a base64 blob in your secrets manager, rotate via ownlock rekey, and never commit .ownlock/.
Pre-commit locally + CI scan — belt and suspenders:
ownlock install-hook # local: ownlock scan on every commit
# CI: ownlock scan . --yes # catches anything that bypassed the hook
What to commit vs keep local
| Commit to git | Keep local only |
|---|---|
.env with vault("KEY") references |
.ownlock/vault.db (encrypted secrets) |
*.template.* files with {{vault("KEY")}} |
Plaintext .env backups under .ownlock/backups/ |
| Application code that reads standard env vars | OWNLOCK_PASSPHRASE (use keyring locally, runner secret in CI) |
Security
- Encryption + KDF details, threat model, and the full security posture live in SECURITY.md.
- get / export: Both print secrets to stdout. Use in trusted environments only; prefer
ownlock runto inject without printing. - Overwrite:
setandimportoverwrite existing values for the same key (and env); no append. - File permissions: Restrict permissions on
~/.ownlock/and.ownlock/. Project init adds.ownlock/to.gitignoreand writes backups under that directory with mode0600. - Reporting: See SECURITY.md.
- Automated checks: Bandit, pip-audit, security-focused tests, and subprocess smoke tests (
pytest -m smoke) — see SECURITY_TESTING.md. Editable installs may skip CVE lookup for the ownlock package itself; dependencies are still audited.
License
MIT
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 ownlock-0.2.1.tar.gz.
File metadata
- Download URL: ownlock-0.2.1.tar.gz
- Upload date:
- Size: 113.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c48a51ff57a3b24ab39cc211091f0c7daef7a3ae6464af52a182023f36689f41
|
|
| MD5 |
46dc0966f59b5d8b3b9f7f9bb2351bdf
|
|
| BLAKE2b-256 |
58af234dbd7a56b918691a3eba9d49e90c3ce7d0f1d17ff4d1794e7627b68d3c
|
Provenance
The following attestation bundles were made for ownlock-0.2.1.tar.gz:
Publisher:
ci.yml on thebscolaro/ownlock
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
ownlock-0.2.1.tar.gz -
Subject digest:
c48a51ff57a3b24ab39cc211091f0c7daef7a3ae6464af52a182023f36689f41 - Sigstore transparency entry: 1730365577
- Sigstore integration time:
-
Permalink:
thebscolaro/ownlock@d1c431b388fa5048b847f261ea70435fd95675bb -
Branch / Tag:
refs/tags/v0.2.1 - Owner: https://github.com/thebscolaro
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
ci.yml@d1c431b388fa5048b847f261ea70435fd95675bb -
Trigger Event:
push
-
Statement type:
File details
Details for the file ownlock-0.2.1-py3-none-any.whl.
File metadata
- Download URL: ownlock-0.2.1-py3-none-any.whl
- Upload date:
- Size: 69.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
df4a01994f22162b08145db6f74a1f27d08c8ea342a7a978c17e22bd3d5b4f2c
|
|
| MD5 |
20d8ffbd4dbe7b2e9f6e8a8497221cec
|
|
| BLAKE2b-256 |
004e2362a3510a6c80deae9ee96dc168c2497c3709bd5aed6f0eb00a6c2b7aa5
|
Provenance
The following attestation bundles were made for ownlock-0.2.1-py3-none-any.whl:
Publisher:
ci.yml on thebscolaro/ownlock
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
ownlock-0.2.1-py3-none-any.whl -
Subject digest:
df4a01994f22162b08145db6f74a1f27d08c8ea342a7a978c17e22bd3d5b4f2c - Sigstore transparency entry: 1730366603
- Sigstore integration time:
-
Permalink:
thebscolaro/ownlock@d1c431b388fa5048b847f261ea70435fd95675bb -
Branch / Tag:
refs/tags/v0.2.1 - Owner: https://github.com/thebscolaro
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
ci.yml@d1c431b388fa5048b847f261ea70435fd95675bb -
Trigger Event:
push
-
Statement type: