深夜、いくつものアプリ用リポジトリに溜まったビルドキャッシュを一掃したくて、その片付けを Claude Code に任せていました。
DerivedData、CocoaPods の Pods、React Native 側の node_modules、.build。個人開発で複数のアプリを並行して抱えていると、こうした中間生成物がディスクを静かに侵食していきます。
一つずつ手で消すのは退屈な作業です。だからこそエージェントに向いている——そう思っていました。
途中、ターミナルの表示が一瞬止まりました。実行されようとしていたのは、こういう形のコマンドでした。
rm -rf "${CACHE_ROOT}"/*CACHE_ROOT が、空でした。展開すれば rm -rf /* に化けるコマンドです。手が、少し冷たくなりました。
変数が空になっていた理由
原因はありふれたものでした。
掃除のスクリプトは、リポジトリごとにキャッシュの場所を環境変数へ入れてから回す設計にしていました。ところが対象の一つは、少し前にディレクトリ構成を変えたばかりのアプリでした。
想定していたパスが存在せず、CACHE_ROOT を組み立てる段階で値が入らなかったのです。シェルは未定義の変数を黙って空文字列として扱います。エラーも警告も出ません。
rm -rf "${CACHE_ROOT}"/* は、意図としては「このアプリのキャッシュだけを消す」でした。けれど空文字列に化けた瞬間、意味はまるで違うものになります。
個人開発では、テストもレビューも自分一人です。こういう取り違えを止めてくれる第二の目が、普段はいません。
空文字は set -u をすり抜ける — 手元で確かめた四つの組み合わせ
あの夜のあと、どうしても腑に落ちない点が残りました。
私は掃除スクリプトの先頭に set -euo pipefail を書いていました。-u は未定義の変数を使ったらそこで止まる、という指定です。それなのに、なぜ空の CACHE_ROOT は素通りしたのか。
気になったので、まっさらな bash 5.1.16 で確かめてみました。まず、空変数がどこまで広がるのかを、削除ではなく引数の数として観測します。
# 実際には消さず、展開結果だけを数える
unset CACHE_ROOT
set -- "${CACHE_ROOT}"/*
echo "引数の数: $#"
echo "先頭3件: $1 $2 $3"手元での出力はこうなりました。
引数の数: 25
先頭3件: /bin /boot /dev
25 という数字は、ルート直下のエントリ数です。「うっかり広い範囲を消しかけた」という曖昧な言い方をしていた事故が、25 個の起点という具体的な形で目の前に出てきました。
続けて、変数の状態と防御の組み合わせを総当たりで試しました。
| 変数の状態 | 防御 | 結果 |
|---|---|---|
未定義(unset) | set -u | 止まる(bash: unbound variable) |
未定義(unset) | ${VAR:?} | 止まる |
空文字を代入(VAR="") | set -u | 止まらない(そのまま通過) |
空文字を代入(VAR="") | ${VAR:?} | 止まる |
三行目が答えでした。
set -u が見ているのは「その変数が定義されているか」であって、「中身があるか」ではありません。私のスクリプトはパスの組み立てに失敗したとき、変数を未定義のままにするのではなく、空文字を代入していました。定義はされている。だから -u は何も言わなかったのです。
${VAR:?メッセージ} のほうは、未定義と空文字の両方を弾きます。値を使う直前に一行置くだけで、この差が埋まります。
: "${CACHE_ROOT:?CACHE_ROOT が空です}"ひとつ注意点があります。この停止の終了コードはシェルによって違いました。同じ条件で bash は 127、dash は 2 を返します。「特定の番号を見て分岐する」書き方をすると、実行環境が変わった瞬間に検知をすり抜けます。終了コードは非ゼロかどうかだけで判断するのが安全です。
auto モードが「文脈から解決できない変数」を見ていた
止めてくれたのは、Claude Code の auto モードでした。
2026年7月のアップデートで、auto モードはいくつかの安全側の挙動を既定に加えています。その一つが、文脈から値を解決できない変数を含む rm -rf は、実行前に確認を挟むというものです。
エージェントは CACHE_ROOT に確かな値を結び付けられませんでした。だから黙って走らせず、私に判断を返してきたのです。
ここで大切なのは、単純な「破壊的コマンドは全部止める」ではない点だと感じています。それでは確認が多すぎて、結局は読まずに承認する癖がつきます。
見ているのは「文脈から解決できるか」でした。値の裏付けが取れない削除だけを拾い上げる。この線引きは、任せる側の集中力を消耗させない配慮のように思えました。
あわせて、auto モードはセッション記録そのものの改ざんもブロックするようになっています。何を実行したかの痕跡が後から書き換えられない——放置気味に回す作業ほど、この一線は効いてきます。
Opus 4.8 に長い片付けを任せた手触り
同じ日、Claude Opus 4.8 が一般提供になりました。コーディングやエージェント的な作業、そして長い手順の一貫性が底上げされたモデルです。
今回の掃除は、リポジトリ数だけ手順が繰り返される、地味に長い仕事でした。各リポジトリでキャッシュ場所を確認し、消してよいものを見極め、消す。この往復を最後まで文脈を保ったまま進めてくれたのは、素直に助かりました。
一方で、任せきりにはしていません。
削除対象の一覧を、実行の前に必ずテキストで出してもらう。これは私が指示に加えた手順です。モデルが賢くなるほど、私の側の「確認の型」を明文化しておくことが効いてくると感じています。
削除に入る前の対象確認を、擬似コードで表すとこうなります。
# 消す前に、対象を必ず可視化してから承認する
for repo in "${REPOS[@]}"; do
cache="${repo}/build-cache"
# 値の裏付けが取れないパスは、そもそも削除ループに渡さない
if [ -z "${cache}" ] || [ ! -d "${cache}" ]; then
echo "skip: ${repo}(キャッシュ未確認)"
continue
fi
echo "delete candidate: ${cache}"
done[ -z "${cache}" ] の一行が、あの夜の空変数を弾く砦です。auto モードの事前確認と、自分のスクリプト側の防御。二重にしておくと、夜間に任せていても落ち着いていられます。
自分の運用に落とし込んだ確認ルール
今回の一件のあと、片付け系の作業をエージェントに任せるときの約束事を、いくつか言葉にしました。
| 場面 | 決めたこと |
|---|---|
| 削除の直前 | 対象の絶対パス一覧を必ずテキスト出力し、目で確認してから承認する |
| 変数の扱い | パスを組む変数は空チェックを必須にし、空ならその対象をスキップする |
| 権限 | rm -rf を含む手順は auto モードの事前確認を無効化しない |
| 記録 | 実行ログは改ざん不可の前提で残し、翌朝に何を消したか追える状態にする |
特別なことは何もありません。けれど個人開発の現場では、この当たり前を仕組みとして持っておけるかどうかが、安心して手を離せるかの分かれ目になります。
AdMob の設定や App Store 向けのビルドと違って、キャッシュ掃除は成果が見えにくい地味な作業です。だからこそ雑になりやすく、事故もそこで起きます。
ドライランを既定にした — 書き直したスクリプトと実行結果
規則を言葉にしたところで、実行するのは疲れた深夜の自分です。読まなくても安全な側に倒れる作りにしておかないと、たぶん同じことを繰り返します。
そこで掃除スクリプトを書き直しました。既定はドライラン、実行するときだけ環境変数で明示する形です。
#!/usr/bin/env bash
set -Eeuo pipefail
DRY_RUN="${DRY_RUN:-1}" # 既定はドライラン。消すときだけ DRY_RUN=0
ROOT="${1:?ROOT を指定してください}" # 未定義も空文字も、ここで落ちる
total_kb=0
while IFS= read -r cache; do
: "${cache:?キャッシュパスが空です}" # ループの中でも都度検証する
case "$cache" in
/|/*/) echo "refuse: $cache(ルート直下は対象外)" >&2; continue ;;
esac
kb=$(du -sk "$cache" | cut -f1)
total_kb=$((total_kb + kb))
printf '%-28s %8s KB\n' "$cache" "$kb"
[ "$DRY_RUN" = "0" ] && rm -rf -- "$cache"
done < <(find "$ROOT" -maxdepth 2 -type d -name build-cache | sort)
echo "----"
echo "対象 ${total_kb} KB / DRY_RUN=${DRY_RUN}"検証用に、キャッシュを持つリポジトリ二つと、構成変更でキャッシュが無くなったリポジトリ一つを作って走らせました。既定のドライランでは、こう出ます。
/tmp/guardtest/demo/repoA/build-cache 12292 KB
/tmp/guardtest/demo/repoC/build-cache 7172 KB
----
対象 19464 KB / DRY_RUN=1
キャッシュを持たない repoB は一覧に現れません。消す前に「何が対象で、合計どれだけか」が数字で見えます。
引数を渡し忘れた場合と、空文字を渡した場合も試しました。どちらも同じ場所で止まり、終了コードは 1 でした。
./clean.sh: line 5: 1: ROOT を指定してください
そのうえで DRY_RUN=0 を付けて実行すると、一覧に出ていた二つだけが消え、repoB のディレクトリは触られないまま残りました。ドライランで見た内容と、実際に起きたことが一致しています。この一致を目で確かめられることが、私にとっての安心の中身でした。
一点、書きながら踏んだ落とし穴を残しておきます。最初は find ... | while read とパイプでつないでいたのですが、合計が最後まで 0 のままでした。
パイプ版 total=0
プロセス置換版 total=19464
パイプの右側は別のシェルとして動くため、ループの中で足した total_kb が外に戻ってきません。done < <(find ...) のプロセス置換に変えると、期待どおりの合計になりました。合計を出す種類のスクリプトでは、静かに間違うので気をつけたいところです。
追記(2026年8月) — 既定になる前に確かめておくこと
この記事を書いたあと、auto モードは試用の位置づけから前へ進み、2026年8月14日に Pro・Max・Team で既定有効になると案内されました。手で止めていた工程が、これからは既定で流れていくことになります。
Anthropic は既定化の説明の中で、Claude Code の権限プロンプトのうち 97% が承認されている、と述べています。同社自身の集計なので数字の扱いには慎重でありたいのですが、この一点は身に覚えがありました。あの夜より前の私も、表示された確認をほとんど読まずに通していたはずです。
読まずに承認する確認は、実質的に防御ではありません。そう考えると、既定化は「守りが薄くなる変更」というより、機能していなかった守りが正直な形に置き換わる変更なのだと思います。
既定が切り替わる前に、私は次の三つを手元で確かめました。
| 確かめたこと | やり方 |
|---|---|
| 止まる操作と進む操作の境界 | 削除・force push・外部への書き込みを、捨ててよいリポジトリで実際に走らせて記録する |
| 自分の環境固有の危険な手順 | 止まってほしいのに進んでしまった手順を洗い出し、拒否ルールとして書き出す |
| スクリプト側の防御が生きているか | 本記事のドライランと ${VAR:?} を、掃除以外の手順にも展開する |
三つ目が、私にとっては一番効きました。auto モードが止めてくれるのは、値の裏づけが取れない操作です。裏づけは取れているけれど自分の意図とは違う、という間違いは、エージェント側からは正しい操作に見えます。そこは自分の側で塞ぐしかありません。
これから取り組むこと
ドライランは、掃除以外の手順にも順に広げているところです。移行スクリプトやログの整理など、「一覧を先に出せる作業」であれば同じ形が使えます。
残っている課題は、拒否ルールの粒度です。広く書けば止まりすぎて読まなくなり、狭く書けば抜けが出ます。しばらくは実際に止まった回数と、そのうち本当に危なかった回数を記録して、手元の数字で調整していこうと考えています。
賢いエージェントに任せられる範囲は、確かに広がりました。同時に、任せる側が「どこで立ち止まってほしいか」を先に決めておく責任も、静かに増している気がします。
あの夜、冷たくなった手のことは、しばらく忘れないと思います。削除の前に一呼吸置いてくれた事前確認に、素直に感謝しています。同じように夜間へ作業を委ねている方の、小さな備えの参考になれば幸いです。お読みいただき、ありがとうございました。