前回、Claude Codeに /docs/ フォルダを消された話を書きました。サンドボックスも、コマンドの承認も、Gitも効かず、最後に助けたのはNAS宛のTime Machineだった、という記録です。あの記事の最後で「denyルールを見直した」と一行だけ書きました。では具体的に何をどう書き直したのか。今回はその中身です。
先に、いちばん大事なところを書いておきます。denyリストに危険なコマンドを並べるだけでは、同じ事故は防げませんでした。書き方に落とし穴があり、しかも私は前回の記事で、安全網の構造そのものを1つ読み違えていました。設定ファイルを開いて確かめて、それに気づきました。
訂正:サンドボックスと承認は、独立していなかった
前回、私は3枚の安全網が別々にすり抜けられた、と書きました。サンドボックス、コマンドの承認、Git。この3つが独立した網だという前提で話を組み立てていました。設定ファイルを読み直して、その前提が間違っていたと分かりました。
| 1 2 3 4 5 | "sandbox": { "enabled": true, "autoAllowBashIfSandboxed": true, "excludedCommands": ["docker", "git"] } |
2行目です。autoAllowBashIfSandboxed。サンドボックスが有効なら、bashコマンドを自動的に許可する、という設定です。私自身が有効にしていました。
つまり、サンドボックスを入れたことが、承認の網を薄くしていたわけです。1枚目を張ったから2枚目が緩む。この2つは並んだ別々の網ではなく、片方がもう片方に効いてくる関係でした。守りを増やしたつもりで、実際にはトレードしていた。前回の記事で「守備範囲の違うものを並べて、数だけ数えて安心していた」と書きましたが、数え方より前に、そもそも独立していると思っていたのが誤りでした。
denyに書いた rm -rf は、たぶん今回の削除を止めない
見直したdenyリストは、いまこうなっています。
| 1 2 3 4 5 6 7 8 9 10 11 12 13 | "deny": [ "Read(./.env)", "Read(./.env.*)", "Read(~/.ssh/**)", "Read(~/.aws/**)", "Bash(curl:*)", "Bash(wget:*)", "Bash(wp plugin install:*)", "Bash(wp plugin delete:*)", "Bash(wp plugin uninstall:*)", "Bash(git clean:*)", "Bash(rm -rf /:*)" ] |
秘密情報の読み取り、外部への通信、プラグインの操作、そして削除系。並べたときは、これで削除は止まると思っていました。
ところが最後の1行をよく見ると、rm -rf / で始まるコマンドを止める書き方です。この形の指定は前方一致で効きます。rm -rf /Users/... のような絶対パスは止まる。では rm -rf docs/ は。rm -rf ./docs は。どちらも rm -rf / では始まらないので、このルールをすり抜けます。今回消えたのはワークスペース内の相対パスですから、この1行を先に書いていたとしても、同じ事故は起きたことになります。
ルート直下を吹き飛ばす事故は防げる。手元のフォルダを消す事故は防げない。そして実際に起きるのは、後者のほうです。git clean:* をdenyに入れたのは正解でしたが、これも同じ話で、消し方はいくらでもあります。rm、find -delete、mv で別の場所へ飛ばす。禁止リストを長くしていく方向には、やはり終わりがありませんでした。
本命はPreToolUseフック
それで、実際の守りはdenyリストの外に置きました。
| 1 2 3 4 5 6 7 8 9 10 11 12 13 | "hooks": { "PreToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "command", "command": "bash /Users/[ユーザー名]/.claude/hooks/protect-plugin-dirs.sh" } ] } ] } |
PreToolUseフックは、ツールが実行される前に、自分のスクリプトを差し込む仕組みです。matcher に Bash を指定しているので、Claude Codeがbashコマンドを走らせようとするたびに、このシェルスクリプトが先に呼ばれます。スクリプトの中で、そのコマンドが守りたいディレクトリに触ろうとしていないかを自分で判定して、危ないと判断したら止める。
denyリストとの違いは、判定を文字列の前方一致に頼らなくていいことです。コマンドの中身を自分のロジックで読んで、相対パスだろうと絶対パスだろうと、対象が守りたい場所かどうかで決められる。rm か find かという入口の違いにも縛られません。
スクリプトがやっていることは3段階です。Claude Codeは実行しようとしているコマンドをJSONで標準入力に渡してくるので、そこから .tool_input.command を取り出す。危ないと判定したら、permissionDecision に deny を入れたJSONを返す。返さなければ、そのまま通ります。
判定は3つ書きました。ひとつ目が、/・~・$HOME・.・裸のグロブに対する再帰削除。カレントディレクトリの . を含めているのは、今回まさにその形で作業フォルダごと消えたからです。ふたつ目が、プラグインの開発フォルダ名がパスの一部として出てくる破壊的な操作。みっつ目が、wp plugin install/delete と git clean です。
ふたつ目が、denyリストでは書けなかった部分です。判定の対象を「コマンドの先頭」ではなく「守りたいフォルダ名がコマンドのどこかに出てくるか」に置いています。だから rm -r でも rmdir でも mv でも rsync --delete でも find -delete でも、同じ1本のルールで引っかかる。前回書いた「消し方はいくらでもある」への答えが、ここです。入口を1つずつ塞ぐのではなく、行き先で判定する。
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 | #!/usr/bin/env bash # # protect-plugin-dirs.sh — PreToolUse(Bash) guard. # # Blocks shell commands that could delete, move, or overwrite the Prime Cache # plugin *development* folders (prime-cache / prime-cache-pro) or otherwise wipe # the working tree. Added after the 2026-07-19 folder-loss incident. # # Reads the PreToolUse hook JSON on stdin and, when a command is dangerous, # prints a deny decision. Every other path exits 0 (allow) — including any parse # trouble, so a bug in this guard can never wedge the session (fail-open). input=$(cat) # jq missing or no command field -> nothing to inspect; allow. command -v jq >/dev/null 2>&1 || exit 0 cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // empty' 2>/dev/null) [ -z "$cmd" ] && exit 0 low=$(printf '%s' "$cmd" | tr '[:upper:]' '[:lower:]') deny() { reason=$(printf '%s' "$1" | jq -R -s .) printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":%s}}\n' "$reason" exit 0 } # 1) Catastrophic wipes anywhere, incl. deleting the current directory (which is # itself a plugin folder). Covers rm -rf on /, ~, $HOME, ., and bare globs. if printf '%s' "$low" | grep -Eq 'rm[[:space:]]+(-[a-z]*[rf][a-z]*[[:space:]]+)+(/|~|\$home|\.|\*)([[:space:]]|/|$)'; then deny "Blocked: recursive delete targeting /, ~, the current directory, or a bare glob." fi # 2) Any destructive/overwriting operation that names a plugin dev folder as a # terminal path segment. A trailing "." — e.g. prime-cache.zip — is NOT a # match, so the release build (rm -f prime-cache.zip; zip ... "prime-cache/") # is fine. target='(^|[/[:space:]"'"'"'])(prime-cache|prime-cache-pro)/?([[:space:]"'"'"']|$)' destructive='rm[[:space:]]+-[a-z]*r|(^|[[:space:]])rmdir[[:space:]]|(^|[[:space:]])mv[[:space:]]|(^|[[:space:]])trash[[:space:]]|-delete([[:space:]]|$)|-exec[[:space:]]+rm|rsync[[:space:]].*--delete|git[[:space:]]+clean' if printf '%s' "$low" | grep -Eq "$destructive"; then if printf '%s' "$low" | grep -Eq "$target"; then deny "Blocked: this command could delete, move, or overwrite the plugin development folder." fi fi # 3) Plugin (re)install / force operations that replace the whole folder. if printf '%s' "$low" | grep -Eq 'wp[[:space:]]+plugin[[:space:]]+(install|delete|uninstall)'; then deny "Blocked: 'wp plugin install/delete' can overwrite or remove the plugin folder." fi if printf '%s' "$low" | grep -Eq 'git[[:space:]]+clean'; then deny "Blocked: 'git clean' removes untracked files and can wipe local-only docs." fi exit 0 |
自分の作業を止めない工夫
ガードを書くときに面倒なのは、守りを強くするほど自分の日常の作業が引っかかることです。このプラグインのリリース作業では、配布用のZIPを作り直すために rm -f prime-cache.zip を毎回走らせます。フォルダ名がそのままファイル名の頭に入っているので、素朴に「prime-cache という文字列を含む削除」を止めると、リリースのたびに自分でブロックされます。
そこで、フォルダ名の判定は末尾がスラッシュか区切り文字のときだけ一致するようにしました。prime-cache/ は止まり、prime-cache.zip は通る。ドット1つで意味が変わります。ガードは、書いた本人が回避したくならない強さで止めるのが大事で、うるさすぎるガードは結局オフにされます。承認疲れと同じ話です。
わざと fail-open にしてある
もうひとつ、意識して決めたことがあります。このスクリプトは、判定に失敗したら通す作りにしてあります。jq が入っていなければ通す。コマンドが取り出せなければ通す。想定外の入力が来ても通す。冒頭のコメントに fail-open と書いたのはそのためです。
セキュリティの原則からすると逆で、迷ったら止める(fail-closed)が本来の作法です。それでも開ける側に倒したのは、このガードが壊れたときにセッションごと動かなくなるほうが、実害として大きいと考えたからです。すべてのbashコマンドの前に挟まる仕組みなので、ここでバグると何もできなくなります。守りのために全部止まるくらいなら、守りが空振りするほうを選びました。
この判断は、正直、正しいかどうか自信がありません。守りたかったフォルダは一度失っていて、それでもなお開ける側に倒している。安全と使い勝手のあいだのどこに線を引くかは、たぶん人によって違います。

承認疲れは、allowリストで減らす
前回の事故の直接の引き金は、確認ダイアログを読まずにOKを押したことでした。承認疲れです。ここへの対処は、確認を増やす方向ではなく、減らす方向に振りました。
| 1 2 3 4 5 6 7 | "allow": [ "Read", "Grep", "Glob", "Edit", "Bash(git status:*)", "Bash(git diff:*)", "Bash(git log:*)", "Bash(git show:*)", "Bash(ls:*)", "Bash(cat:*)", "Bash(head:*)", "Bash(tail:*)", "Bash(wc:*)", "Bash(rg:*)", "Bash(php -l:*)", ... ] |
読むだけのコマンド、状態を見るだけのコマンド、構文チェック。この手のものを片っ端からallowに入れました。git status や ls で毎回確認を求められても、判断する材料は何もありません。押すだけの動作が積み重なるから、本当に読むべき確認まで同じ手つきで押してしまう。
確認の回数を減らすことが、確認の質を上げる。安全側に倒すつもりで確認を増やすと、かえって全部が素通りになる。今回いちばん腑に落ちたのが、この逆転でした。逆に ask に残したのは、外に影響が出る2つだけです。
| 1 2 3 4 | "ask": [ "Bash(git push:*)", "Bash(npm install:*)" ] |
それでも、最後の砦はバックアップ
設定を書き直しながら、結局のところ前回と同じところに戻ってきました。防ぐ側をどれだけ厚くしても、抜け道は残ります。denyリストは前方一致でしか見ない。フックは自分の書いたロジックの範囲でしか判定できない。allowを整理して確認の質を上げても、押すのは人間です。
だから、消されても戻せる状態のほうを厚くしました。前回の記事の最後に「ワークスペースの同期バックアップは検討中」と書きましたが、そのあと入れ終わったので、ここに書いておきます。restic を launchd で6時間ごとに回す構成にしました。
対象はプラグインの作業ディレクトリそのものです。保存先はTime Machineとは別のNASで、SFTP経由でリポジトリを置いています。世代は日次7・週次4・月次12・年次3で保持し、週に一度、保存済みデータの一部を読み直して壊れていないか検証する設定にしました。Time Machineとの違いは、Mac全体ではなく作業ディレクトリだけを狙うこと、そして世代を細かく持てることです。
これで、性格の違う網が2枚になりました。Mac全体を面で守る汎用の網と、作業ディレクトリだけを狙う専用の網。前回の事故で受け止めたのは前者です。専用のほうは、事故のあとに慌てて張りました。順番としては本当は逆であってほしかった、というのが正直なところです。
設定ファイルを直しても、事故が起きない保証にはなりませんでした。起きたときに、戻せる場所が増えただけです。
関連: Claude Codeにフォルダを消された。安全網は3枚あって、3枚とも素通りした(この記事の前編)

コメント