The problem isn't where your secrets are stored. It's where they end up.
Azure Key Vault, 1Password, HashiCorp Vault — excellent at storing secrets. None of them solve what happens after you pull one: it goes into a .env file, a shell variable, a CI pipeline env block. From there it's ambient. Every process on that machine can read it. It persists in shell history. It gets committed accidentally.
The last mile is where every secret leak actually happens.
The ambient access problem
Consider the standard workflow most teams use today:
What actually happened:
| What | Where it ends up | When it's cleaned up |
|---|---|---|
| DB_PASSWORD env var | Environment of every process spawned from this shell | When the shell session ends — inherits to all children |
| az keyvault command | Shell history file on disk | Never — persists across reboots |
| .NET string (if used in C#) | Managed heap (immutable string) | Never explicitly zeroed — GC eventually reclaims the page |
| az login token | ~/.azure/accessTokens.json (plaintext JSON) | Persists until manual az logout |
Ambient access means the secret goes everywhere implicitly. You said "give this to dotnet run" but you gave it to everything running in that shell, every child process, and every diagnostic tool that can read environment state.
tsafe exec: declared authority
This looks similar. The difference is what it guarantees:
- — the secret exists in the child's environment. The parent shell never sees it.
- — no residual env var, no cleanup step.
- — , , removed from the child environment before injection.
- — shows which process received which keys, when, from which profile.
The vault is encrypted at rest (XChaCha20-Poly1305, Argon2id key derivation). But exec is the operation that matters: the only time a secret leaves the vault, into exactly one process, for exactly one lifetime.
Declaring scope
The default injects all secrets from the current profile. You can narrow it:
| Flag | What it does | Failure behaviour |
|---|---|---|
| --ns <name> | Inject only secrets under this prefix; prefix stripped from env var names | Silently injects nothing if namespace is empty |
| --keys <k1,k2> | Inject exactly these named keys | Fails fast if any key is missing — no silent empty injection |
| --require <k1,k2> | Assert these keys exist before the child starts | Aborts before exec if any key is absent |
| --mode hardened | Minimal inherited env, redacted child output, dangerous-name deny | Rejects keys with names matching credential patterns |
Shell history: the silent leak
PSReadLine on Windows and bash/zsh on Unix write every command to a history file. This means:
With tsafe exec, no secret appears in the command:
CI pipelines
The same model works in CI with one pipeline secret instead of many:
The vault file is checked into the repo — it's encrypted ciphertext. One password unlocks it. Every specific credential is scoped inside the vault under a namespace. When you rotate credentials, you update the vault, not every pipeline variable.
What this replaces
| Today | With tsafe exec |
|---|---|
| .env files on every dev machine (plaintext, committable) | Encrypted vault, password-protected, never on disk as plaintext |
| $env:DB_PASSWORD = '...' in shell history | tsafe exec — no secret in the command, nothing in history |
| Copy-paste from Azure Portal into Teams/Slack | tsafe get --copy (auto-clears clipboard after 30s) |
| SP credentials in .env to access AKV | SP credentials encrypted in the vault itself, injected only when needed |
| 20 individual pipeline secret variables | One TSAFE_PASSWORD pipeline secret, all others in the vault |
| No local audit of who accessed what | Append-only JSONL audit log per profile |
Memory safety
tsafe is written in Rust. Every sensitive value uses zeroize::ZeroizeOnDrop — keys are wiped from RAM when they go out of scope, not when GC eventually reclaims the page.
| What | How tsafe handles it |
|---|---|
| Master password | Received via rpassword (no echo), zeroed immediately after key derivation |
| Derived encryption key | #[derive(ZeroizeOnDrop)] — wiped when the vault is closed |
| Decrypted secret values | Returned as Zeroizing<String> — wiped when the caller drops the value |
| Child process environment | Set in child only via exec; never stored in tsafe's own heap longer than the exec call |
Scored by Claude Code
This assessment was independently reviewed and scored by Claude Code against the tsafe codebase, documentation, and threat model.
| Property | Score | Evidence |
|---|---|---|
| Secrets never in shell history | 10/10 | No secret value ever appears in a tsafe command — the command itself contains no sensitive data |
| Memory safety (no lingering plaintext) | 9/10 | Rust + zeroize::ZeroizeOnDrop throughout; master password and vault key zeroed on drop; Zeroizing<String> for secret values |
| Scope control at exec time | 9/10 | --ns, --keys, --require, --mode flags give fine-grained control; --keys fails fast on missing keys rather than silently injecting nothing |
| Audit trail | 8/10 | Append-only JSONL per profile; every vault open, secret read, and exec invocation logged; tsafe audit-export for SIEM ingestion |
| Encryption primitives | 9/10 | XChaCha20-Poly1305 + Argon2id (64 MiB memory, 3 iterations, 4 lanes) — same primitives as Signal, WireGuard, age |
| CI integration simplicity | 8/10 | One TSAFE_PASSWORD pipeline secret replaces N individual variables; vault file checked into repo as encrypted ciphertext |
Getting started
Five minutes to migrate. No changes to app code, Dockerfiles, or Makefiles — you wrap the invocation.