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.
| Version | Supported |
|---|---|
| 0.9.x | yes |
| < 0.9 | no — upgrade; see the CHANGELOG for what changed |
What is in scope
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_requestreaching 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.jsonto forge — see the approval section below. hush run --materializewriting 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 onPATH. .env.schemavalidation 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.
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:
- 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_requestinherits that: the response is masked by matching known values, and it is read as plain text — the request asks forAccept-Encoding: identityfor exactly that reason, and the body is capped before it is scanned. A remote that base64-encodes a reflected credential still defeats it.allowHostsis 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
revealapproval, and it is deliberately absent from the MCP surface. The file is0600and removed on exit, but aSIGKILLcannot be caught: the file survives that, and page cache holds it regardless. - Values under five characters are not masked at all.
redact.tsskips them as noise;hush addandhush adoptnow say so when they store one. .env.schemais 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.
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.
| 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 |
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.
| 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.
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.