Claude Codeが作る.cmdとCP932 — 全ファイルが消える仕組みと対策
WriteツールはLFだけの.cmdを作ります。日本語Windowsでは、行末の「。」が改行を飲み込み、変数が空のままdel /F /Qがカレントの全ファイルを消すことがあります。仕組みと確認方法、対策をまとめます。
日本語版Windowsで、Claude Codeが書いた.cmdを実行したら、カレントフォルダーのファイルが1,068個消えました。anthropics/claude-codeのissue #92328は、この事故の報告です。原因は、WriteツールがLFだけの改行でバッチファイルを作ることと、コードページ932(CP932、Shift-JIS系)のcmd.exeがマルチバイト文字の直後の改行を読み飛ばすことの組み合わせにあります。
この記事は、報告された仕組み、手元のファイルが危ない状態かを調べる方法、Claude Code側で打てる対策をまとめます。
何が起きたのか
報告者の環境は、OEMコードページが932の日本語Windows 11です。Writeツールが作った.cmdはBOMなしのUTF-8で、改行はLFのみ(CR=0、LF=40)でした。このバッチが実行されると、次の順で事故に進みます。
rem行の末尾にある句点「。」(U+3002、UTF-8でE3 80 82)の最後のバイト0x82を、cmd.exeがCP932の先頭バイトとして読む- 先頭バイトは2バイト文字の前半なので、直後の
0x0A(LF)が後半として消費され、改行として働かない - 次の行の
set "WORK=..."がremの続きとして吸収され、実行されない WORKが未設定のまま、del /F /Q "%WORK%"がdel /F /Q ""に展開される- 対象が空のパターンになり、
delはカレントフォルダーの全ファイルを消す
エージェントのカレントはリポジトリのルートでした。1,068ファイルが1秒で消え、NTFSのUSNジャーナルには削除の記録だけがあり、リネームの記録はありません。ごみ箱にも入っていません。サブフォルダーが残ったのは、delが再帰しないからです。報告者によれば、123ファイルは復元できませんでした。
事故が成立した4つの条件
LFだけの.cmd
CRLFなら起きません。同じバイト列で改行だけをCRLFに変えた対照実験では、ファイルは3つとも残りました。
OEMコードページが932
日本語ロケールの既定です。「ベータ: ワールドワイド言語サポートでUnicode UTF-8を使用」を有効にしていない環境が該当します。
非ASCII文字が行末に近い
バイト位置に依存します。0x81〜0x9F、0xE0〜0xFCの先頭バイトの直後にLFが来る箇所があると発火します。
変数が空のままのdel
del /F /Q "%WORK%"のように、変数1つに削除対象を任せた書き方です。
手元で再現して確かめる
issueには、5行の最小再現が載っています。3行目のremが「。」で終わる点が肝です。delを含むので、再現は必ず空のフォルダーで行います。
@echo off
setlocal
rem 速度が要るときだけ VMRE_IDB_KEEP=1 で持ち越す(そのとき py 側が NEW FUNC 警告を出す)。
set "WORK=C:\Temp\work.i64"
del /F /Q "%WORK%" 2>nul保存形式はUTF-8・LF・BOMなしです。チャットやエディターを経由すると改行が正規化されるため、PowerShellからLFのまま書き出すのが確実です。
$lines = @(
'@echo off',
'setlocal',
'rem 速度が要るときだけ VMRE_IDB_KEEP=1 で持ち越す(そのとき py 側が NEW FUNC 警告を出す)。',
'set "WORK=C:\Temp\work.i64"',
' del /F /Q "%WORK%" 2>nul'
)
$text = ($lines -join "`n") + "`n"
[IO.File]::WriteAllText("$PWD\repro.cmd", $text, (New-Object Text.UTF8Encoding($false)))
$b = [IO.File]::ReadAllBytes("$PWD\repro.cmd")
"CR=$(($b | ? { $_ -eq 13 }).Count) LF=$(($b | ? { $_ -eq 10 }).Count)"最後の2行は、CR(0x0D)とLF(0x0A)のバイト数を数えます。CR=0 LF=5と出れば、報告と同じ形で保存できています。Format-Hex .\repro.cmdの出力で0Dが一つも無いことを目で追う方法もあります。
保存したら、同じ階層にダミーファイルとサブフォルダーを持つ空のフォルダーを作り、そこから実行します。
mkdir iso; cd iso
1..3 | % { "x" > "f$_.txt" }; mkdir sub
cmd /c "chcp 932 >nul & ..\repro.cmd"報告された結果は次のとおりです。
| 改行 | 残ったファイル | サブフォルダー |
|---|---|---|
| LFのみ | 残ったファイル0/3 | サブフォルダー1/1 |
| CRLF | 残ったファイル3/3 | サブフォルダー1/1 |
もう一つ注意があります。この再現は、元のファイルから行を削って作った最小形です。報告者は、書き直すとバイト位置がずれて再現しないことがあると書いています。delをechoに変えるようなバイト長が変わる編集でも、症状は隠れます。確かめるなら@echo offを@echo on に置き換えるような、長さを保つ編集にします。
2人目の再現者は、Windows 11 Home、ACP/OEMCPとも932、Claude Code 2.1.296で同じ結果を得ています。ただし飲み込まれ方は少し違いました。setがremに吸収されるのではなく、コメントの途中から実行が再開し、残りのコメントとset行が1つのコマンドとして失敗します。結果は同じでWORKは設定されませんが、読み違いの着地点はファイルごとに変わります。
Writeの改行は2.1.296でも変わっていない
2人目の再現者は、claude -p(Sonnet、acceptEdits)で次の挙動も測っています。
| 操作 | 結果 |
|---|---|
Writeで日本語のrem行を含む.cmdを新規作成 | 結果CR=0、LF=3、UTF-8、BOMなし |
CRLFの既存.cmdをWriteで1語だけ変えて書き直し | 結果CR=3がCR=0に変わる |
| 同じ変更をEditで行う(対照) | 結果CR=3のまま |
つまり、新規作成だけでなく、手で書いたCRLFのバッチをモデルがWriteで書き直したときにも、LFだけになります。同じWriteの挙動は、.ps1のBOMとCRLFが落ちる問題としてClaude Codeが作るps1が文字化けして止まる原因とBOMの対処でも扱っています。そちらはPowerShell 5.1が主役で、こちらはcmd.exeが主役という違いです。
修正の有無は、issue #92328とClaude Codeの変更履歴で確かめられます。
自分のリポジトリで危ない.cmdを探す
LFだけで、かつ非ASCIIを含むバッチを洗い出します。次のスクリプトは読むだけで、何も書き換えません。
Get-ChildItem -Recurse -Include *.cmd,*.bat | ForEach-Object {
$b = [IO.File]::ReadAllBytes($_.FullName)
$cr = ($b | Where-Object { $_ -eq 13 }).Count
$nonAscii = ($b | Where-Object { $_ -gt 127 } | Select-Object -First 1) -ne $null
if ($cr -eq 0 -and $nonAscii) { $_.FullName }
}出力されたファイルは、改行がLFのみで日本語を含みます。実際に発火するかはバイト位置次第なので、出てきたものは「いま動いているだけ」と考えるのが安全です。ASCIIだけのLFバッチは、この機序では先頭バイトが現れないため対象外です。
先頭バイトの直後にLFがある箇所を数える
前節の絞り込みで候補が多すぎるときは、事故の直接の条件を数えます。CP932の先頭バイト(0x81〜0x9F、0xE0〜0xFC)の直後に0x0Aが来る位置です。issueの調査では、事故ファイルの中に11か所ありました。そのうち決定的だったのは、rem行末の「。」1か所です。
$p = ".\helper.cmd"
$b = [IO.File]::ReadAllBytes($p)
for ($i = 0; $i -lt $b.Length - 1; $i++) {
$lead = ($b[$i] -ge 0x81 -and $b[$i] -le 0x9F) -or ($b[$i] -ge 0xE0 -and $b[$i] -le 0xFC)
if ($lead -and $b[$i + 1] -eq 0x0A) { "offset $i" }
}このスクリプトは、先頭バイトに見えるバイトの直後がLFかどうかだけを見る簡易な検査です。出た位置がそのまま事故につながるとは限りません。0件なら、この機序でLFが飲まれる条件はそろっていない、という読み方に使います。
対策1: 改行をCRLFにそろえる
issueの対照実験で効果が確かめられているのはこれです。既存ファイルの改行だけをCRLFに直します。
$p = ".\helper.cmd"
$t = [IO.File]::ReadAllText($p)
$t = $t -replace "`r?`n", "`r`n"
[IO.File]::WriteAllText($p, $t, (New-Object Text.UTF8Encoding($false)))-replace "`r?`n"は、すでにCRLFの行を二重にしないための書き方です。エンコーディングはBOMなしのUTF-8のまま保ちます。
対策2: Writeの直後に自動でCRLFへ直すhook
毎回手で直すより、Writeのたびに直すほうが確実です。Claude Codeのhookは、ツール呼び出しの前後にコマンドを走らせる仕組みで、PostToolUseのmatcherにEdit|Writeを指定できます。コマンドにはツールの入力がJSONで渡され、tool_input.file_pathでファイルが分かります。
設定は.claude/settings.jsonに書きます。
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "powershell.exe",
"args": [
"-NoProfile",
"-File",
"${CLAUDE_PROJECT_DIR}/.claude/hooks/fix-cmd-eol.ps1"
]
}
]
}
]
}
}スクリプト側は、.cmdと.batだけを対象にします。例えば次の形になります(公式のhook入力の形に沿った例で、手元の環境での動作確認を前提にしてください)。
$j = [Console]::In.ReadToEnd() | ConvertFrom-Json
$p = $j.tool_input.file_path
if ($p -notmatch '\.(cmd|bat)$') { exit 0 }
$t = [IO.File]::ReadAllText($p)
$t = $t -replace "`r?`n", "`r`n"
[IO.File]::WriteAllText($p, $t, (New-Object Text.UTF8Encoding($false)))${CLAUDE_PROJECT_DIR}は、セッションを始めたプロジェクトのルートに展開されるプレースホルダーです。hookが動くときのカレントに関係なく、スクリプトを指せます。
このスクリプト自体をASCIIだけで書いているのは、5.1でBOMなしのファイルが壊れる前述の問題を、自分で踏まないためです。Python製のhookで同じ文字化けを避ける方法は、ClaudeCodeのhookをPythonで書くとWindowsで文字化けする問題にあります。
PostToolUseは書いた後に直す仕組みです。同じターンのうちにClaude Codeがそのバッチを実行する場合、実行前にCRLFへ直るかどうかは、hookの完了と実行の順序に左右されます。実行までに直ることが保証されたわけではないので、手元で一度、書かせた直後に実行させて挙動を確かめておくと安心です。
対策3: バッチの書き方で被害を小さくする
改行を直しても、バイト位置の読み違いという性質は残ります。バッチを書かせるときの約束事で、被害を小さくできます。
次はCLAUDE.mdに置く規約の例です。
## Windows バッチファイル(.cmd / .bat)
- 内容は ASCII のみ。rem のコメントも英語で書く。
- 改行は CRLF。Write で書き直したら CRLF を確認する。
- del / rd は変数だけを対象にしない。直前に `if not defined X exit /b 1` を置く。
- 削除対象は絶対パスで書く。カレントフォルダーに依存しない。ASCIIだけなら、0x80以上のバイトが存在しないので、先頭バイトが改行を巻き込む余地がありません。そのうえで、削除の直前に変数の存在を確かめれば、変数が空になる経路は止まります。
@echo off
setlocal
set "WORK=C:\Temp\work.i64"
if not defined WORK exit /b 1
del /F /Q "%WORK%" 2>nulClaude Codeの削除防御が届かない範囲
バッチの書き方とは別に、Claude Codeの権限側の防御がどこまで届くかも押さえておきます。v2.1.283の変更履歴には、PowerShellツールがcmd /c rd・rmdir・del・eraseでドライブルートやホームフォルダーを消せた問題の修正があります。この防御は、ドライブルートやホームフォルダーのようなシステムパスが対象です。プロジェクト内の相対パスの削除は拒否されない、とv2.1.283の解説にあります。今回の事故はリポジトリのルートで起きており、この種の防御の外側にあります。
切り分けの早見表
| 状況 | 見るところ | 手当て |
|---|---|---|
Claude Codeが書いた.cmdが日本語を含む | 見るところCRが0か、Format-Hexで確認 | 手当てCRLFに変換 |
'""' is not recognizedと出る | 見るところ変数が空のまま展開されていないか | 手当て@echo on に差し替えて展開結果を見る |
| ファイルが消えた | 見るところGitの履歴、バックアップ | 手当て消えた直後は書き込みを控える |
| 手書きのCRLFバッチを書き直させた | 見るところ改行がLFに変わっていないか | 手当てEditで変更させる |
| ほかのDBCS環境(949、936、950) | 見るところ未検証 | 手当てissueでは同様と想定されているが、テストは932のみ |
消えたファイルの復旧は、Gitで追跡しているものはチェックアウトで戻せます。未追跡のファイルは、報告者のように一部が戻らないことがあります。
よくある質問
UTF-8のベータ機能を有効にすれば防げますか
issueの再現条件は「ベータ: Unicode UTF-8」が無効なOEMCP=932の環境です。有効にした環境での検証結果は、報告に載っていません。有効化は他のアプリへの影響が大きいため、試す場合は影響範囲を確かめたうえで行います。
PowerShellスクリプトでも同じ事故は起きますか
機序は別です。.ps1はBOMがないと5.1がANSIとして読み、引用符に化けたバイトで構文が壊れます。こちらは実行前にパースエラーで止まる型で、全ファイル削除の経路とは異なります。