見えないバックスラッシュ: シェルエスケープのバグが 20 リリースを静かに壊した話
Tauri-action の署名鍵が 20 回成功した後、突然失敗するように。原因はシェルクオートのエスケープでパスワードに混入したリテラルなバックスラッシュでした。
金曜の朝です。Beetroot 1.6.5 が完成しました。大きなアップデート、554 件のテストがパス、Changelog も書き終えました。リリーススクリプトを走らせます。ビルドは 4 分で完了。NSIS インストーラー、MSI、すべて問題なし。最後の手順: 自動アップデート用に成果物を署名します。
incorrect updater private key password: Wrong password for that key
うーん。タイポでしょう、たぶん。再試行。同じエラー。Secure Note からパスワードをコピー&ペースト。同じエラー。
パスワードは変わっていません。鍵ファイルも変わっていません。まったく同じコマンドが先月 20 回のリリースを署名しました。最後の成功したビルドは 6 日前です。
土曜から今日のあいだに何かが壊れたのに、何が壊れたのか分かりません。
ウサギの穴へ
最初の 1 時間は地道に。明らかなところを確認します。
# 鍵ファイルは変わっていない?
sha256sum beetroot-signing.key
# 3 月 24 日のログのハッシュと一致 ✓
# パスワードはバイト単位で正しい?
echo -n 'the_password!' | xxd
# きれいな 16 進数、隠し文字なし ✓
# 鍵ファイルの NTFS タイムスタンプは?
# 作成: 3 月 1 日。更新: 3 月 1 日。誰も触っていない。 ✓同じパスワードで新しいテスト鍵を生成: 完璧に動きます。つまり暗号は壊れていません。パスワードは正しい。鍵ファイルも無傷。ただ、もう噛み合わなくなっただけです。
思いつくものは全部試しました: 環境変数、--password フラグ、TAURI_SIGNING_PRIVATE_KEY_PATH、Bash の代わりに PowerShell、別の Git Bash ウィンドウ、新しくコンパイルした cargo tauri signer、見えない改行を取り除くために tr -d '\r\n' でパイプ。
どれも同じエラーです。
偽の手がかり
ここはデバッグの中で、見事に聞こえる仮説を組み立てたあげく、まったく外れる部分です。
最初の仮説: 4 月 3 日にインストールされた Windows Security Update KB5079473。最後に動いたビルドと今日のちょうど間です。私の AI アシスタントはそれらしい論証を作りました。「セキュリティ更新が暗号の振る舞いを変えた」。10 分くらいは説得力がありました。しかし Tauri の署名ツールはピュア Rust (scrypt + Ed25519) を使っていて、Windows CryptoAPI ではありません。同じパスワードのテスト鍵は問題なく動いています。Windows が暗号を壊したなら、何も動かないはずです。
2 つ目の仮説: npm が何かを更新した。node_modules/.package-lock.json に 4 月 3 日の新しいタイムスタンプ。怪しい。しかし実際の tauri-cli バイナリのファイル日付は 2 月 4 日、バージョン 2.10.0、変わっていません。
3 つ目の仮説は楽しいやつでした。GitHub CI を確認: 同じパスワード、同じ鍵、こちらも失敗。動かぬ証拠? いいえ。3 月 3 日からずっと失敗していました。GitHub Secret は最初から間違っていたのです。20 リリースすべてがローカルビルドだったので気づかなかっただけ。これは目の前で隠れていた完全に別のバグでした。
2 時間経過。手がかりはなく、問題ではないことのリストだけが伸びていきます。
ブレイクスルー
画面を見つめ、アイデアが尽きました。そのとき思い出します: Claude Code のソースはディスクにある。JavaScript だ。読める。
Claude Code が bash コマンドを実行するときのコードパスをたどります。
BashTool.tsx → Shell.ts → bashProvider.ts → shellQuoting.ts
shellQuoting.ts の中にこのコメントを見つけました。
// The shell-quote library incorrectly escapes ! to \! in these cases心拍数が上がります。周辺コードを読みます。Claude Code が eval で包む前にすべてのコマンドをサニタイズするのに使う shell-quote ライブラリは、! を \! に置き換えます。
軽いサニティチェック:
const { quote } = require('shell-quote');
quote(["export PASSWORD='mypassword!' && echo test"]);
// → "export PASSWORD='mypassword\\!' && echo test"ここにありました。リテラルなバックスラッシュが、感嘆符の前に静かに注入されています。
開発者は知っていました。ヒアドキュメントや複数行文字列のために、shell-quote を完全に迂回する回避策を作っていました。一行コマンドは? まだ quote() を通り、! は依然として \! になります。
実際に何が起きたか
Claude Code の JSONL コマンドログから再構築します。
3 月 1 日。Claude Code 経由で署名鍵を生成。npx tauri signer generate コマンドはパスワードを対話的に尋ねます。私は mypassword! と入力します。しかし Claude Code はすべてを eval で包み、その前に shell-quote が ! を \! に変えます。鍵は mypassword\! というパスワードで暗号化されました。私が一度もタイプも目にもしなかったリテラルなバックスラッシュ付きで。
3 月 1 日から 3 月 28 日。20 回の成功したリリース。毎回、Claude Code はパスワードを同じ shell-quote → eval パイプラインに通します。バックスラッシュは毎回追加されます。パスワードは毎回一致します。すべて美しく動きます。
4 月 4 日。Claude Code が 2.1.86 から 2.1.92 に更新。新しいバージョンでは ! のエスケープが修正されています。パスワードは亡霊のバックスラッシュなしできれいに渡されます。しかし鍵は依然として mypassword\! を期待しています。
正しいパスワードはもう一致しません。
私だけではなかった
根本原因を見つけたあと、GitHub を検索しました。Tauri には同じ症状の未解決 issue が少なくとも 3 つあります。
- #13485、「Updater Signing with password from environment is broken.」鍵生成時のパスワードを貼り付けたか手で打ったかで結果が違う、というユーザーの発見。VS Code のターミナルで Ctrl+V がクリップボードの内容ではなくリテラルな
^Vを挿入していました。 - #14829、「Can not use empty string as password.」
-p ""ですら Windows、macOS、Linux で一貫して動きません。 - #10488、CI で「Invalid padding」「Wrong password」。役に立つエラー診断が無い。
キーボードから署名ツールまでのチェーン: ターミナル → シェル → eval → 環境変数 → 署名ツール。パスワードはどの段階でも静かに変換され得ますし、エラーメッセージ (「Wrong password」) はどこで間違ったかを何も教えてくれません。
修正
1 行です。
export TAURI_SIGNING_PRIVATE_KEY_PASSWORD='mypassword\!'バックスラッシュは常にパスワードの一部だったのです。20 リリースが証明していました。私はそこにあるとは知らなかっただけです。
皮肉
shell-quote の振る舞いはバグでした。! を \! にエスケープするのは間違いです。bash ではシングルクオート内の ! に特別な意味はありません。Claude Code 2.1.92 はそれを修正しました。パスワードは正しく渡されるようになりました。
しかし私の鍵はバグの振る舞いで生成されました。20 リリースもバグの振る舞いで署名されました。バグが私の通常でした。バグを直すと私のワークフローが壊れました。
これは Hyrum の法則 の典型例です。API のユーザー数が十分に多ければ、システムのあらゆる観察可能な振る舞いを誰かが当てにする。バグでさえも。
あなたに同じことが起きたら
Claude Code、ターミナルエミュレーター、何らかの CLI ラッパーを更新したあと、Tauri 署名が突然「Wrong password」で失敗するようになったら、shell-quote がパスワードを歪めていないか確認してください。
npm install shell-quote
node -e "const {quote} = require('shell-quote'); \
console.log(quote(['echo hello!']))"
# 出力に \! が含まれていれば影響を受けている該当するなら、パスワードのすべての ! の前に \ を入れます。
# 以前 (動かなくなった):
export TAURI_SIGNING_PRIVATE_KEY_PASSWORD='mypassword!'
# 試す (鍵を Claude Code / shell-quote 経由で生成した場合):
export TAURI_SIGNING_PRIVATE_KEY_PASSWORD='mypassword\!'今後の鍵では、-p を使い、対話入力を避けてください。
npx tauri signer generate -w my-key.key -p "password_without_special_chars"CLI ツールが使うパスワードでは !、$、`、\ などのシェルメタ文字を避けてください。シングルクオートの中であっても、中間レイヤーが「親切に」エスケープすることがあります。
ここから持ち帰ったこと
私が Enter を押してからコマンドが実行されるまでに 6 つのレイヤー: Claude Code → shell-quote → eval → bash → npx → tauri-cli。それぞれがデータを静かに変換でき、その 1 つがやりました。抽象化スタックのコストは実行時間だけではなく、見えない変異の表面積です。
ログに救われました。Claude Code はコマンド履歴を JSONL ファイルにフルで保存しているので、3 月 1 日に何が実行されたかを正確に再構築し、4 月 4 日と比較できました。これらのログがなければ、新しい鍵を生成して、すでに Beetroot をインストールしていた全員の自動アップデートを壊していたでしょう。
ツールに --password または -p フラグがあるなら、それを使ってください。ターミナルマルチプレクサ、AI コーディングアシスタント、シェルラッパーを経由する対話プロンプトは破損のベクトルです。あなたのキー入力と、それを実際に消費するツールとの間に「親切な」レイヤーが多すぎるのです。
数か月前にセットアップした CI ワークフローは 3 月 3 日からずっと壊れていました。ローカルビルドが動いていたので直しませんでした。リリースが CI を通っていたら、環境がピン留めされ、ローカルツールの更新が何かを壊すことはなかったはずです。次に直すのはそこです。
最も安いレッスンは最も役に立つレッスン: 20 個のものが動いて 21 個目が失敗したとき、変化はデータではなく環境にあります。キーボードとツールの間で何かが動いた。動いたものを見つけてください。
Beetroot 1.6.5 は同じ日にリリースされました。バックスラッシュは現在リリースランブックに記載されています。