「キャッシュを消してほしい」と頼んだ結果、開発者のドライブの中身が消えてしまったのだと報じられていました。その報告を読んだ夜のことです。私は自分のビルド掃除用のシェルスクリプトを開き直しておりました。
個人開発のアプリでは、ビルドの残骸を消す作業が週に何度も発生します。その中に、変数が空のまま動けば根元のディレクトリを指しうる行が、一つ残っていました。報告の主役はエージェントでしたが、胃のあたりが冷えたのは、自分の古いスクリプトに対してでした。いま思えば、他人の事故を読んでいるつもりで、自分の棚卸しを迫られていたのかもしれません。
最初にお伝えしたいのは、承認を外してよいかどうかより先に、削除がどこまで届くかを機械で狭めておくほうが、はるかに効くということです。このあと、その関所を Python で一枚作り、任せてよい作業と任せない作業の線引きまでを書き残してまいります。
報じられたことと、私が断定しないこと
私が確認できたのは、報道の見出しと概要の範囲です。キャッシュの削除を頼んだところ、意図しない範囲まで消えた、という内容でした。原因が指示の曖昧さなのか、パスの解決なのか、権限の設定なのかは、公開されている範囲では私には判断がつきません。
ですので、ここから先は「あの事例の再現」ではなく、同じ種類の事故を自分の環境で起こさないための手当てとして読んでいただければと思います。
事故の種類は、突き詰めると一つです。削除コマンドの対象が、頼んだ人の想像より広かったのだと思います。それだけのことです。
「頼み方」を磨くより、「届く範囲」を先に狭めます
最初のうち、私は指示文のほうを直そうとしておりました。「このプロジェクトの build フォルダだけを」と書き添え、念押しを増やしていったのです。結果は芳しくありませんでした。念押しは、読む側が守ってくれたときにしか効きません。
いまは順番を逆にしています。対象が正しいかどうかを人の注意や文章の丁寧さに預けず、削除を実行する直前の一点で、機械が範囲を検査します。
検査する項目は多くありません。
- 対象が空文字ではないこと(変数が展開されなかった場合を止めます)
- 実体のパスに解決したとき、プロジェクトの内側にあること
- プロジェクトのルートそのものではないこと
- ルートや
/Users/名前のような浅い場所ではないこと - シンボリックリンクを辿らないこと
.gitを含むディレクトリではないこと
どれも、事故のあとに読み返すと当たり前に見える項目です。当たり前だからこそ、毎回人の目に任せると抜けます。
関所の実装(既定は dry-run)
次のスクリプトを safe_rm.py として、プロジェクトの外側(たとえばホーム直下の bin)に置きます。プロジェクトの内側に置かないのが、地味ですが大事な点です。 エージェントが編集できる場所に置くと、関所そのものが書き換わりうるためです。
#!/usr/bin/env python3
"""削除系コマンドの手前に置く関所。既定は dry-run、--apply を付けたときだけ消す。"""
import argparse
import os
import shutil
from pathlib import Path
MIN_DEPTH = 3 # /Users/name/project より浅い場所は無条件で拒否
def check(target: str, root: Path) -> Path:
if not target.strip():
raise SystemExit("拒否: 対象が空です(変数が展開されていない可能性があります)")
p = Path(target).expanduser()
if p.is_symlink():
raise SystemExit(f"拒否: シンボリックリンクは辿りません: {p}")
real = p.resolve(strict=True)
if len(real.parts) <= MIN_DEPTH:
raise SystemExit(f"拒否: 浅すぎる場所です: {real}")
if real == root or root not in real.parents:
raise SystemExit(f"拒否: プロジェクトの外、または root そのものです: {real}")
if (real / ".git").exists():
raise SystemExit(f"拒否: .git を含むディレクトリは消しません: {real}")
return real
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("targets", nargs="*")
ap.add_argument("--root", default=os.getcwd())
ap.add_argument("--apply", action="store_true")
a = ap.parse_args()
root = Path(a.root).resolve(strict=True)
if not a.targets:
raise SystemExit("拒否: 対象がありません")
for t in a.targets:
real = check(t, root)
if real.is_dir():
size = sum(f.stat().st_size for f in real.rglob("*") if f.is_file())
else:
size = real.stat().st_size
print(f"{'削除' if a.apply else '予定'}: {real} ({size:,} bytes)")
if a.apply:
shutil.rmtree(real) if real.is_dir() else real.unlink()
if __name__ == "__main__":
main()「なぜこう書くか」を三つだけ添えます。--apply を付けない限り何も消えないのは、エージェントが最初に打つコマンドを必ず「予定の表示」にするためです。resolve(strict=True) は、存在しないパスを黙って通さないためです。そして root not in real.parents で判定しているのは、文字列の前方一致だと /proj と /proj-old を取り違えるからです。
手元で確かめた結果
実際に、次の構成を作って動かしました。proj の中に build/cache と、外側を指すシンボリックリンクを置き、proj の外には消されては困るファイルを一つ置いています。
| 渡した対象 | 結果 |
|---|---|
proj/build/cache(dry-run) | 予定の表示のみ。何も消えない |
| 空文字 | 拒否(終了コード 1) |
proj の外のディレクトリ | 拒否 |
| 外側を指すシンボリックリンク | 拒否。外側のファイルは無傷 |
| プロジェクトのルートそのもの | 拒否 |
/tmp(浅い場所) | 拒否 |
.git を含むサブディレクトリ | 拒否 |
proj/build/cache(--apply) | 削除。外側のファイルは残る |
終了コードが 1 で返るので、エージェントが走らせたときも、失敗として表に出ます。黙って通り抜けることがありません。
承認を外す作業と、外さない作業
関所があっても、任せる範囲の線引きは別に要ります。私は次のように分けています。
| 作業 | 承認なしで任せる | 理由 |
|---|---|---|
| ビルド成果物の再生成 | 任せる | 消えても同じ手順で戻せるため |
safe_rm.py 経由の削除(dry-run) | 任せる | 予定の表示だけで、何も変わらないため |
safe_rm.py --apply | 目で見てから | 表示された「予定」の一覧を、人が一度読みます |
| プロジェクト外のパスを含む削除 | 任せない | 戻せるかどうかを、人が先に確かめます |
| 同期フォルダ(Dropbox など)配下の削除 | 任せない | 削除が他の端末へ伝わるため |
同期フォルダの行は、私自身の実感から足しました。Dropbox の中で消したものは、手元のバックアップがなければ、版履歴に頼ることになります。復元できるとはいえ、夜中にそれを探す作業は、できれば避けたいものです。
関所を通さないときの、もう一つの保険
削除を機械で狭めても、戻せる経路は別に持っておきます。大きな作業の前には、次の二つだけを欠かさないようにしています。
- 作業ディレクトリを Git で一度コミットし、未追跡ファイルも含めて状態を固定する
- プロジェクトの外にあるものは、そもそもエージェントの作業ディレクトリに入れない
どちらも、手順としては短い文で済みます。短いからこそ、疲れている日でも飛ばしにくいのです。
承認の設定画面の名称や置き場所は版によって変わりますので、ここでは触れません。許可リスト・拒否リストの考え方そのものは、公式のドキュメントに整理されています。設定を触る日は、そちらを開きながら確かめていただければと思います。
まずは一行だけ
今日いちばんにお勧めしたいのは、手元の削除スクリプトを開いて、変数が空だったら何を消すのかを確かめることです。私は、それだけで一つ冷や汗をかきました。
頼み方は人が磨き、届く範囲は機械が狭めます。 この分担だけは、疲れた日でも崩さないようにしています。