朝、ログを開いたら、あるはずのディレクトリが無くなっていました。
正確に言えば、無くなったのは「掃除対象の中身」ではなく「掃除対象そのもの」です。夜のうちに一度だけ走る保守処理で、私自身が書いた一行が、親ディレクトリごと持っていっていました。
原因はすぐに分かりました。find に -mindepth 1 を書き忘れていただけです。ただ、そこから先が長くなりました。「なぜ気づかなかったのか」と「なぜ後段の mkdir -p が助けにならなかったのか」を追っていくうちに、同じ形の穴が、個人開発で回している他の自動化にもいくつも残っていることが見えてきたためです。
突き詰めると、問われていたのは一点でした。無人で走る処理が「わからない状態」に落ちたとき、どちら側へ倒れるか。 私のスクリプトは、そろって破壊側へ倒れる作りになっていました。
消えたのは中身ではなく、ディレクトリそのものでした
まず、事故そのものを再現しておきます。以下は実際に手元の環境(GNU bash 5.1.16 / GNU findutils 4.8.0)で走らせた検証です。
B=/tmp/probe1; rm -rf $B; mkdir -p $B
mkdir -p $B/a/work/sub1 $B/b/work/sub1
touch $B/a/work/f1 $B/a/work/sub1/f2
touch $B/b/work/f1 $B/b/work/sub1/f2
# 事故った側: -mindepth 1 なし
find $B/a/work -delete
echo "a/work exists? $([ -d $B/a/work ] && echo yes || echo NO)"
# 正しい側: -mindepth 1 あり
find $B/b/work -mindepth 1 -delete
echo "b/work exists? $([ -d $B/b/work ] && echo yes || echo NO) / 中身=$(ls -A $B/b/work | wc -l)件"
実行結果です。
a/work exists? NO
b/work exists? yes / 中身=0件
find の起点パスは、それ自体が探索結果の一件目です。-delete はその一件目も対象にします。つまり -mindepth 1 は「省略しても同じ」ではなく、「起点を守るか、起点ごと渡すか」を決める指定です。
ここまでは、知っていれば防げる話です。私が本当に困ったのは次でした。
mkdir -p は安全弁になりませんでした
私のスクリプトには、掃除の直後に mkdir -p "$WORK" が書いてありました。「万一消えても作り直すから大丈夫」という意図です。実際にはこう振る舞います。
mkdir -p $B/a/work
echo "a/work exists? $([ -d $B/a/work ] && echo yes) / 中身=$(ls -A $B/a/work | wc -l)件"
a/work exists? yes / 中身=0件
ディレクトリは戻りますが、中身は戻りません。そして厄介なのは、後段の処理が「ディレクトリはある」という前提で正常に進んでしまうことです。存在チェックは通り、書き込みも通り、終了コードは 0 です。
事故が起きたのに、事故として観測されません。この日の異常に私が気づいたのは、翌朝ログを目で追ったときでした。mkdir -p は復旧処理のつもりで置いたのに、実際には事故を隠す装置として働いていたわけです。
同じ形の書き方は、ほかにもあります。「無ければ作る」「壊れていれば初期化する」「読めなければ既定に戻す」。どれも復旧の顔をしていますが、壊れた事実を消してから先へ進むという点では同じです。
Antigravity CLI 1.1.16 に「解析できない settings.json を既定値で上書きしてしまう問題」の修正が入りました。従来は、設定ファイルが読めない状態で次に何かを保存した時点で、全設定が黙って初期化されていたそうです。修正後は保存を拒否し、ファイルはバイト単位で保たれ、ステータスラインが該当ファイルを名指しします。
読めない設定を既定値で上書きする、というのは私の mkdir -p と同じ構図です。復旧の意図で書いた分岐が、いちばん情報を失う経路になっていました。手元のスクリプトを見直すきっかけとしては、これ以上ないタイミングでした。
既定値で埋める書き方が、破壊側の分岐を選んでいた
もう一箇所、事故の引き金になった書き方があります。ディスク残量を見て、少なければキャッシュを掃除する、という判定です。
FREE_MB=$(df /tmp --output=avail -m 2>/dev/null | tail -1 | tr -d ' ')
if [ "${FREE_MB:-0}" -lt 500 ]; then
# 掃除を実行する
fi
一見、防御的に見えます。2>/dev/null でエラーを黙らせ、${FREE_MB:-0} で未定義に備えています。私はこの二つを「安全のため」に書いたつもりでした。
実際の挙動を測ります。
echo "df 正常: [$(df /tmp --output=avail -m 2>/dev/null | tail -1 | tr -d ' ')]"
FREE=$(df /nonexistent-mount --output=avail -m 2>/dev/null | tail -1 | tr -d ' ')
echo "df 失敗時の値: [${FREE}] len=${#FREE}"
echo "判定に渡る左辺: [${FREE:-0}]"
df 正常: [3195]
df 失敗時の値: [] len=0
判定に渡る左辺: [0]
df が失敗すると、変数には空文字が入ります。そして ${FREE:-0} は空文字も 0 に置き換えます。結果として、「残量がわからない」という状態が「残量ゼロ」として扱われ、掃除の分岐が選ばれます。
これが直感に反する部分でした。:-0 は未定義を埋めるための書き方で、私はそれを安全側の措置だと思っていました。実際には、不明を最悪値に読み替えたうえで、その最悪値がいちばん破壊的な分岐に対応していました。安全のつもりで書いた一つのデフォルトが、事故の導線になっていたわけです。
set -u を足しても、この経路は塞がりません。:- を使っている以上、未設定エラーは発生しないためです。
「わからない」を三つ目の状態として扱う
対処は難しくありませんでした。真偽の二値ではなく、「はい」「いいえ」「わからない」の三状態として扱えばよいだけです。
# 取得できなければ空を返す。0 に倒さない
free_mb() {
local v
v=$(df "$1" --output=avail -m 2>/dev/null | tail -1 | tr -d ' ')
[[ "$v" =~ ^[0-9]+$ ]] || return 1 # 数値でなければ失敗として返す
printf '%s' "$v"
}
if V=$(free_mb /tmp); then
if [ "$V" -lt 500 ]; then
purge_under "$ALLOW_ROOT" "$CACHE_DIR"
fi
else
echo "SKIP: 残量を取得できないため掃除を実行しません" >&2
fi
数値かどうかを正規表現で確かめ、確かめられなければ「失敗」として返します。呼び出し側は、失敗したときに何もしない。掃除されないまま容量が逼迫する可能性は残りますが、そちらは次の実行で観測できます。消えたファイルは観測できません。
無人で回す処理では、この非対称性が判断の基準になります。やらなかったことは後から取り返せます。やってしまったことは取り返せません。 迷ったら、取り返せる側へ倒します。
許可ディレクトリの外へ手を伸ばさせないガード
-mindepth 1 を足しても、まだ足りません。削除対象のパス自体が想定外の場所を指していたら、正しく「中身だけ」消したところで被害は同じです。
そこで、削除を関数一つに閉じ込め、許可した根の下だけを扱うようにしました。
# purge_under: allow_root の実体パス配下に限り、target の中身だけを削除する
purge_under() {
local allow_root="$1" target="$2"
local ar tg
# 実体パスへ解決する(解決できなければ何もしない)
ar=$(cd "$allow_root" 2>/dev/null && pwd -P) || { echo "SKIP: allow_root 解決不可" >&2; return 3; }
tg=$(cd "$target" 2>/dev/null && pwd -P) || { echo "SKIP: target 解決不可" >&2; return 3; }
# 許可した根そのものを対象にしない
[ "$tg" = "$ar" ] && { echo "SKIP: target が allow_root そのもの" >&2; return 3; }
# 許可した根の配下であることを、実体パスで確かめる
case "$tg/" in
"$ar"/*) : ;;
*) echo "SKIP: allow_root の外 ($tg)" >&2; return 3 ;;
esac
find "$tg" -mindepth 1 -delete && echo "OK: $tg の中身を削除"
}
五通りの入力で実際に確かめた結果です。
| 渡した target | 結果 | 保護対象の状態 |
$B/root/cache(正常系) | OK: 中身を削除 | cache 自体は残存・中身 0 件 |
$B/root(許可した根そのもの) | SKIP | root 残存 |
$B/outside(明らかに外側) | SKIP | precious 残存 |
$B/root/../outside(.. 経由) | SKIP | precious 残存 |
$B/root/link(外側へのシンボリックリンク) | SKIP | precious 残存 |
return 3 は「実行しなかった」を表す独自コードです。0(成功)とも 1(失敗)とも区別できるようにしておくと、呼び出し側で「スキップが続いている」ことに気づけます。スキップを成功として飲み込むと、結局また無言の異常に戻ります。
素朴な前方一致は、シンボリックリンクに騙されます
上の実装で pwd -P を挟んでいる理由を、はっきり見ておきます。文字列のまま前方一致を取ると、こうなります。
B=/tmp/probe3; mkdir -p $B/root $B/outside; touch $B/outside/precious
ln -s "$B/outside" "$B/root/link"
T="$B/root/link"
case "$T/" in "$B/root"/*) echo "素朴チェック: 許可";; *) echo "素朴チェック: 拒否";; esac
R=$(cd "$T" && pwd -P); echo "実体パス: $R"
case "$R/" in "$B/root"/*) echo "実体チェック: 許可";; *) echo "実体チェック: 拒否(外側だった)";; esac
素朴チェック: 許可
実体パス: /tmp/probe3/outside
実体チェック: 拒否(外側だった)
$B/root/link という文字列は、たしかに $B/root で始まります。素朴な前方一致は通ります。実体は外側です。
一点、誤解しやすいところを補足します。find -mindepth 1 -delete 自体はシンボリックリンクを追いません。リンクの実体ではなく、リンクそのものを消します。実際に確かめると、リンク先の precious は残り、リンクだけが消えました。
つまり危険なのは find の側ではなく、許可判定の側です。リンクを含むパスを「配下である」と判定してしまえば、その先で何をしても間に合いません。判定は必ず実体パスで行う、という一点に尽きます。
Antigravity 側の手当てと、こちら側に残る責任
同じ問題に、ツール側も手を入れています。
| バージョン | 変更 | こちら側への含意 |
| CLI 1.1.14 | ワークスペース外のパスは既定で読み取りのみ。書き込みは実行モードに応じた承認が必要 | エージェントが外へ書き出す既定の経路が一段狭くなりました。ただし承認を通した先は従来どおりです |
| CLI 1.1.14 | 不正な MCP サーバー設定が1件あっても、他のサーバーは読み込まれる | 「1件の異常で全体が黙る」構図の解消。異常はログに残る |
| CLI 1.1.14 | 言語サーバーの失敗が記録され、終了コードも非ゼロになる | CI やスクリプトから呼んでいる場合、これまで成功扱いだった失敗が表に出る |
| CLI 1.1.16 | 解析できない settings.json を既定値で上書きしません。保存を拒否し、ファイルはバイト単位で保たれます | 「読めない → 既定に倒す」を止めた実例。自分のスクリプトの同型箇所を探す手がかりになる |
ワークスペース外への書き込みをどう棚卸しするかは、CLI 1.1.14 に上げる前に、ワークスペースの外へ書いている箇所を数えるで別途整理しています。この記事で扱っているのはその先、承認を通したうえで実際に走る破壊操作の中身です。
境界が締まったことで安心しがちですが、私の事故はすべてワークスペースの内側で起きました。ツールが守ってくれるのは「外へ出ること」であって、「内側で消しすぎること」ではありません。
エージェントに掃除を書かせるときの渡し方
この経験のあと、掃除や初期化を含む依頼の出し方を変えました。実際に指示へ含めているのは次の四点です。
- 削除は関数一つに閉じ込め、その関数以外から
rm と find -delete を呼びません。 レビュー対象が一箇所に集まります。差分を読むときに、まずその関数だけを見れば済むようになりました
- 取得に失敗しうる値には、必ず「取得できなかった」経路を書きます。
:- による既定値の補完を、判定に使う値へは適用しない
- スキップを独自の終了コードで表します。 成功と混ぜない
- 削除の直後に「作り直す」処理を置きません。 作り直しが必要なら、掃除とは別のステップに分ける
四番目は、順序の問題というより意図の問題です。掃除と復旧を同じ関数に同居させると、片方の失敗をもう片方が覆い隠します。分けておけば、掃除が失敗した回はディレクトリが無いまま残り、次のステップが素直に落ちます。落ちてくれるのは、ありがたいことです。
エージェントに一括の変更を任せる場面全般については、1,400箇所の置換を1コミットで投げてきたエージェントに、バッチで返してもらう設計でも触れました。あちらは「量」の話で、こちらは「取り返しがつくかどうか」の話です。両方を通す必要があると、今は考えています。
点検の順序
同じ穴が自分の自動化に残っていないか、私は次の順で見ました。上から順に、被害の大きさで並べています。
| 順 | 探すもの | 検出のとっかかり |
| 1 | 起点ごと消しうる削除 | find の呼び出しで -delete があり -mindepth が無いもの |
| 2 | 変数展開を含む rm -rf | 変数が空になったときに何を指すかを、その場で読み下せるか |
| 3 | 判定に使う値の既定値補完 | if の中に :- が現れる箇所 |
| 4 | 削除の直後にある作り直し | mkdir -p が削除処理と同じ関数にあるもの |
| 5 | 文字列のままの配下判定 | パス比較の前に pwd -P や realpath を通していないもの |
一番と二番は機械的に洗い出せます。三番以降は、書いた意図を思い出しながら読む必要がありました。私の場合、三番でいちばん多く手が止まりました。「安全のために書いた既定値」が、そのまま候補として並んでくるためです。
次の一歩
手元のリポジトリで、-delete を含む find の呼び出しを一度だけ数えてみてください。そのうち -mindepth が付いていないものが何件あるか。私の環境では、事故を起こした一件のほかにも残っていました。
無人で走る処理の怖さは、失敗が静かなことにあります。壊れたことに気づける形にしておくだけで、翌朝の景色はずいぶん変わります。私自身、まだ全部を直しきれてはいませんが、少しずつ倒れる向きを変えているところです。お読みいただきありがとうございました。