hush

command module
v0.1.5 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 22, 2026 License: MIT Imports: 3 Imported by: 0

README

hush

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

hush demo

  • 🔍 cat .env / grep -r KEY in 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 runnever your shell.
  • 🤖 Smooth for humans, strict for agents. Agent / non-interactive contexts (e.g. CLAUDECODE set, 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 hdiutil RAM disk (so edit never writes plaintext to persistent storage).


Install

With Go (recommended — builds locally, no Gatekeeper quarantine):

go install github.com/allen-hsu/hush@latest
hush install     # idempotently adds  eval "$(hush hook)"  to ~/.zshrc

With Homebrew:

brew install allen-hsu/tap/hush
hush install

From source:

git clone https://github.com/allen-hsu/hush && cd hush
go build -o ~/bin/hush .   # ensure ~/bin is on your PATH
hush install

Then restart your shell (or source ~/.zshrc).


Quick start

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, exec

That'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.


How it works

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.

Per-worktree workflow

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 differ

The committed .hush.toml travels with the checkout, so the new worktree is ready at once; you only set the diffs.


Commands

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.


Shell integration

eval "$(hush hook)" (added by hush install) gives you, in an interactive shell:

  • a dim banner showing the active project · profile when you cd into a project;
  • shims: for each command in .hush.toml shims = [...] (you choose them), typing the bare command auto-wraps it — npm run dev runs as hush 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 only

Security model

hush stops the realistic, common failure modes:

  • cat / grep of 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);
  • edit writing 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 — forbid hush get even for a human (values usable only via run).
  • deny_agent_run = true — refuse hush run when 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. (extends is disabled in this case so a missing key can't leak a real value from base.)

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.


Distributing / forking

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.


Status

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.

License

MIT

Documentation

Overview

Command hush is a local, agent-safe secret manager for per-worktree env. See docs/SPEC.md for the design.

Directories

Path Synopsis
internal
cli
Package cli implements the hush command dispatch and shared helpers.
Package cli implements the hush command dispatch and shared helpers.
config
Package config loads the per-repo .hush.toml — the value-free declaration of which keys a project needs and how to pick a profile.
Package config loads the per-repo .hush.toml — the value-free declaration of which keys a project needs and how to pick a profile.
keychain
Package keychain wraps the macOS `security` CLI to store the hush master age identity.
Package keychain wraps the macOS `security` CLI to store the hush master age identity.
resolve
Package resolve turns a Config + the current git/cwd state into a concrete project, active profile, and the resolved key/value set (walking `extends`).
Package resolve turns a Config + the current git/cwd state into a concrete project, active profile, and the resolved key/value set (walking `extends`).
store
Package store manages the single global encrypted secret file.
Package store manages the single global encrypted secret file.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL