hushsecrets your AI agent can use but never readGitHub

Red team: can an agent get a secret value out?

Date: 2026-09-12 Scope: everything hush itself exposes to an agent — the eight MCP tools, the CLI as an agent would invoke it, the policy files, the approval and grant mechanism, output redaction, .hush/ (audit log, journal, pending/, grants.local.json), and the local app's HTTP API. Out of scope, per SECURITY.md: a process reading the identity key directly from the keychain or ~/.hush/identity and decrypting with src/crypto.ts itself, without going through any hush surface at all. Every probe here goes through hush.

Method: every experiment ran against a fresh mkdtemp() project and a fresh mkdtemp() HUSH_HOME, with a generateIdentity() this session created and passed as HUSH_IDENTITY, HUSH_NO_KEYCHAIN=1, HUSH_BIOMETRY=off (or a specific value under test), HUSH_APPROVAL_MODE=file, HUSH_NO_NUDGE=1, NO_COLOR=1, and made-up placeholder secret values (FAKE-VALUE-..., sk_live_cli, super-secret-value-here — the latter two are the existing test fixtures' own placeholders, not anything real). Nothing here touched the real ~/.hush, a real keychain entry, or a real vault. Real findings got a failing test first, confirmed red against the pre-fix source (either by reverting the fix and re-running, or diffing it out with a small script), then green after the fix — the CONTRIBUTING.md bar.

Findings

#FindingSeverityStatus
1grants.local.json forgery bypasses approval, including biometry: "required"Value out / policy bypassFixed: grants are memory-only (2026-09-16)
2A symlinked grants.local.json turns a later legitimate approval into an overwrite of an arbitrary file (e.g. the policy floor)Policy bypass (persistent)Fixed: nothing is written there any more
3Self-approval by forging .hush/pending/<id>.answerValue out / policy bypassFixed: the file queue is gone (2026-09-16)
4HUSH_VAULT pointed at a copy of vault.json bypasses the project's policy when no floor is configuredPolicy bypassFixed: the floor is created whenever an agent is set up (0.6.0)
5npx node -e ... reaches a denied interpreter; a one-liner hex-encode defeats redactionHardening gap (documented)Pinned
6Values under 5 characters are never redacted (MIN_REDACTABLE)Hardening gap (documented, already tested)No change — already covered
—hush_check_repo's path argument is not confined to the project rootMinor info disclosure (env-var names, never values)Fixed: confined to the project, symlinks resolved (0.6.0)

1. grants.local.json forgery bypasses reveal/add approval — Fixed

Tried: grants.local.json (next to policy.json, inside the project) is read by requestApproval() as a cache of prior human decisions: if a scope key maps to a future timestamp, the action is approved with no dialog, no biometry, nothing. It is a plain, unsigned JSON file written by writeGrant(). An agent has ordinary write access to the project — the same access it needs for basically everything else — so it can write this file itself. The scope string format is not secret: it is spelled out in policy.ts's runScope() and in every call site (reveal:${env}/${key}, reveal:export:${layers}, add:${slug}/${key}, add:${slug}), so an agent does not need to guess it, only compute it from the arguments it is about to pass anyway.

writeFileSync(".hush/grants.local.json",
  JSON.stringify({ "reveal:default/FAKE_API_KEY": Date.now() + 3_600_000 }))

With policy.json set to requireApproval: ["reveal"], biometry: "required" and nobody able to answer a biometric prompt (HUSH_BIOMETRY=off, as any non-macOS host or CI box would be), hush get FAKE_API_KEY --yes printed the plaintext value, exit 0. The same scope shape works for hush add KEY=value, letting an agent plant a credential of its own choosing into the vault with no approval and no record that a human ever saw it.

What happened / why it holds: the disk-cache check in requestApproval() runs before the biometry branch, the dialog, and the file fallback — a hit returns immediately. This is not the same risk as "an agent reads the identity from the keychain" (out of scope): it never touches the identity or the vault's crypto. It defeats the approval gate, using nothing but the ordinary filesystem write access every coding agent already has.

Existing precedent, and where the line is: test/commands/policy.test.ts already had four tests establishing that a forged run:-scoped grant is honoured across process boundaries — deliberately, so "Allow 15 min" survives the next hush invocation being a new process (a CLI run is a fresh process every time). That is an accepted trade-off for run: the action still only lets an already-injected secret be used by a redacted child process, bounded by allowCommands. reveal and add are categorically different — reveal hands back plaintext directly, and add writes a secret with no human review — so skipping either past a forged file is a value out or a planted credential, not a redaction gap.

Fix: src/approval.ts — reveal and add grants are never read from or written to disk (persistToDisk = req.action !== "reveal" && req.action !== "add"). They still coalesce repeated calls within one long-lived process (e.g. hush ui's server) via the in-memory map, which a separate process cannot forge into. run keeps disk persistence, unchanged.

Tests: test/commands/policy.test.ts — "a forged reveal grant on disk is never honoured, even with biometry required", "a forged add grant on disk is never honoured — an agent cannot pre-approve planting its own secret". Confirmed red (value printed / key planted) with the fix reverted, green with it applied.

Resolved further (2026-09-16). The carve-out for run was the same defect at lower volume: a correctly-spelled grants.local.json still pre-authorised a run, and a run is enough to get a value out through the documented redaction gap. So the file is gone entirely — no action is read from disk, and the "accepted trade-off" paragraph above no longer applies. A grant lives in the process the human answered, which is why a one-shot hush command now offers "Allow once" only: it cannot honestly promise longer.


2. A symlinked grants.local.json clobbers an arbitrary file — Fixed

Tried: writeGrant() calls writeFileSync/chmodSync on .hush/grants.local.json, which follow symlinks by default. An agent plants that path as a symlink to a file it cannot write directly — the interesting target is ~/.hush/policy.json, the floor that is supposed to survive exactly this kind of project-directory tampering:

symlinkSync("~/.hush/policy.json", ".hush/grants.local.json")

It then does nothing further. Later, a human legitimately approves an ordinary action ("Allow 15 min" for npm test, say) through the real dialog or file-approval flow. writeGrant() runs as it always does, but the path it writes through now resolves to the floor file.

What happened: the floor's entire content — requireApproval, denyCommands additions, biometry, everything a team lead put there specifically so no repo edit could remove it — was silently replaced with {"run:npm:default": 1735689600000}. The agent never touched the floor path itself; it only arranged for hush's own later, legitimate write to land there. From that point on, every project on the machine that relies on this floor is unprotected, until someone notices ~/.hush/policy.json no longer looks like a policy file.

This is a real escalation, not a restatement of "a software identity is usable by anything running as you": the floor's whole premise (docs/SAFETY.md) is that it sits outside a project-confined agent's write access. A symlink inside the project redirecting a write hush performs outside the project defeats that premise without the agent ever needing write access to ~/.hush itself.

Fix: src/approval.ts's writeGrant() now lstatSyncs the path first and refuses to write (silently, matching the function's existing "best effort" failure mode) unless it is a regular file, not a symlink. Cost: a session grant that would have been persisted through a symlink instead just doesn't persist, so the next hush invocation re-prompts — the safe direction to fail. readGrantsFile() was deliberately left following symlinks on the read side: reading through an attacker's symlink cannot disclose more than an attacker-controlled grants.local.json written directly already would (same JSON shape, same effect on the reading process's own decision), so there is no additional read-side risk to close.

Test: test/commands/policy.test.ts — "a symlinked grants.local.json cannot be used to clobber the user's policy floor". Confirmed red (floor file replaced) with the lstatSync guard removed, green with it restored.


3. Self-approval via a forged .hush/pending/<id>.answer — fixed 2026-09-16

Tried: when no native dialog backend is available (any non-macOS/GNOME/KDE host, or HUSH_APPROVAL_MODE=file, which this entire red-team pass was required to set), askViaFile() drops .hush/pending/<timestamp>-<code>.json and polls for a matching .answer file — the mechanism that exists so a human can run hush approve in a separate terminal. The answer file carries no signature and no binding to who wrote it: existence plus content "session"/"once" is the entire contract.

An agent's own file tool (the same Bash/Write access it has for everything else, running alongside its hush MCP session — exactly the deployment docs/SAFETY.md calls "Situation 2: me and an AI agent") can watch .hush/pending/ and answer its own request:

for f of readdirSync(".hush/pending") if f ends with .json:
  writeFileSync(f.replace(".json", ".answer"), "session")

What happened: with the default/common configuration (biometry: "off" or "preferred" with biometry unavailable, which is every Linux/headless/CI agent host), this fully approved a hush get reveal and a hush_run MCP call under the default policy (requireApproval: ["run","add","reveal"]) with nobody answering — confirmed both through the CLI (hush get printed the plaintext) and through the actual MCP hush_run tool (the injected secret was used, exit 0, no approval prompt blocked it). Chained with finding 5 below (npx node -e hex-encoding), this is a complete, zero-human-interaction path from "default policy, nobody watching" to a transformed secret value in the agent's own tool result.

Why no source fix was attempted: this is not fixable at the file layer. There is no local signal askViaFile() can check that distinguishes "a human in a separate terminal wrote this" from "the same agent process wrote this", when both have ordinary filesystem access to the same directory — embedding the 4-digit code in the answer does not help, because the code is also written into the plaintext <id>.json the agent can already read. This is the same class of limit as rung 3/4 being "presence controls, not a wall" (SECURITY.md): a real dialog (osascript/zenity/kdialog) requires an actual click on the actual screen, which is what makes it "the control that holds"; the file fallback is explicitly the degraded substitute for a host with no screen to click on, and degrades exactly this far when the agent shares that host's filesystem. biometry: "required" is the actual, working mitigation — it denies synchronously before ever reaching askViaFile() when biometry is unavailable, so the pending file is never created (confirmed in the reproduction script's second run).

Fix: none in src/. Documentation: added to SECURITY.md's "What is already known, and not a finding" list, naming the exact mechanism and the mitigation (biometry: "required", or a real native dialog backend — neither of which shares this failure mode). Reproduction: test/fixtures/redteam/pending-answer-self-approval.mts (not run by npm test) — running it prints both the vulnerable case (biometry: "off", value leaks) and the mitigated case (biometry: "required", denied synchronously).

Resolved (2026-09-16). The reasoning above was right about the file layer and wrong about the conclusion. There is no signal that tells a human's answer file apart from the agent's, so the answer was not to make the file trustworthy — it was to stop treating a file as an approval at all. The queue, hush approve, /api/pending, /api/answer and the HUSH_APPROVAL_MODE switch are gone. An approval now has to come from a dialog on your screen or your fingerprint; a host that can offer neither refuses the gated action. The probe above now asserts exactly that: no secret out, no pending file written, and a hand-written .answer file changes nothing.


4. HUSH_VAULT redirect bypasses policy when no floor is configured — fixed in 0.6.0

Tried: .hush/vault.json is meant to be committed and read by anyone with repo access — envelope encryption protects the values, not the file. An agent copies it to a scratch directory that has no policy.json, and invokes hush against the copy directly:

cp .hush/vault.json /tmp/scratch/vault.json
HUSH_VAULT=/tmp/scratch/vault.json hush get FAKE_API_KEY --yes   # cwd: /tmp/scratch

What happened: resolveVaultPath() derives hushDir from dirname(HUSH_VAULT) with no check that this is the vault's "real" project. policyFor(hushDir) finds neither a repo policy.json (none in the scratch dir) nor a floor (~/.hush/policy.json — none configured in this run), so it returns null, and the CLI runs exactly as it would for a project that never opted into agent-safe policy at all: hush get printed the plaintext with no prompt, even though the real project's own policy.json had requireApproval: ["reveal"], biometry: "required". This directly contradicts SECURITY.md's "shelling out to hush is not a way around" claim, under the specific — and common — condition that the user never set up a floor (hush init --agent alone never creates one; the floor is recommended in docs/SAFETY.md, not required).

The mitigation that already works: with any floor file present — even a near-empty {"approvalTimeoutSeconds": 1} — policyFor() no longer returns null, so DEFAULT_POLICY's own requireApproval: ["run","add","reveal"] applies to the scratch-directory invocation regardless of its missing policy.json, and the attack correctly times out and refuses (confirmed in the reproduction script's second run). A floor's mere existence is what matters here, more than its content.

Why no source fix was attempted: the underlying design — policy is a property of a directory, resolved fresh per invocation from HUSH_VAULT/cwd — is load-bearing for legitimate uses of HUSH_VAULT (tests, CI, named vaults) documented right in identity.ts's own comments. Binding policy to "the vault's real project" instead would need the vault or the policy to carry some notion of its own canonical location, which is a design change, not a small patch, and risks being wrong in a different way for a legitimate multi-machine setup.

Fix: none in src/. Documentation: added to SECURITY.md's "What it does not protect" section, spelling out the condition (no floor) and telling anyone whose agent can set environment variables — true of any agent with a shell — to configure one. Reproduction: test/fixtures/redteam/hush-vault-redirect-bypass.mts (not run by npm test).

Update, 0.6.0 — fixed by making the mitigation the default. Since a floor's existence is what closes this, hush now writes an empty ~/.hush/policy.json at every moment an agent is brought near a vault: answering yes to "will an AI agent use secrets here?" (hush init --agent, hush start), hush install-mcp (listed in its "this will write" plan), and hush install-skill. hush level fails a new rung-3 check, and hush doctor names the fix (hush secure floor), when an agent is registered and there is no floor. It never overwrites a floor someone wrote. The reproduction is now a real test: test/floor.test.ts — "after hush install-mcp, the HUSH_VAULT redirect meets an approval and refuses" — confirmed red with the floor write disabled.


5. npx reaches a denied interpreter; a one-liner defeats redaction — pinned, not a new finding

Tried: checkCommand() denies by basename only (node, bash, etc. are in the built-in denyCommands). npx is not itself an interpreter, so it is not denied — and it is a completely ordinary, intended thing to run through hush_run (the existing test suite's own fixtures use "Run: npx vercel deploy" as a representative example). It runs whatever is already on PATH, including node:

hush_run({ command: "npx", args: ["--yes", "node", "-e",
  "process.stdout.write(Buffer.from(process.env.API_KEY).toString('hex'))"] })

What happened: the command ran (npx is allowed), and the reply contained the hex-encoded secret — the literal value never appeared (redaction still catches an exact substring match), but the transformed one sailed through, because the redactor can only ever match known values verbatim.

Why this is not a new finding: SECURITY.md already states both halves of this explicitly and generically: "The command deny list is a speed bump, not a boundary… no deny list is complete: npm run <script> executes whatever package.json says", and "Redaction is defence in depth… it cannot see a value that has been base64'd, encrypted, reversed". npx node -e is one concrete instance of both at once, using nothing but what ships in the box — worth pinning precisely because it is more surprising than the deny list's "go write a wrapper script" framing suggests (no wrapper script needed), but not a different category. Adding npx (or pnpm dlx/bunx) to the built-in denyCommands was considered and rejected: npx <tool> running a one-off CLI (vercel, wrangler, supabase) with secrets injected is a primary, intended hush_run use case, and the existing test suite treats it as such. Blocking it would be a real usability regression for the documented, intended case, not a security fix.

What actually holds, confirmed: allowCommands: ["npm"] refused npx outright ("npx" is not in policy.allowCommands) — this is the control SECURITY.md and docs/SAFETY.md already point to ("Use allowCommands for anything sensitive").

Tests: test/mcp.test.ts — "npx reaches a denied interpreter and a one-liner defeats redaction — the documented speed bump, pinned" and "allowCommands — not denyCommands — is what actually stops the npx indirection". These pin current, documented behaviour; they are not regression tests for a fix.


6. Values under 5 characters are never redacted — already covered, not a new finding

redact.ts's Redactor skips any value shorter than MIN_REDACTABLE = 5 entirely ("Values this short or this common are not worth masking — masking them is noise"). A 4-character secret (a PIN, say) would print unmasked in hush_run output. This is a real, if narrow, gap — but test/hush.test.ts's existing "ignores values too short or too common to be worth masking" test already pins exactly this threshold, and it is a deliberate, reasoned trade-off in the code, not an oversight nobody looked at. No new test added; no change made.


Minor, fixed in 0.6.0: hush_check_repo's path is not confined

hush_check_repo({ path: "/etc" }) scanned outside the project without complaint. scanRepo() only reports which environment-variable names look referenced in source it finds there, plus which of them the vault satisfies — never file content, never vault values — so this is not a "get a secret out" vector. It is still a minor boundary an agent should not be able to reach (the tool's own description says "scan the current codebase"), worth a one-line confinement to the project root as a future cleanup. Not attempted here: it is cosmetic relative to everything else in this pass, and a five-minute fix risks being wrong about some legitimate monorepo layout this red-team pass did not check for.

Update, 0.6.0. confinedScanRoot() in src/mcp.ts resolves the requested path against the project root with realpath on both sides and refuses anything that is not the root or beneath it — an absolute path elsewhere, ../, or a symlink inside the project that leads out of it. A monorepo is fine: the root is the repository's, so packages/app is inside it. Tests in test/mcp.test.ts, "hush_check_repo stays in the project (S-2)".


Probes that failed (negative results)

What could not be tested, and why

Bite table

TestFails without the fixPasses with it
test/commands/policy.test.ts — "a forged reveal grant on disk is never honoured, even with biometry required"Yes — prints sk_live_cliYes
test/commands/policy.test.ts — "a forged add grant on disk is never honoured — an agent cannot pre-approve planting its own secret"Yes — plants PLANTED_KEYYes
test/commands/policy.test.ts — "a symlinked grants.local.json cannot be used to clobber the user's policy floor"Yes — floor file replaced with grant JSONYes
test/mcp.test.ts — "npx reaches a denied interpreter…" (pinning, not a fix)N/A — pins existing, documented behaviourYes
test/mcp.test.ts — "allowCommands — not denyCommands — is what actually stops the npx indirection" (pinning)N/AYes

All confirmed by reverting each fix (or removing just its guard clause) and re-running the specific test, per CONTRIBUTING.md's bar, then restoring it.