この記事は Zenn に投稿したもの の再掲です。内容は同じで、更新も両方に反映します。
Claude Code で「このコマンドを許可しますか」と聞かれて Yes, and don't ask again を押すと、~/.claude/settings.json の permissions.allow に1行足される。押すたびに足される。押した記憶はもう無い。
数えたら152件あった。
そのうち何件が、いま現在も自分を助けているのか。設定ファイルを眺めても分からないので、手元のセッションログにある Bash 呼び出し全件に照合した。
結果は先に書く。
一度でも一致したルール 18 / 133 = 13.5%
形ごとの生存率
接頭辞 14/ 20 = 70.0% が一致 / 一致回数 合計 47,559
ワイルドカード 4/ 21 = 19.0% が一致 / 一致回数 合計 54
完全一致 0/ 92 = 0.0% が一致 / 一致回数 合計 0
完全一致の形で書かれた92件は、10万9762回のコマンド実行に対して、合計0回しか一致していない。
ルールの63%は完全一致の形をしている
まず形を数えた。
=== permissions.allow 152 件の形
完全一致 96 = 63.2%
ワイルドカード 33 = 21.7%
接頭辞 22 = 14.5%
引数なし 1 = 0.7%
Bash(git push:*) のような接頭辞ルールは「git push で始まる何か」に効く。次も効く。
Bash(python -m pytest tests/unit/services/test_a.py tests/unit/services/test_b.py -v --tb=short) のような完全一致ルールは、この文字列そのものが再び来たときだけ効く。
完全一致が63%を占めていた。中身を見るとさらに悪い。
=== 完全一致の Bash ルール 92 件の中身
絶対パスを含む 13 = 14.1%
引数が3語以上 81 = 88.0%
80字を超える 49 = 53.3%
長さ 中央値 87 / 最大 335
中央値87文字。最長335文字。88%が3語以上の引数を持つ。
最悪の1件は Bash(kill 168) だった。PID 168 は二度と来ない。 このルールが再び効く世界線は存在しない。
同じ性質のものが並んでいる。絶対パス直書きの sqlite3 呼び出し。テストファイル名を7個並べた pytest。xattr -l node_modules/electron/dist/...。どれも「その日その瞬間に走ったコマンドそのもの」の形をしている。
なぜそうなるか
承認ダイアログは、いま走ろうとしているコマンドを見せて Yes を押させる。 押した結果として保存されるのは、いま見せたそのコマンドである。当たり前だ。それ以外に保存できるものがない。
つまり permissions.allow に自動で溜まっていくルールは、構造的に、二度と来ない形をしている。次に似たコマンドが来ても、テストファイルが1つ増えただけ、フラグが1つ変わっただけで一致しない。また聞かれる。また Yes を押す。また1行増える。
152件は、そうやって増えた。
検証:ゼロは本物か
「0件」は照合器のバグでも出る。潰した。
設定ファイルに保存されるとき、括弧はエスケープされる。python -c "print\('OK'\)" のように。実際に走ったコマンドにバックスラッシュは無いので、素朴に文字列比較すると必ず外れる。9件が該当した。
そこでエスケープ解除版も一致候補に入れて再照合した。
def matcher(arg):
if arg.endswith(":*"):
pre = arg[:-2]
return lambda c: c == pre or c.startswith(pre + " ")
if "*" in arg:
return lambda c: fnmatch.fnmatchcase(c, arg)
# 設定ファイル側は括弧をエスケープして保存される。
# 素の文字列とエスケープ解除版の両方を許す(偽の不一致を防ぐ)。
cand = {arg, arg.replace("\\(", "(").replace("\\)", ")")}
return lambda c: c in cand
結果は変わらず 0/92 だった。ゼロは照合器の都合ではない。
効いている20件
一方、接頭辞ルールは20件しかないのに、一致の合計が47,559回ある。上位はすべて接頭辞形だった。
一致回数 上位(形と回数のみ)
45502 [接頭辞]
543 [接頭辞]
459 [接頭辞]
441 [接頭辞]
147 [接頭辞]
110 [接頭辞]
この20件は、自動で溜まったものではない。 「また同じことを聞かれる」と気づいて、手で :* の形に書き直したものだ。書き直した記憶がある。
自動で152件溜まり、そのうち手で直した20件だけが働いている。残りは設定ファイルの体積になっている。
数えなかったこと
被覆率は出していない。 出せなかった。
複合コマンド(&& || ; | を含む) 94,778 = 86.3%
コマンドの86%が cd X && ... のような複合形だった。実際の許可判定は複合コマンドを分解して各部分を照合するが、この計測は1本の文字列として扱っている。したがって「何%のコマンドがルールで自動許可されたか」を出すと過大になる。最初に出した43.4%という数字は捨てた。
出せるのは「ルールが一致したか」という、ルール側から見た数だけである。それは分解の影響を受けない(分解すれば一致機会は増えこそすれ減らない。つまり 0件は上限側から見てもゼロ)。
もう一つ。完全一致ルールは「承認した瞬間に作られる」ので、それを生んだコマンド自身とは一致し得ない(もう走り終わっている)。ここで数えているのは「その後もう一度来たか」である。0回とは、一度も来なかったという意味だ。
何をすればいいか
permissions.allow を開いて、* の付いていない行を全部消す。 消しても失うものはない。0回しか効いていない。
そのうえで、消したものの中に「また聞かれると分かっているもの」があれば、接頭辞の形で1行書き直す。pytest を毎回聞かれるなら Bash(python -m pytest:*) の1行で、42件のテスト実行ルールが要らなくなる。
ただしこれは許可範囲を広げる操作である。Bash(git:*) と書けば git push --force も通る。狭く始めて、必要になったら広げるほうがいい。
そして、自動で溜まる分は今後も溜まる。溜まる仕組みが生む形は、再利用できない形だと分かった以上、定期的に捨てる以外にない。
計測に使ったスクリプトを置いておく。集計値と「ルールの形」しか出力しない(許可ルールにはプロジェクト名・パス・接続先が入るため、本文は表示しない)。
#!/usr/bin/env python3
"""~/.claude/settings*.json の permissions.allow が実際に効いたかを、
セッションログの Bash 呼び出し全件に照合して数える。
python3 permission_rules.py
出すのは集計値と「ルールの形」だけ。ルール本文は表示しない
(許可ルールにはプロジェクト名・パス・接続先が入るため)。
"""
import json, os, re, glob, fnmatch, collections, statistics
SETTINGS = ["settings.json", "settings.local.json"]
LOGS = os.path.expanduser("~/.claude/projects/**/*.jsonl")
def load_rules():
out = []
for name in SETTINGS:
p = os.path.expanduser("~/.claude/" + name)
if not os.path.isfile(p):
continue
d = json.load(open(p, encoding="utf-8"))
out += (d.get("permissions") or {}).get("allow", [])
return out
def shape(arg):
"""ルールの形。再利用できる形か、一度きりの形か。"""
if arg.endswith(":*"):
return "接頭辞"
if "*" in arg:
return "ワイルドカード"
return "完全一致"
def matcher(arg):
if arg.endswith(":*"):
pre = arg[:-2]
return lambda c: c == pre or c.startswith(pre + " ")
if "*" in arg:
return lambda c: fnmatch.fnmatchcase(c, arg)
# 設定ファイル側は括弧をエスケープして保存される。
# 素の文字列とエスケープ解除版の両方を許す(偽の不一致を防ぐ)。
cand = {arg, arg.replace("\\(", "(").replace("\\)", ")")}
return lambda c: c in cand
def main():
rules = load_rules()
bash = [m.group(1) for m in (re.match(r"^Bash\((.*)\)$", r, re.S) for r in rules) if m]
by_shape = collections.Counter()
for r in rules:
m = re.match(r"^[A-Za-z_]+\((.*)\)$", r, re.S)
by_shape[shape(m.group(1)) if m else "引数なし"] += 1
print(f"=== permissions.allow {len(rules)} 件の形")
for k, v in by_shape.most_common():
print(f" {k:14} {v:4} = {v / len(rules) * 100:5.1f}%")
lit = [a for a in bash if "*" not in a]
print(f"\n=== 完全一致の Bash ルール {len(lit)} 件の中身")
for k, f in [
("絶対パスを含む", lambda a: "/Users/" in a),
("引数が3語以上", lambda a: len(a.split()) >= 3),
("80字を超える", lambda a: len(a) > 80),
]:
n = sum(1 for a in lit if f(a))
print(f" {k:16} {n:4} = {n / len(lit) * 100:5.1f}%")
print(f" 長さ 中央値 {statistics.median(len(a) for a in lit):.0f} / 最大 {max(len(a) for a in lit)}")
M = [(a, matcher(a)) for a in bash]
hit = collections.Counter()
total = compound = 0
files = glob.glob(LOGS, recursive=True)
for f in files:
with open(f, encoding="utf-8", errors="replace") as fh:
for line in fh:
if '"Bash"' not in line:
continue
try:
d = json.loads(line)
except ValueError:
continue
content = (d.get("message") or {}).get("content")
for b in content if isinstance(content, list) else []:
if not (isinstance(b, dict) and b.get("type") == "tool_use"
and b.get("name") == "Bash"):
continue
c = ((b.get("input") or {}).get("command") or "").strip()
if not c:
continue
total += 1
if re.search(r"&&|\|\||;|\|", c):
compound += 1
for a, fn in M:
if fn(c):
hit[a] += 1
print(f"\n=== ログ {len(files)} ファイル / Bash 呼び出し {total:,} 件に照合")
used = sum(1 for a in bash if hit[a])
print(f"一度でも一致したルール {used} / {len(bash)} = {used / len(bash) * 100:.1f}%")
print("\n形ごとの生存率")
for name in ("接頭辞", "ワイルドカード", "完全一致"):
g = [a for a in bash if shape(a) == name]
if not g:
continue
u = sum(1 for a in g if hit[a])
print(f" {name:14} {u:3}/{len(g):3} = {u / len(g) * 100:5.1f}% が一致"
f" / 一致回数 合計 {sum(hit[a] for a in g):,}")
print("\n一致回数 上位(形と回数のみ)")
for a, n in hit.most_common(8):
print(f" {n:6} [{shape(a)}]")
print(f"\n複合コマンド(&& || ; | を含む) {compound:,} = {compound / total * 100:.1f}%")
print(" 実際の照合は複合を分解して行われる。ここでは1本の文字列として")
print(" 扱っているため、被覆率は算出しない。")
if __name__ == "__main__":
main()
関連
152件ためこんで92件が死んでいた、というのは許可ルールに限った話ではありません。指示ファイルでも同じことが起きていて、そちらは「配ったのに守られない」という形で出ます。効かないルールが増えても実行は止まらないので、数えるまで気づけないところまで同じです。
まず何を削るか、層をどう切るか、効かなくなる4つのパターンの見分け方をまとめた本があります。
→ AIコンテキスト設計ガイド ── 規約を配ったのに守られない理由(2,000円)
第3章「規約が効かなくなる4つのパターン」まで無料で読めます。
/compact の後もルートの CLAUDE.md は再注入されるが、サブディレクトリと paths: 付きは再注入されない ── 長いセッションで静かに落ちるのはどちらか、という章です。 第1章が、この記事と同じ「削る」側の話です。