消えたファイルの原因を2日後に物証で特定した記録(Claude Code の再帰削除事故)
消えたファイルの原因を2日後に物証で特定した記録(Claude Code の再帰削除事故)
スタートメニューの検索窓に paper と打つと、いつもならスキャナの取り込みアプリが候補に出る。それが出てこない。Windows の更新か再起動のタイミングで何かが変わったのだろう、くらいに思って Claude Code に調べさせた。
アプリ本体は残っていた。消えていたのはショートカットだけだった。ところが Excel も検索に出ない。1本のアプリの問題ではなく、共通の原因がある。
ショートカットが消えた時刻が、2日前の事故と一致した
C:\ProgramData\Microsoft\Windows\Start Menu\ の配下が丸ごと空になっていた。フォルダの更新時刻は 2026-07-29 07:22。2日前の朝、~/.claude が消えた事故(07:24〜07:29)の2分前である。
つまりこれは新しい不具合ではなく、あの事故の被害範囲が把握していたより広かったという話だった。
Excel と Word は Office のクイック修復で検索に戻った。スキャナのアプリは管理者権限が無かったので、ユーザー側のスタートメニューにショートカットを作り直してもらった。ここで終わりだと思っていた。
プロファイルも、保存先フォルダも消えていた
取り込みアプリを開いたら、ドキュメントプロファイルが1件も無い。スクリーンショットを撮って Claude Code に渡した。「ショートカットが消えた」話ではないと思ったからだ。
プロファイルの実体も C:\ProgramData\ 配下だった。当日の朝に初期状態で再生成されていて、中身は0件。Google Drive のバックアップも当たらせたが、控えは iPad の写真だけで、ProgramData のものはどこにも無かった。復元不能である。
さらにもう1つ、スキャン PDF の保存先フォルダも消えていた。/rename-pdf の読み取り元なので、ユーザーデータそのものである。ごみ箱を確認させたら、$Recycle.Bin 自体の更新日時が 07-29 06:42。ストレージセンサーが有効になっていて、その朝ごみ箱も空になっていた。
途中で「最近開いたファイルの履歴のうち403件が存在しない」と報告されて肝が冷えた。年別に集計させたら 2021 年からの累積で、7/29 とは無関係だった。数を出すだけでは何も分からない。
容疑者を1つずつ潰したが、全部空振りだった
「07:22 に何かが一斉に消した」ところまでは分かった。では何が。
| 調べたもの | 結果 |
|---|---|
| Windows Update | 無罪。Setup ログでは KB のインストールは 07-30 08:25〜。Get-HotFix の日付表示がズレていて危うく誤認するところだった |
| 再起動時の再構築 | 無罪。07-29 は 05:41 起動 → 16:50 停止で、07:22 に再起動していない |
| Defender の駆除 | 無罪。当日のスキャンで検出ゼロ |
| イベントログ全般 | 07:22 のイベントが1件も無い(07:17 の次が 07:23) |
| シャドウコピー / 復元ポイント | どちらも存在せず |
| PowerShell 操作ログ | 機能が無効で記録なし |
| Prefetch | 管理者権限で1回だけ実行したが、3日経過して常用アプリに上書き済み |
| ごみ箱 / USN ジャーナル / シェル履歴 | 全滅 |
Windows は既定でファイル削除を監査しない。 「誰が消したか」を追えないのは、記録の探し方が悪いからではなく、そもそも記録が取られていないからだった。
ManicTime で「その時刻に何が動いていたか」だけは分かった
残っていたのはアクティビティトラッカーの DB だった。ManicTimeCore.db の中身は gzip 圧縮された JSON である。そのまま出すと大きすぎたので、解凍して時刻で絞り込ませた。
07:21:17 chrome.exe Worker バンドル削減 統合計画(2026-07-29)
07:22:21 WindowsTerminal.exe ⠂ 積み残したタスクの確認
07:23:32 WindowsTerminal.exe ✳ 積み残しタスクの確認
07:24:02 chrome.exe New chat - Claude
⠂ ✳ は Claude Code の実行中スピナーである。07:22 台に画面で動いていたのは Claude Code のセッションだけだった。ただし「動いていた」は「消した」の証明にならない。
一時ディレクトリに残っていたコマンド出力
Claude Code はバックグラウンド実行した Bash コマンドの出力を次の場所に書く。
%LOCALAPPDATA%\Temp\claude\<プロジェクト>\<セッションID>\tasks\<id>.output
ここは ~/.claude の外である。 だから削除を免れていた。7/29 の 07:00〜07:45 を列挙させたら、07:25 に 260 バイトのファイルが2件あった。中身は cp932 で、Git Bash の cat では文字化けする。Python でデコードさせたらこう出た。
\Users\<ユーザー名>\CODEX~1\tmp\arg0\CODEX-~3 - ディレクトリが空ではありません。
\Users\<ユーザー名>\DROPBO~1\CB2270~1\DROPBO~1 - プロセスはファイルにアクセスできません。別のプロセスが使用中です。
読み解きどころが3つあった。
CODEX~1DROPBO~1は 8.3 形式の短縮名。dir /xで引かせたら、DROPBO~1はDropboxではなく.dropbox_biだった(Dropbox本体は無傷)パス - メッセージという形式は cmd のrd /s系に固有である。PowerShell のRemove-Itemはエラーの出方が違う- ここに出るのは削除に失敗したものだけ。成功した削除は何も出力しない
3つめが重い。消えたものを全部特定できないのは、調べ方の問題ではなく、この構造のせいだった。
出力を読み進めようとしたところで一度止めた。Temp はいつ消えてもおかしくない。先に外部 SSD へ退避させた。証拠用に切ったディレクトリへ 1,284 ファイル。読むのは保全のあとでいい。
「エラーが出た=無事だった」は誤読だった
最初、この3件は「ロックされていたので削除を免れた」と受け取った。違った。
DropboxBackup の中を見せたら、残っていたのは desktop.ini が1個だけで、その日付も 07:25 だった。中身は消えていた。親ディレクトリが使用中だったので、枠だけが残っていたのである。エラーメッセージは「このディレクトリは無事だった」ではなく「ここ自体は残ったが、中は空にされた」を意味していた。同じ理屈で .codex\tmp 配下も中身は消えていた。
被害は当初の見立てより広かった。
推測が2回外れた
削除の動機について、途中で2つの説明が出てきた。どちらも筋が通っていたが、どちらも根拠が取れなかった。
1つめは「ディスク容量を空ける掃除だった」。計画書を読ませたら、書いてあったのは Cloudflare Workers のバンドルサイズ削減で、PC の空き容量とは無関係だった。
2つめは、私自身が言い出した「7月半ばにやったディスク掃除 round2 の続きではないか」という線。キャッシュ系のキーワードで削除対象を決めた記憶があったので追わせた。ManicTime とメモを突き合わせたら、round2 の実行は 7/16 で、7/29 ではなかった。
コマンドの全文も、削除対象の選定基準も、動機も、分からないまま残っている。分かったのは範囲の一部だけである。.ssh や .npmrc は旧日付のままで、削除を試みられた形跡すらない。ホーム全体を消したのではなく、対象は限定的に指定されていた。ただしその基準を示す記録は無い。
もっともらしさは根拠ではない。 分からないところは分からないまま書いた。
再発防止は4層で入れた
1. permissions.deny で cmd 系の再帰削除を無条件ブロック
rd /s rmdir /s cmd /c rd を deny に入れた。ask にしなかったのは、作業に集中しているとき承認ダイアログを反射で押してしまうからである。Git Bash で rm -rf が使える以上、cmd 系の再帰削除は業務上まったく要らない。
ファイルを保存した瞬間から効くこと、単一ファイルの削除は影響を受けないことを、その場で叩いて確認した。
rm -r 系は途中で deny から外した。deny は無条件ブロックなので、「Git_repo 配下だけは許可」という条件を書く余地が無くなるためである。ここで1回ハマった。ユーザーレベルの設定から外したのに拒否され続けた。原因は、プロジェクト側の .claude/settings.json に同じ行が残っていたことだった。設定は両方を見る。
2. PreToolUse hook で条件判定する
保護したいパスを列挙する方式は採らなかった。ホームディレクトリを保護対象にすると Git_repo が丸ごと入り、全リポジトリで node_modules すら消せなくなる。逆に、許可する場所だけを挙げる形にした。
削除系コマンドを検出 → 対象パスを正規化 → Git_repo 配下か? → YES: 通す / NO: 拒否
正規化を照合の前に噛ませるのが肝だった。 事故時の形(ドライブレター無し+8.3短縮名)は、素朴な文字列一致を素通りする。
Codex にレビューさせたら、致命的な誤りが1つ出た。fail-closed のつもりで書いたのに、JSON の解析に失敗したとき exit(0) を返していた。つまり許可に倒れていた。PreToolUse で拒否と解釈される終了コードは 2 だけである。全体を try-catch で包んで、例外もすべて exit 2 に倒した。
テストを17ケース書いたら2件落ちた。正規表現も判定関数も、単体では正しく動く。デバッグ版を作って中を出させたら、パス抽出の誤判定だった。クォート文字列の全体が1つのパスとして cwd 基準で解決され、Git_repo 配下と誤認されていた。cd の追跡とインタープリタ経由の拒否を足して全17ケース PASS。
書いただけでは安全にならない。動かして初めて、2件の誤許可が見つかった。
3. 削除ログを実行前に残す
許可した削除については、対象と中身を実行前にファイルへ記録するようにした。今回の調査でいちばん欠けていたのがこの記録である。成功した削除は何も残さない、という構造への直接の手当てになる。
4. auto mode の分類器を確実に通す(これが一番効く)
公式ドキュメントにこう書いてある。narrow な permissions.allow ルールは auto mode でも分類器より先に解決される。つまり allow に入っているコマンドは、分類器の目に触れずに実行される。
当時の allow を見に行ったら、こんな行が並んでいた。
"Bash(powershell -Command:*)"
"Bash(cmd /c \"rmdir /s /q <ホーム>\\.cache\\chrome-devtools-mcp 2>nul\")"
下の1行は、今回の事故の実行形態そのものである。「auto mode がオンだから自律的に危険を判断してくれる」という前提は、無条件には成り立っていなかった。
autoMode.classifyAllShell: true で全シェルコマンドを分類器に通し、hard_deny に再帰削除の禁止を足し、危険な allow を3件消した。配列を書き換えるときは $defaults を必ず含める。外すと組み込みルールがまとめて消える。
フックをどこに置くか
これが今回いちばん大事な設計判断だと思っている。
フックを ~/.claude/settings.json に定義しても、~/.claude ごと消える事故では定義そのものが消えて発動しない。だから hook の実体はバックアップ用の別リポジトリに置いた。削除ログの出力先も同じ場所である。日次バックアップを Claude Code のフックではなく Windows タスクスケジューラで動かしているのも、まったく同じ理由による。
爆心地の中に消火器を置かない。
体感が遅くなるかは測ってから決めた
classifyAllShell: true には「コマンドごとに分類器呼び出しが入る」と公式の注意書きがある。日常の体感が落ちるなら困るので、測らせた。
最初の測定は設計が間違っていた。date で測ったが、date は allow に入っていないので、どちらの設定でも分類器を通る。差が出なくて当然だった。
allow 内の ls で測り直しても、5本並列の分散に傾向は出なかった。ただし「差が無い」ではなく「この方法では測れなかった」が正確である。応答生成とネットワーク往復が秒単位で乗るので、分類器の数百 ms は埋もれる。裏を返せば、その程度には小さい。重く感じたら false に戻せばいい。そのとき失うのは「allow 登録済みも分類器に通す」部分だけで、deny と hard_deny と hook は効き続ける。
余波が2つ出た
1つは自分で仕掛けた罠だった。設定をコミットしようとしたら hook に止められた。コミットメッセージの本文に書いた rd /s, rmdir /s, cmd /c rd を、hook が削除コマンドと判定したのである。メッセージをファイル経由に切り替えて通した。
もう1つは重かった。学習ゲートに止められて /learn を実行したら、learn-quiz の MCP がどこにも登録されていない。~/.claude.json のリセットで消えていた。事故から2日間、学習ゲートが無効なまま気づかれていなかった。 その間のコミットは LEARN_SKIP で通っている。安全機構が、事故の余波で黙って外れていたことになる。README から登録コマンドを掘り起こして復旧した。
なお調査の全記録は _local-unpublished に置いたが、そこは .gitignore で除外されている。未公開メモをリポジトリに入れない設計なので正しい状態ではあるものの、控えは外部 SSD のバックアップだけになる。
学び
- 「原因不明」で止めない。 2日後に物証が出た。
~/.claudeの外にコマンド出力が残る構造を知っていれば、当日に特定できた - 推測を事実として記録しない。 筋の通った説明を2つ立てて、2つとも取り下げた
- 「エラーが出た=無事だった」ではない。 今回いちばん大きな誤読がこれ
- 危険なコマンドは
askではなくdeny。 承認ダイアログは反射で押される denyと hook は役割が違う。 両方に同じものを入れると hook まで到達しない- fail-closed は
exit 2でしか実現しない。 例外で 0 や 1 に落ちると、非ブロッキング扱いで実行される - セキュリティ機構こそテストを書き、外部レビューを通す。 自分では閉じたつもりのコードが開いていた
hook も分類器も、Claude Code の内側の仕組みでしかない。シェルが最終的に何を消すかまでは保証できない。本当の主防御は「Claude Code にホームディレクトリを削除できる権限を渡さないこと」で、別ユーザーかサンドボックスで動かす話になる。OS の ACL で止められることは手順まで確かめた(親フォルダの Delete Child 権限があると子は消せてしまうので、親と対象の両方に拒否をかける必要がある)。ただしどこに適用するかはまだ決めていない。皮肉なことに、今回いちばん困った ~/.claude は Claude Code 自身が日々書き換えるので、この方法では守れない。