English · 繁體中文
Local, agent-safe secrets for per-worktree development. Zero service, zero daemon.
.env files are a liability the moment an AI coding agent is in your repo: agents love to
cat .env and grep -r KEY, and a stray value ends up in a model's context or an
accidental commit. hush keeps secrets out of your worktree entirely — the repo holds
only a value-free declaration; the actual values live age-encrypted outside it, and are
injected only into the child process of the command that needs them.
hush run -- npm run dev # decrypts, injects env into THIS child only, execs- 🔍
cat .env/grep -r KEYin a worktree finds key names only, never values. - 🔐 Secrets are age-encrypted in one file outside the repo; the master key lives in the macOS Keychain — there is no plaintext key file on disk.
- 🧬 Values are injected only into the child process of
hush run— never your shell. - 🤖 Smooth for humans, strict for agents. Agent / non-interactive contexts (e.g.
CLAUDECODEset, no TTY) are auto-detected and locked to "use, don't see". - 🌳 Per-worktree by design. The declaration is committed, so a fresh worktree works immediately; values are keyed by git branch and inherit from a base profile.
Platform: macOS only. hush relies on the macOS Keychain (master key) and an
hdiutilRAM disk (soeditnever writes plaintext to persistent storage).
With Go (recommended — builds locally, no Gatekeeper quarantine):
go install github.com/allen-hsu/hush@latest
hush install # idempotently adds eval "$(hush hook)" to ~/.zshrcWith Homebrew:
brew install allen-hsu/tap/hush
hush installFrom source:
git clone https://github.com/allen-hsu/hush && cd hush
go build -o ~/bin/hush . # ensure ~/bin is on your PATH
hush installThen restart your shell (or source ~/.zshrc).
cd my-project
hush init # writes .hush.toml (committed; declares keys, no values)
hush import .env --shred # migrate an existing .env, then destroy the plaintext
hush ls # key names + which profile resolves each (no values)
hush run -- npm run dev # decrypt, inject env into this child only, execThat's the whole loop: declare → import → run. Your code reads process.env /
os.environ / vm.envString exactly as before — hush just populates the environment of
the process it launches. No SDK, no library, no code change.
Three pieces, cleanly separated:
| What | Where | Contains | |
|---|---|---|---|
| Declaration | .hush.toml |
committed in the repo | which keys, how to pick a profile — never values |
| Store | store.age |
~/.config/hush/ (outside any repo) |
all values, age-encrypted, namespaced project → profile → key |
| Master key | age identity | macOS Keychain | generated on first use; no plaintext key file on disk |
hush run reads the declaration, resolves the active profile (by git branch by
default), decrypts the store with the Keychain key, injects the resolved values into the
child process, and execs it. Plaintext exists only in that child's memory.
profile = "branch" keys values by git branch, so each worktree/branch gets its own set:
git worktree add ../feature-x -b feature-x
cd ../feature-x
hush fork # clone the `base` profile into this branch's profile
hush set DATABASE_URL # override only the few values that differThe committed .hush.toml travels with the checkout, so the new worktree is ready at
once; you only set the diffs.
| Command | What it does |
|---|---|
hush run -- <cmd> |
Resolve profile, inject env into the child, exec. Use, don't see. |
hush edit |
Edit a profile in $EDITOR (TTY only; agents refused; RAM-disk backed). |
hush set <KEY> |
Set one value — interactive prompt or piped stdin. |
hush unset <KEY> |
Remove a key's value from the active profile (leaves it declared). |
hush purge <KEY> |
Remove a key from every profile + .hush.toml (destructive; needs --yes non-interactively). |
hush ls |
List declared keys + which profile resolves each. Never prints values. |
hush get [KEY] |
Print a value (TTY only; refused for agents). Omit KEY to pick from a numbered list; set disable_get = true to forbid it entirely. |
hush import [path] |
Import an existing .env. Flags: --profile, --force, --shred. |
hush fork [--from p] |
Copy a profile into the active profile. |
hush cp <from> <to> |
Copy one profile's values into another. |
hush init |
Scaffold a .hush.toml. |
hush install |
Idempotently add the shell hook to ~/.zshrc. |
hush hook |
Print the shell integration snippet (eval "$(hush hook)"). |
hush scrub |
Print shell cmds to clear hush vars/shims before launching an agent. |
--json is accepted by ls, get, set, unset, import, fork, cp for
machine-readable output — useful for scripts and agents.
eval "$(hush hook)" (added by hush install) gives you, in an interactive shell:
- a dim banner showing the active
project · profilewhen youcdinto a project; - shims: for each command in
.hush.tomlshims = [...](you choose them), typing the bare command auto-wraps it —npm run devruns ashush run -- npm run dev. Values land in that child only; your persistent shell stays clean.
Leaving the project tears the shims down. When an agent is detected — CLAUDECODE
(Claude Code), CODEX_SANDBOX (Codex), or HUSH_AGENT set, or no TTY — nothing
installs and hush get is refused; the agent must call hush run explicitly and never
inherits shims or shell env. HUSH_AGENT=1 is the universal override for any other
runtime. The cd feed (hush context) reads only .hush.toml (+ git), never the store,
so it never triggers a Keychain prompt.
# .hush.toml — committed, value-free
profile = "branch" # branch | cwd | fixed:<name>
extends = "base" # fall back to this profile for keys absent in the active one
keys = ["DATABASE_URL", "DEPLOYER_KEY"]
shims = ["npm", "pnpm"] # opt-in; commands to auto-wrap with `hush run`
# disable_get = true # forbid `hush get` entirely — values usable only via `hush run`
# deny_agent_run = true # also refuse `hush run` for detected agents — humans onlyhush stops the realistic, common failure modes:
cat/grepof worktree files surfacing secret values;- accidental commit of values (the repo only ever holds key names);
- secrets leaking into your persistent shell (and thereby into an agent you launch from it);
editwriting plaintext to persistent storage (it uses a RAM disk; secure-delete is unreliable on APFS/SSD, so not-writing-it is the only sound guarantee).
hush does not stop a same-user process that deliberately runs hush run -- env.
Any local tool that can decrypt can be invoked by anything sharing your uid; closing that
needs a separate trust domain (daemon / per-binary Keychain ACL), which is intentionally
out of scope for a local single-user tool. The goal is to defeat accidental exposure and
an agent's reflex to read files — not a determined local attacker.
Per-project agent policy (in .hush.toml):
disable_get = true— forbidhush geteven for a human (values usable only viarun).deny_agent_run = true— refusehush runwhen an agent marker is set, so an honest agent can't pull values at all; a human runs secret-dependent commands.agent_profile = "sandbox"— detected agents resolve against a sandbox profile instead, so the agent can still run the program but only ever sees throwaway test credentials. Often the most practical answer: stop hiding secrets from the agent, give it ones that don't matter. (extendsis disabled in this case so a missing key can't leak a real value frombase.)
These are guardrails against honest/careless agents, not walls. Detection is
marker-based, and a process sharing your uid can unset the marker — or copy a wrapper
script's unset — to evade. You cannot, on the same uid, let an agent run a
secret-bearing process yet stop it from reading that process's own env/memory. A real
boundary needs the privileged run to happen somewhere the agent can't control: a separate
user, a daemon/broker that returns only results, or running the app as a service the agent
merely talks to over localhost. Plain .env is strictly weaker than any of this — the
agent just reads the file.
hush is a single static Go binary with two runtime dependencies on macOS system tools
(security, hdiutil/diskutil). To ship your own builds, GoReleaser
can produce a Homebrew tap formula and GitHub Release archives in one step. Prefer
go install / brew (which build locally) over raw downloaded binaries: an unsigned,
un-notarized binary downloaded from a release will be quarantined by macOS Gatekeeper.
v0.1 — released. go install github.com/allen-hsu/hush@latest.
Full command set is in place and tested:
run · edit · set · unset · ls · get · import · fork · cp · init · install · hook · context · scrub.
Tests cover the store crypto (round-trip, encryption-at-rest, wrong-key rejection),
profile/extends resolution, dotenv parsing, and the RAM-disk temp path.
Out of scope (by design, for a local single-user tool): multi-recipient / team sharing, secret rotation, non-macOS platforms.
See docs/SPEC.md for the full design rationale.
MIT
