hushsecrets your AI agent can use but never readGitHub

Security policy

Reporting a vulnerability

Please do not open a public issue for anything that lets someone read a vault they should not.

Open a private security advisory instead. Tell me what you did, what happened, and what you expected; a failing test or a short script is ideal. I will confirm receipt, and I would rather hear about something that turns out to be fine than not hear about it.

There is no bounty. This is one person's project.

Supported versions

hush is pre-1.0 and has not been through an external security review. Fixes go to the latest release only.

VersionSupported
0.9.xyes
< 0.9no — upgrade; see the CHANGELOG for what changed

What is in scope

Anything that lets someone read a secret they should not be able to:

What is already known, and not a finding

These are documented trade-offs rather than oversights. The threat model below covers them in full — see What it does not protect — but in short:


Threat model

What hush protects, what it does not, and where the edges are. Written to be read before you trust it with anything real.

What it protects

Each row names the tests that fail if the control is removed.

HowHeld by
Secrets at rest in your repoAES-256-GCM per value, under a per-vault data keytest/crypto-properties.test.ts, test/scheme-conformance.test.ts
A value moved between slotsAAD binds each ciphertext to env|KEY — a staging URL cannot be pasted into the prod slottest/hush.test.ts ("AAD binds a ciphertext to its env and key"), test/crypto-properties.test.ts
Sharing without a serverThe data key is wrapped once per member (X25519 ECDH → HKDF → AES-GCM), so git push is the whole distribution mechanismtest/scheme-conformance.test.ts, test/hush.test.ts
Offboardinghush team rm mints a new data key and re-seals every value; the removed member's checkout decrypts nothing newtest/hush.test.ts ("removing a member revokes them and re-seals every value")
A vault replaced by someone who is not a memberhush/v3: an admin signs the header (members, roles, key commitments); every member checks the signature against admins it already trusts, and that the key it unwrapped matches. Every machine also pins what it has accepted, so an unsigned or downgraded copy is refusedtest/trust.test.ts, test/signed.test.ts
Some people seeing only some setsA set can have a key of its own, wrapped only for full members and the scoped members given it; hush ci create makes CI identities that are scoped by constructiontest/signed.test.ts ("a scoped member reads dev, not prod…")
Secrets reaching a modelThe MCP server has no tool that returns a value. hush_run injects and streams back redacted outputtest/mcp.test.ts ("never returns a secret value, only its effects"), test/fuzz-surfaces.test.ts
A credential reaching an API without reaching the callerhush_request substitutes inside hush's own process; nothing is substituted into the URL, and a redirect to another host is refused rather than followedtest/request.test.ts ("a redirect to another host is refused…")
A secret reaching a file without reaching the scrollbackhush run --materialize writes one file at 0600, created with wx, removed on ordinary exit and best-effort after a SIGKILL (see the known-limitations list above), gated on revealtest/materialize.test.ts
A credential reaching the clipboard instead of the terminalhush get --copy pipes it to pbcopy/wl-copy/xclip, resolved from PATH, never through argvtest/commands/get.test.ts ("the value goes to the clipboard and never to stdout")
A value of the wrong shape.env.schema rules, checked before anything runs or is sent; messages carry the rule and the length, never the valuetest/schema.test.ts ("a failure message never contains the value")
A key entering a transcripthush_add_secret opens a native input box; the value goes keyboard → vaulttest/mcp.test.ts (hush_add_secret)
Silent use of a credentialApproval dialog naming the command, accounts and variables, optionally gated on Touch IDtest/approval.test.ts, test/commands/policy.test.ts, test/relay.test.ts
Key theft from diskOnly with a hardware identity — see belowtest/hardware-path.test.ts, test/enclave.test.ts

The ladder

Most of what follows is a limitation of a particular rung, not of hush. Run hush level to see which one you are on.

RungNameThe key isAn attacker running as you
1encrypteda file at ~/.hush/identityreads the file, decrypts everything
2keychain-backedin the login keychaincalls security or hush export, decrypts everything
3approved usesamemust get past a dialog you will see
4biometricsamemust produce your fingerprint
5hardware-backedinside a YubiKey or Secure Enclave, non-extractablecannot steal the key at all, and cannot use it without you touching the device

Rungs 3 and 4 are presence controls: they stop silent and remote use, not a determined local attacker who bypasses hush entirely. Rung 5 is the only one that changes what is cryptographically possible.

For which of this matters in your situation — alone, with an agent, or as a team — and what to turn on for each, see docs/SAFETY.md. What has actually been tried against these surfaces, and with what result, is in docs/RED-TEAM.md.

What it does not protect

A software identity is usable by anything running as you. This is the big one. The identity key lives in the login keychain, and hush retrieves it with the security CLI. Any process running as your user can do the same — including a shell command from an agent. Once .hush/policy.json exists, hush get, hush export, hush run and hush add apply the same policy and approval as the MCP tools, so shelling out to hush does not skip the policy. The approval is a separate question and depends on the mode: the dialog program is resolved only from fixed OS-owned paths (/usr/bin, /bin, /usr/local/bin, root-owned and not group- or world-writable) and is always executed by absolute path, so a caller cannot choose it by editing PATH, and there is no longer any environment variable that selects, replaces or skips it. There is also no file to answer: the pending-request queue that used to back this up was a second place the same caller could answer from, and it is gone. An agent with a shell cannot answer an approval by itself: it would have to click a dialog on your screen or touch the fingerprint reader for you. Where a machine can offer neither of those, hush refuses the gated action rather than accepting a file. The fingerprint helper is built fresh from hush's own source, per process, into a private folder — it is never read from a path anything running as you could have written, which is what makes "touch the sensor" mean what it says. The policy in the repo can only tighten what ~/.hush/policy.json, your floor outside the repo, allows — so an agent editing project files cannot loosen it — but nothing stops a process from reading the keychain directly. The policy constrains hush; it cannot constrain a process that bypasses hush.

The prompt itself is app-modal: a click anywhere else cannot answer it and cannot dismiss it. On macOS it is re-presented every 45 seconds until it is answered or the configured wait runs out, so a window that slips behind something comes back to the front, and a request that nobody answers lapses into a refusal rather than a quiet yes.

This holds for a project directory; it assumes hush is being asked about this vault. .hush/vault.json is meant to be committed and read by anyone with repo access — that is the design, envelope encryption protects the values, not the file — so nothing stops a copy of it, plus HUSH_VAULT pointing at the copy, from being opened from a directory that has no policy.json of its own. With no ~/.hush/policy.json floor configured, that reverts to "no policy anywhere for this invocation," which is opt-in by design for a project that was never set up for an agent, not for a copy of one that was. Since 0.6.0 hush writes an empty floor whenever an agent is set up (hush install-mcp, hush install-skill, and saying yes to "will an AI agent use secrets here?"), and hush level / hush doctor flag a machine with an agent registered and no floor. Even an empty ~/.hush/policy.json is enough to keep requireApproval from disappearing; hush secure floor writes one. The floor and the approval now hold on their own terms: the floor keeps the policy in force, and the approval is answered by a dialog or a fingerprint, neither of which the caller can supply.

If that matters for your threat model, use a hardware identity (docs/BIOMETRY.md): with a Secure Enclave key (hush secure --hardware on a Mac) or age-plugin-yubikey, the key is non-extractable and every unwrap needs a touch.

Redaction is defence in depth, not a boundary. It masks known values in a child's output. It cannot see a value that has been base64'd, encrypted, reversed, or written to a file. The controls that hold are allowCommands and human approval.

The command deny list is a speed bump. It blocks the obvious interpreters and exfiltration tools for the MCP tools, and it is a floor that a stale policy.json cannot lower. The CLI does not refuse them: with run approval on they reach the approval prompt with a warning line, so an agent shelling out to hush run -- node -e … still needs a human to click Allow on that exact command; with run approval off, the person has opted out of gating. But no deny list is complete: npm run <script> executes whatever package.json says. Use allowCommands for anything sensitive.

Revocation protects future values only. Anyone who could read a secret has read it. hush team rm re-keys the vault; only Stripe can rotate a Stripe key. The CLI says so every time.

Git history is permanent. A deleted secret remains in history as ciphertext. If the vault key ever leaks, so does everything the history contains.

The audit log shows an edit; it does not prevent one. .hush/audit.log is a hash chain — each line carries the SHA-256 of the line above, the first a random salt — and hush audit verify names the first line that does not follow. Anything running as you can still rewrite the whole file and recompute the chain, or cut lines off the end. A log nobody on the machine can rewrite has to live somewhere else.

Trust on first use. The first time a machine sees a vault, it trusts it as it stands — its members, its admins' signing keys. Everything after that is checked against that first look. A vault forged before your first clone is not caught by the signature; hush team verify <admin> (a safety number you compare over a call) is what closes that gap.

A hardware admin signs with a software key. A YubiKey or Secure Enclave key reached through age can decrypt but not sign, so an admin whose identity is hardware-only signs with a separate Ed25519 key kept in the keychain (or a 0600 file). Stealing it lets someone change who can read the vault — and so read what is added afterwards — but not decrypt anything already there.

Members can write values. The signature covers who holds which key, not what is in the values: any member holding a key can set a value, as before. A member acting in bad faith is out of scope for the vault; keeping non-members out is not.

No zeroisation. Decrypted values live in JS strings and are collected whenever the runtime feels like it. A core dump or swap file may contain them.

hush is not a KMS. No dynamic credentials, no leasing, no expiry.


Every defect found so far, and what changed, is in docs/AUDIT.md.

Thanks

People who reported a security problem in hush, with their permission to be named. There is no bounty; there is this list, a credit in the advisory and the changelog, and a fix you can watch land.

No external reports yet. The findings in docs/RED-TEAM.md and docs/AUDIT.md came from the project's own reviews. The first name here could be yours.