Skip to content

Security policy

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.

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

Version Supported
0.9.x yes
< 0.9 no — upgrade; see the CHANGELOG for what changed

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

  • Recovering a value from a committed vault without being a recipient of it.
  • Getting a value back through the MCP server, which is supposed to be blind to them — including through hush_run’s output.
  • Reaching the local UI from another machine, or without the session token.
  • A revoked member still being able to decrypt.
  • A vault that someone who is not a member rebuilt, re-keyed or re-signed being decrypted without a refusal — a new member, a new data key, or planted values accepted with no signature from an admin this machine trusts (hush/v3), or with no hush team accept (an older, unsigned vault).
  • A scoped member, or a CI identity, reading a set it was not given.
  • A member who is not an admin changing a signed vault’s membership or keys in a way other members’ hush accepts.
  • Getting a value out through hush request / hush_request: in a header the caller named, in a query string or body it opted into, or reflected back in a response the redactor failed to mask.
  • hush_request reaching a host the policy does not allow, being redirected to one, or sending a credential in cleartext to a host that is not loopback.
  • Any approval being pre-authorised by a file in the project. Grants live in the process the human answered and nowhere else, so there is no grants.local.json to forge — see the approval section below.
  • hush run --materialize writing a value to a path the caller chose, handing that path to a child, or leaving the file behind after the child exits.
  • The clipboard path in hush get --copy: the value reaching it, or the clipboard tool being chosen from somewhere other than a real file on PATH.
  • .env.schema validation printing a value, or a schema declaring a value.
  • Code execution from something a vault or a repository can carry: a value, a key name, .hush/link.json, .hush/vault.json.

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:

  • A software identity can be used by anything running as you. That is what the hardware rung exists for.
  • Output redaction is defeated by encoding. It stops an accident, not an adversary.
  • hush_request inherits that: the response is masked by matching known values, and it is read as plain text — the request asks for Accept-Encoding: identity for exactly that reason, and the body is capped before it is scanned. A remote that base64-encodes a reflected credential still defeats it.
  • allowHosts is empty by default, so an agent may send an allowed set to any https host. The approval dialog is what makes that visible; the host list is what bounds it.
  • Materialising is a reveal, and is treated as one. It writes plaintext to the filesystem, so it needs the reveal approval, and it is deliberately absent from the MCP surface. The file is 0600 and removed on exit, but a SIGKILL cannot be caught: the file survives that, and page cache holds it regardless.
  • Values under five characters are not masked at all. redact.ts skips them as noise; hush add and hush adopt now say so when they store one.
  • .env.schema is read for rules only. The placeholder after = is ignored, and a validation failure prints the key, the rule and the length, never the value.
  • The command deny list is a speed bump, not a boundary.
  • Revocation protects future values only. Anyone who could read a secret has.
  • Git history is permanent.
  • An approval has to come from something the gated process cannot supply: a dialog drawn on your screen by an OS-owned program, your fingerprint, or an answer signed by a device you paired with (the relay, below). There is deliberately no file to answer. A host with none of those cannot ask you anything, so it refuses the gated action instead of pretending. Nothing in the environment can make an approval easier — the one switch that exists (HUSH_NO_DIALOG) can only make hush refuse.
  • The approval relay (docs/RELAY.md) carries only sealed, signed messages: a relay cannot read a request, change one, answer one, or replay an old answer — only delay or drop them, which is a refusal. Its weak point is the requester’s own ~/.hush: something running as you there can rewrite the pairing to trust an “approver” of its own, just as it can read a software key in the same directory. The relay puts a person in front of everything that goes through hush on a remote machine; against code already running as you on it, the answer is a hardware identity.

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

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

How Held by
Secrets at rest in your repo AES-256-GCM per value, under a per-vault data key test/crypto-properties.test.ts, test/scheme-conformance.test.ts
A value moved between slots AAD binds each ciphertext to env|KEY — a staging URL cannot be pasted into the prod slot test/hush.test.ts (“AAD binds a ciphertext to its env and key”), test/crypto-properties.test.ts
Sharing without a server The data key is wrapped once per member (X25519 ECDH → HKDF → AES-GCM), so git push is the whole distribution mechanism test/scheme-conformance.test.ts, test/hush.test.ts
Offboarding hush team rm mints a new data key and re-seals every value; the removed member’s checkout decrypts nothing new test/hush.test.ts (“removing a member revokes them and re-seals every value”)
A vault replaced by someone who is not a member hush/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 refused test/trust.test.ts, test/signed.test.ts
Some people seeing only some sets A 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 construction test/signed.test.ts (“a scoped member reads dev, not prod…”)
Secrets reaching a model The MCP server has no tool that returns a value. hush_run injects and streams back redacted output test/mcp.test.ts (“never returns a secret value, only its effects”), test/fuzz-surfaces.test.ts
A credential reaching an API without reaching the caller hush_request substitutes inside hush’s own process; nothing is substituted into the URL, and a redirect to another host is refused rather than followed test/request.test.ts (“a redirect to another host is refused…”)
A secret reaching a file without reaching the scrollback hush 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 reveal test/materialize.test.ts
A credential reaching the clipboard instead of the terminal hush get --copy pipes it to pbcopy/wl-copy/xclip, resolved from PATH, never through argv test/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 value test/schema.test.ts (“a failure message never contains the value”)
A key entering a transcript hush_add_secret opens a native input box; the value goes keyboard → vault test/mcp.test.ts (hush_add_secret)
Silent use of a credential Approval dialog naming the command, accounts and variables, optionally gated on Touch ID test/approval.test.ts, test/commands/policy.test.ts, test/relay.test.ts
Key theft from disk Only with a hardware identity — see below test/hardware-path.test.ts, test/enclave.test.ts

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

Rung Name The key is An attacker running as you
1 encrypted a file at ~/.hush/identity reads the file, decrypts everything
2 keychain-backed in the login keychain calls security or hush export, decrypts everything
3 approved use same must get past a dialog you will see
4 biometric same must produce your fingerprint
5 hardware-backed inside a YubiKey or Secure Enclave, non-extractable cannot 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.

A project’s .hush/ folder is committed, so it arrives with every clone. Its list of sets can name sets from your library, but a library set named only by that list is not used until you confirm it for that project on this machine; the confirmation lives in ~/.hush. A repository’s policy.json can tighten your rules but not loosen them once you have a personal floor (~/.hush/policy.json, hush secure --floor); without one, the repository’s own policy decides whether you are asked, which is why setup now includes it.

hush serve --tailnet answers other machines on your tailnet (docs/TAILNET.md). A caller is whoever Tailscale says holds the WireGuard key its connection came from, checked against --allow and the tailnet policy’s grants; nothing the caller sends is taken as identity, and a connection from the broker’s own machine is refused, because it would carry the owner’s identity whichever account made it. hush_request keeps the key on the broker, and every request asks the owner unless --without-approval is given, which needs allowHosts. A lease is different: it hands the values of some sets to one enrolled machine, sealed to its key, after an approval that shows the command. hush on that machine runs only that command, but a machine that is not honest can use the values as it likes; a lease is “this machine gets these values”. The broker holds every key it offers, so it is the machine to give a hardware identity.

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.

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.