土曜の夜、Claude Code の更新履歴で 2.1.309 の項目を読んでいて、手が止まりました。
権限チェックの修正が、びっくりするほど並んでいたのです。cd を挟んだ git の複合コマンド、Edit(path) の deny ルールとリダイレクト、変数を信用してしまう判定。どれも「ルールを書いた人の想定と、実際に通るものがずれる」種類の修正でした。
ふと、自分のルールはどうなっているのだろうと思いました。サイトの作業フォルダにある .claude/settings.local.json は、定期実行のたびに「今後は確認しない」を選んできたファイルです。最後に中身をきちんと読んだのがいつか、思い出せませんでした。
開いてみると、わずか 429 バイトでした
ファイルは小さく、permissions.allow に 6 件だけ入っていました。目で読めば済む量です。ただ、読んだだけでは「どこが問題か」を人に説明できないので、先に分類するスクリプトを書きました。
許可ルールは、1件ずつ「何が通るか」で数えて初めて見えてきます。
棚卸しスクリプト
公式の説明では、Bash ルールは最初の * より前の文字列が「書いたとおり」に照合されます。Bash(git log *) は git log だけ、Bash(git *) は git の全コマンドです。この性質を使って、危険度を5段階に分けています。
#!/usr/bin/env python3
"""settings(.local).json の permissions.allow を棚卸しして、危険度の高い順に並べる。"""
import json, re, sys
READONLY = {"ls", "cat", "echo", "pwd", "head", "tail", "grep", "wc", "which", "diff", "stat", "du"}
INTERPRETERS = {"python", "python3", "node", "bash", "sh", "zsh", "ruby", "perl",
"deno", "bun", "npx", "uvx", "pwsh", "powershell"}
def classify(rule: str):
m = re.fullmatch(r"(\w+)\((.*)\)", rule, re.S)
if not m:
return ("tool-wide", "括弧なし: そのツールを丸ごと許可")
tool, body = m.groups()
if tool != "Bash":
return ("other", f"{tool} ルール(本スクリプトの対象外)")
if "*" not in body:
return ("exact", "完全一致: 想定どおり")
head = body.split("*", 1)[0] # 最初の * より前は「書いたとおり」に照合される
words = head.split()
prog = words[0] if words else ""
if body.strip() == "*":
return ("critical", "Bash(*) と同じ: 全コマンドを許可")
if prog == "":
return ("critical", "先頭が *: プログラム名で絞れていない")
if prog.rsplit("/", 1)[-1] in INTERPRETERS:
return ("critical", f"インタプリタ {prog} の接頭辞ワイルドカード: * 以降に任意のコードを書ける")
if len(words) < 2 and not head.endswith(" "):
return ("warn", f"{prog}* のように語の途中でワイルドカード")
if len(words) < 2:
if prog in READONLY:
return ("ok", f"{prog} は読み取り専用の組み込み集合")
return ("warn", f"{prog} の全サブコマンドを許可(サブコマンドの後ろに * を置く)")
return ("ok", "プログラム + サブコマンドで絞れている")
ORDER = {"critical": 0, "warn": 1, "tool-wide": 2, "other": 3, "exact": 4, "ok": 5}
def main(path):
allow = json.load(open(path)).get("permissions", {}).get("allow", [])
rows = sorted(((classify(r), r) for r in allow), key=lambda x: ORDER[x[0][0]])
for (level, why), rule in rows:
print(f"{level:9} {rule[:70]!r:74} {why}")
bad = sum(1 for (lv, _), _r in rows if lv in ("critical", "warn", "tool-wide"))
print(f"-- {len(allow)} 件中 {bad} 件が要見直し")
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1]))
書き方に2つ理由があります。ひとつは、終了コードを 0 / 1 にして、定期実行の前段に置けるようにしたこと。もうひとつは、READONLY を例外にしたことです。最初の版では Bash(ls *) まで警告が出てしまい、読むほうが疲れました。公式が読み取り専用として扱うコマンドは、そもそも許可なしで通ります。
実際の結果
自分のファイルに対して python3 -I audit_allow.py を実行した出力です(長い行は切り詰め、桁を揃えて載せています)。
critical "Bash(python3 -I -c ' *)" インタプリタ python3 の接頭辞ワイルドカード: * 以降に任意のコードを書ける
tool-wide 'mcp__scheduled-tasks__list_scheduled_tasks' 括弧なし: そのツールを丸ごと許可
exact 'Bash(git -C claudelab.net remote -v)' 完全一致: 想定どおり
exact 'Bash(sort -k3)' 完全一致: 想定どおり
exact "Bash(sort -t' ' -k2,2n -k1,1n)" 完全一致: 想定どおり
exact "Bash(python3 -I -c 'import json,sys; d=json.load\\(sys.stdin\\); print\\(" 完全一致: 想定どおり
-- 6 件中 2 件が要見直し
6件のうち4件(約67%)は、実際に承認した1コマンドそのままの完全一致でした。これは狙いどおりの形です。要見直しは2件(約33%)でした。
問題は1行目です。python3 -I -c ' で始まれば、その後ろは何でも通ります。-I は import の探索パスを隔離するオプションであって、コードの中身を制限するものではありません。「隔離のフラグが付いているから安全だろう」という印象が、見落としの原因でした。
手で書いた覚えはないので、承認の「今後は確認しない」で保存されたものだと考えています。公式の説明では、Bash の承認は .claude/settings.local.json に保存され、そのリポジトリの以後のセッションすべてに効きます。一度の選択が、期限なしの許可になるわけです。
もう一点、直感に反した発見がありました。この行は広すぎるだけでなく、狭すぎる側でも壊れやすいのです。python3 -c や /usr/bin/python3 -I -c のように書き方が少し違えば、このルールには当たらず、毎回確認が出ます。つまり、危険を残したまま、便利さも中途半端という状態でした。
直し方 — ワイルドカードをやめ、固定スクリプトに寄せます
やりたかったことは、標準入力の JSON から件数を数える、ごく小さな処理でした。そこで、処理をファイルに固定します。
#!/usr/bin/env python3
"""標準入力の JSON を読み、data 配列の件数と has_more を1行で出す。"""
import json, sys
d = json.load(sys.stdin)
print(len(d.get("data", [])), d.get("has_more"))
許可ルールは、このスクリプトに絞ります。
{
"permissions": {
"allow": [
"Bash(python3 -I scripts/count_items.py)"
],
"deny": [
"Edit(scripts/count_items.py)",
"Write(scripts/count_items.py)"
]
}
}
理由を整理します。ルールを完全一致にすると、通るのは「このファイルを実行する」だけになります。ただ、スクリプト自体を書き換えられては意味がないので、deny で編集を止めます。公式の説明では、評価順は deny、ask、allow です。deny に入れたパスは、他のルールがどうであれ書き換えを止められます。
これで便利さは保たれます。承認の画面で止まらず、中身は固定されます。代償は、処理を変えたくなったとき、自分の手でファイルを直す必要があることです。私は、その手間のほうが望ましいと判断しました。
なお、deny は「そのコマンドの書き方」を止めるルールで、セキュリティ境界ではない、とも公式に書かれています。ファイルの書き込み自体を確実に止めたいなら、サンドボックスの側で守るのが本筋です。2.1.309 には、そのサンドボックスの denyWrite がシンボリックリンク経由で効かなかった問題の修正も入っていました。
2.1.309 の更新履歴から「見直す日か」を判断する
全文は読みません。私が使っている見方は、次の3つの言葉です。
2.1.309 では、この3つがすべて該当しました。そこで棚卸しの日にしました。2.1.307 で追加された claude_code.safety_event という OpenTelemetry のイベントは、止められた・確認された呼び出しを記録するものです。こちらは今回は設定しておらず、次の候補としてメモに残しました。
自分の環境で再現する手順
手順はこの順番を推奨します。私の場合は、実行前に必ずコピーを取っています。
.claude/settings.local.json のコピーを別の場所に取ります。直した後に差分を見るためです
python3 -I audit_allow.py .claude/settings.local.json を実行し、critical と warn の行だけを拾います
critical の行は、まず「何をしたくて保存されたのか」を直前の作業ログから思い出します。分からなければ削除して構いません。必要なら、次回の確認で承認し直せます
- 同じ処理を固定スクリプトに移し、完全一致のルールと deny を足します。もう一度スクリプトを実行して、終了コードが 0 になることを確かめます
本番運用で定期実行に載せている場合は、4 のあとに実際の定期実行を1回だけ手動で走らせ、確認が出ずに通るかまで見ておくと、翌朝に止まっている事故を回避できます。
落とし穴
- 完全一致でも安心しきらないこと: 承認した1コマンドがそのまま保存されるため、中に認証情報や絶対パスが入っていることがあります。棚卸しのときに一緒に目で読みます
tool-wide は削除ではなく確認: 今回の MCP の一覧取得のように、読み取りだけのツールは丸ごと許可で構いません。書き込みを伴うツールだけ、絞るかどうかを考えます
- スクリプトの分類は近似: 上のスクリプトは「最初の
* より前」を見るだけで、Claude Code 本体の照合とは同じではありません。最終判断は公式の説明と、実際に確認が出るかどうかで行います
次の一手
今日の作業は3つでした。ファイルを開き、スクリプトで分類し、危険な一行を固定スクリプトに置き換えました。数が6件と少なかったので、ここまで一晩で終わりました。
次は、audit_allow.py を定期実行の前段に置き、終了コードが 1 の日は実行を見送るようにします。関連する考え方は、許可ルールを積み上げたセッションが毎ターン重くなる — ルールセットの棚卸しと、安全性を保ったまま数を減らす設計 や、Claude Code Skill を無人で動かす設計 — 許可ダイアログで止まらない3つの実装パターン にも書いています。
個人開発の作業フォルダは、気づかないうちに承認が積み重なっていきます。更新履歴を読む日は、自分の設定ファイルを開く日でもある、と覚えておくと楽になりました。