The Invisible Backslash: How a Shell Escaping Bug Silently Corrupted 20 Releases
A debugging war story. My Tauri signing key worked for 20 releases, then stopped. Three hours later, I found a literal backslash hiding inside the password — put there by shell-quote, invisible to everyone, including me.
It's Friday morning. Beetroot 1.6.5 is ready — big update, 554 tests passing, changelog written. I run the release script. Build finishes in 4 minutes. NSIS installer, MSI — all good. Last step: sign the artifacts for auto-update.
incorrect updater private key password: Wrong password for that key
Okay. Typo, probably. Try again. Same error. Copy-paste the password from the secure note. Same error.
The password hasn't changed. The key file hasn't changed. This exact command signed 20 releases over the past month. Last successful build was six days ago.
Something broke between Saturday and today. And I have no idea what.
Down the rabbit hole
The first hour is methodical. Check the obvious things:
# Key file unchanged?
sha256sum beetroot-signing.key
# Matches the hash from March 24th logs ✓
# Password correct byte-by-byte?
echo -n 'the_password!' | xxd
# Clean hex, no hidden characters ✓
# NTFS timestamps on the key file?
# Created: March 1st. Modified: March 1st. Never touched. ✓Generate a fresh test key with the same password — works perfectly. So the crypto isn't broken. The password is correct. The key file is intact. But they don't work together anymore.
I try everything I can think of: environment variables, --password flag, TAURI_SIGNING_PRIVATE_KEY_PATH, PowerShell instead of Bash, a separate Git Bash window, a freshly compiled cargo tauri signer, piping through tr -d '\r\n' to strip any invisible line endings.
Every single attempt returns the same error.
The red herrings
This is the part of debugging where you start building theories that feel brilliant and turn out to be completely wrong.
Windows Security Update KB5079473. Installed April 3rd — the day before. Right between the last working build and today. My AI assistant even built a compelling theory: "The security update changed cryptographic behavior." It sounded plausible for about ten minutes. But Tauri's signer uses pure Rust — scrypt + Ed25519 — not the Windows CryptoAPI. And the test key with the same password worked fine. If Windows broke crypto, nothing would work.
npm updated something. node_modules/.package-lock.json had a fresh timestamp from April 3rd. Suspicious. But the actual tauri-cli binary? File date: February 4th. Version: 2.10.0. Unchanged.
GitHub CI. This one was fun. I checked: CI with the same password and key was also failing. Smoking gun? No — it had been failing since March 3rd. The GitHub Secret was wrong from day one. I just never noticed because all 20 releases were local builds. A completely separate bug hiding in plain sight.
Two hours in. No leads. Just a growing list of things that aren't the problem.
The breakthrough
At this point I'm staring at the screen, out of ideas. Then I remember — Claude Code's source is on disk. It's JavaScript. I can read it.
I start tracing the code path that executes when Claude Code runs a bash command:
BashTool.tsx → Shell.ts → bashProvider.ts → shellQuoting.ts
And in shellQuoting.ts, I find this comment:
// The shell-quote library incorrectly escapes ! to \! in these casesMy heart rate goes up. I read the code around it. The shell-quote library — which Claude Code uses to sanitize every command before wrapping it in eval — replaces ! with \!.
Quick sanity check:
const { quote } = require('shell-quote');
quote(["export PASSWORD='mypassword!' && echo test"]);
// → "export PASSWORD='mypassword\\!' && echo test"There it is. A literal backslash, injected silently before the exclamation mark.
The developers knew about this. For heredocs and multiline strings, they'd built a workaround — bypassing shell-quote entirely. But regular single-line commands? Still going through quote(). Still getting ! turned into \!.
What actually happened
Here's the timeline, reconstructed from Claude Code's JSONL command logs:
March 1st. I generate the signing key through Claude Code. The command npx tauri signer generate asks for a password interactively. I type mypassword!. But Claude Code wraps everything in eval, and before that, shell-quote turns ! into \!.
The key is encrypted with the password mypassword\! — with a literal backslash that I never typed and never saw.
March 1st through March 28th. Twenty successful releases. Every time, Claude Code passes the password through the same shell-quote → eval pipeline. The backslash gets added every time. Password matches every time. Everything works beautifully.
April 4th. Claude Code updates from 2.1.86 to 2.1.92. In the new version, the ! escaping behavior is fixed — the password is now passed cleanly, without the phantom backslash. But the key still expects mypassword\!.
The correct password no longer matches.
I wasn't alone
After finding the root cause, I searched GitHub. Tauri has at least three open issues with the same symptom:
- #13485 — "Updater Signing with password from environment is broken." A user discovered that pasting vs typing a password during key generation produced different results. Ctrl+V in VS Code's terminal was inserting the literal characters
^Vinstead of clipboard contents. - #14829 — "Can not use empty string as password." Even
-p ""doesn't work consistently across Windows, macOS, and Linux. - #10488 — "Invalid padding", "Wrong password" in CI. No useful error diagnostics.
The common thread: the chain from your keyboard to the signing tool is terminal → shell → eval → environment variable → signer. The password can be quietly transformed at any step, and the error message — "Wrong password" — tells you nothing about where it went wrong.
The fix
One line:
export TAURI_SIGNING_PRIVATE_KEY_PASSWORD='mypassword\!'The backslash was always part of the password. Twenty releases proved it. I just didn't know it was there.
The irony
Here's what makes this story worth telling. The shell-quote behavior was a bug. Escaping ! to \! was incorrect — ! inside single quotes has no special meaning in bash. Claude Code 2.1.92 fixed it. The password is now passed correctly.
But my key was generated with the buggy behavior. And twenty releases were signed with the buggy behavior. The bug was my normal. Fixing the bug broke my workflow.
This is Hyrum's Law in its purest form: with a sufficient number of users of an API, all observable behaviors of your system will be depended on by somebody. Even the bugs.
If this happens to you
Diagnosis
If your Tauri signing suddenly fails with "Wrong password" after updating Claude Code, a terminal emulator, or any CLI wrapper:
# Check if shell-quote mangles your password:
npm install shell-quote
node -e "const {quote} = require('shell-quote'); \
console.log(quote(['echo hello!']))"
# If output contains \! — you're affectedQuick fix
Try adding \ before any ! in your password:
# Was (stopped working):
export TAURI_SIGNING_PRIVATE_KEY_PASSWORD='mypassword!'
# Try (if key was generated through Claude Code / shell-quote):
export TAURI_SIGNING_PRIVATE_KEY_PASSWORD='mypassword\!'Prevention
Generate keys with the -p flag, bypassing interactive input entirely:
npx tauri signer generate -w my-key.key -p "password_without_special_chars"Avoid !, $, `, \ and other shell metacharacters in passwords used by CLI tools. Even inside single quotes, an intermediary layer might "helpfully" escape them.
Lessons
Abstraction layers have a cost. Between me pressing Enter and the command executing: Claude Code → shell-quote → eval → bash → npx → tauri-cli. Six layers. Each one could silently transform the data. One of them did.
Logs saved me. Claude Code stores complete command history in JSONL files. I could reconstruct exactly what was executed on March 1st and compare it to April 4th. Without those logs, I'd have generated a new key — breaking auto-update for everyone who already installed Beetroot.
Don't trust interactive input through wrappers. If your tool has a --password or -p flag, use it. Interactive prompts routed through terminal multiplexers, AI coding assistants, or shell wrappers are a corruption vector.
Test your release pipeline in CI, not just locally. I had a GitHub Actions workflow ready. Never fixed it after the first failure. If releases went through CI, the environment would be pinned and a local tool update couldn't have broken anything.
If twenty things worked, the twenty-first failure is an environment change. Not a data problem. Not a corruption. Something between the keyboard and the tool changed. Find what.
Beetroot 1.6.5 shipped the same day. Auto-update works. The backslash is documented.