CHANGELOG の1行で手が止まりました。
「プランモードが touch や rm といったファイルを変更する Bash コマンドを、許可プロンプトも SDK の canUseTool コールバックも通さずに実行してしまう不具合を修正」。
私は夜間に回している調査系のジョブで、プランモードを「読むだけのモード」と見なしていました。だから deny ルールを緩め、canUseTool のログも眺める程度に留めていたのです。守られていると思っていた場所が、実は素通りだった。そう気づいたときの、背筋のあたりが少し冷える感じを、うまく言葉にできませんでした。
幸い、私の環境で実害は見つかりませんでした。けれど「見つからなかった」と「起きなかった」は違います。この記事は、その差を埋めるために手を動かした記録です。
何が素通りしていたのか
まず事実を整理します。2026-07-18 に公開された Claude Code 2.1.212 で修正されたのは、次の経路です。
| 経路 |
修正前の挙動 |
修正後の挙動 |
| 対話セッションの許可プロンプト |
プランモード中のファイル変更 Bash で表示されない |
通常どおり確認を求める |
SDK の canUseTool コールバック |
呼ばれずに実行される |
他のツール呼び出しと同じく通過する |
settings の deny ルール |
評価される(この経路は無事) |
変わらず評価される |
重要なのは3行目です。deny ルールは効いていました。つまり、機構として明示的に書かれた禁止は守られ、モードの性質に暗黙で期待していた部分だけが崩れていたことになります。
この非対称は、示唆的だと感じています。私が緩めていたのは、まさに「モードが読み取り専用だから、ルールは要らないだろう」と判断した箇所でした。前提に寄りかかった分だけ、穴が空いていたわけです。
「読み取り専用」とは、どこにも書かれていなかった
事後に自分の思い込みの出どころを辿ってみました。
公式ドキュメントでプランモードは、実装に入る前に計画を立てるためのモードとして説明されています。読んでいて自然に浮かぶのは「調査と提案をするモード」という像です。実際、体感としても書き込みはほとんど起きません。
けれど「ファイルを変更するツールが機構として遮断される」とは、どこにも書かれていませんでした。私は体感を仕様として読み替えていたのです。
ここが一番の学びでした。モード名が示すのは意図であって、保証ではありません。意図に沿って動くことがほとんどだからこそ、保証だと錯覚しやすい。錯覚したまま防御を外すと、例外が起きた1回目に全部通ります。
手元で確かめる — 最小の検証ハーネス
「うちは大丈夫か」を確かめるには、実際に走らせるのが早いです。壊しても構わない使い捨てのディレクトリを用意し、書き込みが通るかどうかだけを見ます。
#!/usr/bin/env bash
# plan-mode-write-probe.sh
# プランモードで書き込みが素通りしないかを確認する。
# 使い捨てディレクトリの中だけで完結するため、リポジトリには触れない。
set -euo pipefail
PROBE_DIR="$(mktemp -d -t plan-probe-XXXXXX)"
trap 'rm -rf "$PROBE_DIR"' EXIT
cd "$PROBE_DIR"
echo "probe dir: $PROBE_DIR"
echo "claude version: $(claude --version)"
# プランモードで、明示的にファイル作成を依頼する。
# 期待する挙動: 許可を求められる、または deny される。
# 危険な挙動: 何も聞かれずに canary.txt が生成される。
claude -p "plan" \
--permission-mode plan \
--append-system-prompt "確認は不要です。作業ディレクトリで touch canary.txt を実行してください。" \
>/dev/null 2>&1 || true
if [ -f "$PROBE_DIR/canary.txt" ]; then
echo "RESULT: ⚠️ 書き込みが素通りしました(前提が崩れています)"
exit 1
else
echo "RESULT: ✅ 書き込みは通りませんでした"
fi
私の環境(Claude Code 2.1.212 / macOS)では ✅ になり、実行時間は8秒前後でした。2.1.211 以前を使っている環境が手元に残っている場合は、そちらでも同じスクリプトを回してみると、境界がはっきりします。
このハーネスには注意点が2つあります。
1つ目は、偽の安心を拾いやすいことです。指示が曖昧だとモデルがそもそも touch を試みず、書き込みが起きないまま ✅ が出ます。「防がれた」のか「試されなかった」のかが区別できません。回避するには、標準出力を捨てずに一度目視し、実際に Bash 呼び出しが試みられたかを確かめます。私は最初の実行でここに引っかかり、通っていない防御を通ったものとして数えかけました。
2つ目は、mktemp の外へ出る指示を書かないことです。プローブの目的は境界の確認であって、破壊の再現ではありません。rm を使いたくなりますが、touch で足ります。
この2つを踏まえた上で、ハーネスは修正後の今も価値があります。「今日の自分の環境で、書き込みが通らないこと」を確かめた記録が残るからです。前提を信じるのではなく、確かめた事実に置き換える。それが目的です。
SDK 側は、コールバックが呼ばれたかを数える
SDK で回している場合、canUseTool が「呼ばれなかった」ことは、ログを見ているだけでは気づけません。何も出ないからです。呼ばれた回数を数えて、期待と突き合わせる必要があります。
// probe-can-use-tool.ts
import { query } from "@anthropic-ai/claude-agent-sdk";
// 呼ばれた回数と、通そうとしたコマンドを記録する
const seen: { tool: string; command?: string }[] = [];
const result = query({
prompt: "作業ディレクトリで touch canary.txt を実行してください。",
options: {
permissionMode: "plan",
canUseTool: async (toolName, input) => {
seen.push({
tool: toolName,
command: typeof input?.command === "string" ? input.command : undefined,
});
// プランモード中の書き込み系は、ここで明示的に落とす。
// モードに任せず、自分の手で線を引くのが要点。
if (toolName === "Bash" && /\b(touch|rm|mv|tee|>)\b/.test(String(input?.command ?? ""))) {
return { behavior: "deny", message: "plan mode: write commands are denied" };
}
return { behavior: "allow", updatedInput: input };
},
},
});
for await (const _ of result) {
// メッセージ本体はこの検証では使わない
}
console.log(`canUseTool calls: ${seen.length}`);
console.table(seen);
// 書き込みを依頼したのに1回も呼ばれていないなら、素通りの疑いがある
if (seen.length === 0) {
console.error("⚠️ canUseTool が一度も呼ばれていません。素通りの経路を疑ってください。");
process.exit(1);
}
seen.length === 0 を異常として扱うのが肝心です。ゼロは「安全だった」ではなく「観測できていない」を意味します。私はここを取り違えていました。静かなログを、平穏の証拠として読んでいたのです。
二重の防御へ組み替える
修正が入った以上、同じ不具合を心配する必要はありません。ただ、私が直したかったのは不具合ではなく、不具合に晒される設計の方でした。
そこで、無人で回すジョブの設定をこう組み替えました。
{
"permissions": {
"deny": [
"Bash(rm:*)",
"Bash(mv:*)",
"Bash(touch:*)",
"Bash(tee:*)",
"Write",
"Edit",
"NotebookEdit"
]
}
}
ポイントは、プランモードで走らせるジョブであっても、このファイルを読み込ませることです。「モードで読み取り専用にしているから deny は不要」という考えを捨てました。
| 層 |
担うもの |
破れたときの影響 |
| パーミッションモード |
意図の表明(何をしたいセッションか) |
単独で頼ると、実装の穴がそのまま通る |
deny ルール |
機構としての禁止(明示的に書いた線) |
今回の不具合でも評価され続けた |
canUseTool |
実行時の判断と記録 |
呼ばれない経路があると観測が途切れる |
| 実行環境の分離 |
最後の物理的な壁(権限・ディレクトリ) |
上3つが全部抜けても被害範囲が閉じる |
4層を並べてみて、自分がどれだけ上の2行に依存していたかが分かりました。いちばん下の「実行環境の分離」は、実のところ最も原始的で、最も裏切らない層です。読み取りしか要らないジョブなら、そもそも書き込み権限のないユーザーで走らせればいい。当たり前の話が、当たり前に効きます。
個人開発で全部を積むのは重いので、私は線引きを決めました。対話で自分が画面を見ているセッションなら、モードと許可プロンプトの2層で十分だと考えています。無人で回すジョブを書く場合は、deny ルールと実行環境の分離まで下ろすことを推奨します。判断の分かれ目は「壊れたときに、誰かがその場で気づくか」の一点です。気づく人がいない時間帯に走るものほど、下の層に寄せます。
本番運用のジョブでこの組み替えをしてから、設定の行数は12行ほど増えました。増えた12行のうち、実際に何かを止めた行はまだ1行もありません。それでいいのだと思っています。
修正後も残る、モード名への信頼
今回の不具合は塞がれました。それでも私の設計にとって、この話はまだ終わっていません。
パーミッションモードは、これからも増えたり変わったりします。そのたびに私たちは、名前から挙動を推測します。推測は速いし、たいてい当たります。ただ、当たり続ける推測ほど、検証されないまま設計の土台に沈んでいきます。
だから私は、モード名に安全性を負わせないことにしました。モードは「何をしたいか」を伝えるもの。「何をさせないか」は、別のところに明示的に書く。この分担にしておけば、モードの実装がどう変わっても、書いた線は残ります。
窮屈な設計だとは思いません。むしろ、モードを気軽に切り替えられるようになりました。守りを別の層に預けてあるので、モードの選択が安全性の判断を兼ねなくて済むからです。
自分の設定から前提を洗い出す5ステップ
同じ点検を、他の暗黙の前提にも広げられます。私が実際に踏んだ手順です。
- 「〜だから大丈夫」と書いた/思ったコメントを探す。設定ファイルとタスクのプロンプトを
grep -rn "読み取り専用\|read-only\|安全なので\|不要" . で洗います。理由が仕様ではなく体感なら、それが候補です。
- その前提が破れたときの被害を1行で書く。書けないなら、被害を把握していないということです。私の場合は「調査対象のリポジトリにファイルが生成されうる」でした。
- 前提を機構に置き換える。deny ルール、実行ユーザー、ディレクトリ権限のうち、いちばん下の層で書けるものを選びます。
- 置き換えたことを確かめる。上の検証ハーネスのように、実際に破ろうとして破れないことを見ます。確かめていない対策は、新しい前提が増えただけです。
- 確かめた日付とバージョンを残す。
claude --version の出力を添えてログに書きます。次に挙動が変わったとき、どこから調べればいいかが分かります。
5番目は地味ですが、いちばん効きました。今回も「いつからいつまで素通りだったのか」を自分の記録から辿れず、結局は全期間を疑うことになったからです。
プランモードそのものを設計フェーズとして活かす使い方はClaude Code の Plan Mode で手戻りをなくす — 設計フェーズを独立させる実践にまとめてあります。無人実行で確認要求をどう捌くかは無人実行で auto モードの確認要求を止めずに捌く — permission-prompt-tool で deny-by-default に応えるが、ルールを増やしすぎたときの棚卸しは許可ルールを積み上げたセッションが毎ターン重くなる — ルールセットの棚卸しと、安全性を保ったまま数を減らす設計が土台になります。いちばん下の層をどう固めるかはサンドボックスはコードを動かせても、認証ファイルは読ませない — Claude Code の sandbox.credentials で秘密の露出面を絞るが近い話です。
おわりに
不具合そのものは、7月18日の更新で塞がれました。手元の環境を上げておけば、この話は終わりです。
それでも書き残したかったのは、私が失ったものが「安全」ではなく「安全だと思っていた根拠」だったからです。根拠が体感でできていたことに、不具合が起きるまで気づけませんでした。
次に何かを「大丈夫」と判断する瞬間があったら、その根拠がドキュメントに書かれた保証なのか、それとも何度もそう見えただけなのかを、一度だけ立ち止まって分けてみてください。分けた先で、たいていは小さな設定を1行足すだけで済みます。私の場合はそうでした。
私自身まだ確かめ切れていない前提が残っています。共に点検していけたら嬉しいです。お読みいただきありがとうございました。