個人開発で回している自動投稿パイプラインの settings.json を開いたら、deny の配列が 140 行を超えていました。
一つひとつは覚えがあります。夜間に走らせて肝を冷やしたコマンドを足し、うっかり読まれたくないパスを足し、新しい MCP サーバを入れるたびに念のための行を足す。半年でこうなりました。
そして、そのセッションはどれも「なんとなく重い」のです。原因が分からないまま、モデルが遅いのだろうと片付けていました。
2.1.209 のリリースノートに、その正体が書いてありました。deny/ask ルールが多いセッションで、毎ターン数秒遅くなる問題が修正された、と。マッチャをコンパイルしてキャッシュするようになったそうです。安全側に倒すつもりで積み上げた設定が、そのまま速度を削っていたことになります。
修正で消えたコストと、手元に残るコスト
まず切り分けておきたいことがあります。2.1.209 で消えたのは実行時の再コンパイル であって、ルールセットの設計負債 ではありません。
コストの種類 2.1.209 以降 誰の責任か
ルール文字列のマッチャ化(毎ターン) キャッシュされ解消 Claude Code 側
MCP ツールプールの組み立て(毎ラウンド) キャッシュされ最大7倍速 Claude Code 側
ルール数そのもの(照合の母数) 変わらず線形に増える 私たち
被覆・矛盾したルールの判断ゆれ 変わらず残る 私たち
接続する MCP サーバの本数 変わらず増える 私たち
上の2行はアップデートで受け取れます。下の3行は、こちらが片付けないかぎり残ります。
しかも厄介なことに、コンパイルコストが消えたおかげで「重さ」の体感は薄れます。痛みが消えると、負債は静かに増えていきます。今のうちに数えておくのが得だと考えました。
まず数える — ルールセットの棚卸し
推測で削るのは危険なので、機械に数えさせます。settings.json を読んで、ルール数・重複・ツール別の分布を出すスクリプトです。
#!/usr/bin/env python3
"""audit_permissions.py — settings.json のパーミッションルールを棚卸しする。
使い方:
python3 audit_permissions.py ~/.claude/settings.json .claude/settings.json
"""
import json
import re
import sys
from collections import Counter, defaultdict
from pathlib import Path
BUCKETS = ( "allow" , "ask" , "deny" )
# "Bash(rm -rf:*)" → ("Bash", "rm -rf:*") / "Read" → ("Read", None)
RULE_RE = re.compile( r " ^( ?P<tool> [ A-Za-z_ ][\w - ] * )(?: \( ( ?P<arg> . * ) \) ) ? $ " )
def parse_rule (rule: str ):
m = RULE_RE .match(rule.strip())
if not m:
return None , rule.strip()
return m.group( "tool" ), m.group( "arg" )
def load (path: Path) -> dict :
if not path.exists():
print ( f " skip (not found): { path } " )
return {}
with path.open( encoding = "utf-8" ) as fh:
return json.load(fh)
def audit (paths):
total = Counter()
by_tool = defaultdict( lambda : defaultdict( list ))
seen = defaultdict( list ) # rule -> [(bucket, file), ...]
for p in paths:
data = load(Path(p).expanduser())
perms = data.get( "permissions" , {})
for bucket in BUCKETS :
for rule in perms.get(bucket, []):
total[bucket] += 1
tool, arg = parse_rule(rule)
by_tool[tool][bucket].append(arg)
seen[rule].append((bucket, str (p)))
print ( "=== 合計 ===" )
for b in BUCKETS :
print ( f " { b :5s } : { total[b] :4d } " )
print ( f " total: { sum (total.values()) } " )
print ( " \n === ツール別(多い順) ===" )
rows = sorted (by_tool.items(), key =lambda kv: - sum ( len (v) for v in kv[ 1 ].values()))
for tool, buckets in rows:
counts = " " .join( f " { b } = { len (buckets[b]) } " for b in BUCKETS if buckets[b])
print ( f " { tool :28s } { counts } " )
print ( " \n === 完全重複・バケット衝突 ===" )
dup = 0
for rule, hits in seen.items():
if len (hits) < 2 :
continue
dup += 1
buckets = {b for b, _ in hits}
mark = "⚠ 衝突" if len (buckets) > 1 else "重複"
print ( f " [ { mark } ] { rule } " )
for b, f in hits:
print ( f " { b :5s } ← { f } " )
if dup == 0 :
print ( " なし" )
return sum (total.values())
if __name__ == "__main__" :
args = sys.argv[ 1 :] or [ "~/.claude/settings.json" , ".claude/settings.json" ]
n = audit(args)
sys.exit( 0 if n else 1 )
私の環境で走らせたところ、deny 141・ask 22・allow 63 の計 226 でした。完全重複が 9 件、ユーザー設定とプロジェクト設定でバケットが食い違っている衝突が 2 件。衝突の 2 件は、プロジェクト側で allow に入れたものをユーザー側で deny していたもので、動作としては安全側に倒れていたため気づけていませんでした。
数える前に「たぶん 60 行くらい」と見積もっていたので、桁が違いました。ここが出発点になります。
被覆されたルールを見つける
重複より効くのが被覆 です。Bash(rm -rf:*) を deny しているのに、Bash(rm -rf /tmp/cache:*) を別途 deny している、というような行。後者は前者に飲み込まれているので、照合の母数を増やすだけの存在です。
グロブの包含関係を厳密に判定するのは難しいので、実用的な近似に留めます。同じバケット・同じツールの中で、あるパターンが別のパターンの接頭辞になっているかを見ます。
#!/usr/bin/env python3
"""find_shadowed.py — 同一バケット内で他のルールに被覆されたルールを検出する。
使い方:
python3 find_shadowed.py ~/.claude/settings.json
"""
import json
import re
import sys
from collections import defaultdict
from pathlib import Path
RULE_RE = re.compile( r " ^( ?P<tool> [ A-Za-z_ ][\w - ] * )(?: \( ( ?P<arg> . * ) \) ) ? $ " )
def literal_prefix (pattern: str ) -> str :
"""グロブの手前までのリテラル部分を返す。'rm -rf:*' → 'rm -rf:'"""
out = []
for ch in pattern:
if ch in "*?[" :
break
out.append(ch)
return "" .join(out)
def covers (broad: str , narrow: str ) -> bool :
"""broad が narrow を飲み込んでいそうなら True(保守的な近似)。"""
if broad == narrow:
return False
if not broad.endswith( "*" ):
return False # 末尾ワイルドカードのない広域ルールは判定しない
bp = literal_prefix(broad)
np = literal_prefix(narrow)
if not bp:
return True # "*" は全部を飲む
return np.startswith(bp)
def main (path: str ):
data = json.loads(Path(path).expanduser().read_text( encoding = "utf-8" ))
perms = data.get( "permissions" , {})
found = 0
for bucket, rules in perms.items():
groups = defaultdict( list )
for rule in rules:
m = RULE_RE .match(rule.strip())
if not m or m.group( "arg" ) is None :
continue
groups[m.group( "tool" )].append((rule, m.group( "arg" )))
for tool, items in groups.items():
for broad_rule, broad_arg in items:
for narrow_rule, narrow_arg in items:
if covers(broad_arg, narrow_arg):
found += 1
print ( f "[ { bucket } ] { narrow_rule } " )
print ( f " ↑ { broad_rule } に被覆されています" )
print ( f " \n 被覆候補: { found } 件" )
print ( "※ 近似判定です。削除前に必ず1件ずつ目視で確認してください。" )
return found
if __name__ == "__main__" :
sys.exit( 0 if main(sys.argv[ 1 ] if len (sys.argv) > 1 else "~/.claude/settings.json" ) == 0 else 0 )
この場合は covers() を保守的に寄せておくことを推奨します。末尾がワイルドカードでない広域ルールは判定対象から外し、リテラル接頭辞だけで包含を見ます。グロブの意味論を完全に再現しようとすると、判定を誤って安全な行を消す事故につながります。見逃しは許容し、誤検出は許容しない という向きに倒しました。
私の 141 件の deny のうち、被覆候補は 34 件でした。目視で確認して、実際に消せたのは 28 件です。残り 6 件は、Bash(git push:*) と Bash(git push --force:*) のように、広い方が ask で狭い方が deny という意図的な組み合わせでした。バケットが違うので今回の検出には出ませんでしたが、こうした対は残す価値があります。
ターンあたりのオーバーヘッドを測る
削る前と後で、本当に変わったのかを見たいところです。ルール数だけを変えた比較なら、claude -p で最小のプロンプトを何度か回すのが手軽です。
#!/usr/bin/env bash
# bench_turn.sh — ルールセットを差し替えてターン時間を比較する。
#
# ./bench_turn.sh before.json after.json 10
#
set -euo pipefail
SETTINGS_A = " ${1 :? 比較元の settings . json } "
SETTINGS_B = " ${2 :? 比較先の settings . json } "
RUNS = " ${3 :- 10} "
PROMPT = 'Reply with exactly: ok'
run_set () {
local label = " $1 " settings = " $2 "
local times = ()
for i in $( seq 1 " $RUNS " ); do
local start end
start = $( date +%s%N )
CLAUDE_CONFIG_DIR = "$( mktemp -d )" \
claude -p " $PROMPT " \
--settings " $settings " \
--model haiku \
> /dev/null 2>&1 || true
end = $( date +%s%N )
times += ( $(( (end - start) / 1000000 )) )
done
# 中央値で比較する(初回のウォームアップに引きずられないため)
local sorted median
sorted = $( printf '%s\n' "${ times [ @ ]}" | sort -n )
median = $( printf '%s\n' " $sorted " | awk '{a[NR]=$1} END {print (NR%2) ? a[(NR+1)/2] : int((a[NR/2]+a[NR/2+1])/2)}' )
printf '%-10s median=%5s ms min=%5s ms max=%5s ms n=%s\n' \
" $label " " $median " "$( printf '%s\n' " $sorted " | head -1 )" \
"$( printf '%s\n' " $sorted " | tail -1 )" " $RUNS "
echo " $median "
}
echo "=== ルール数 ==="
for f in " $SETTINGS_A " " $SETTINGS_B " ; do
n = $( python3 -c "
import json,sys
p=json.load(open(' $f ')).get('permissions',{})
print(sum(len(v) for v in p.values()))
" )
printf ' %-40s %s rules\n' "$( basename " $f ")" " $n "
done
echo
MED_A = $( run_set "before" " $SETTINGS_A " | tail -1 )
MED_B = $( run_set "after" " $SETTINGS_B " | tail -1 )
echo
awk -v a=" $MED_A " -v b=" $MED_B " 'BEGIN {
d = a - b
printf "差分: %+d ms (%.1f%%)\n", -d, (d / a) * 100
}'
CLAUDE_CONFIG_DIR を毎回使い捨てのディレクトリに向けている点と、中央値で比べている点が、この手の計測では効きます。前者は前回のセッション状態を持ち込まないため、後者は初回のウォームアップに平均を引っ張られないためです。
なぜ --model haiku を指定するかというと、測りたいのはモデルの推論時間ではなく、その手前のセッション組み立てだからです。応答が短く速いモデルほど、オーバーヘッドの比率が見えやすくなります。
私の手元(226 ルール → 154 ルール)では、中央値で 90ms ほどの差でした。2.1.209 のキャッシュが効いた後なので、この程度に収まるのは順当です。逆に言えば、2.1.209 に上げていない環境で数百のルールを持っている場合、ここが数秒として現れます 。まずバージョンを上げるのが最優先で、棚卸しはその次、という順番になります。
列挙型から接頭辞型へ畳み込む
数を減らす、といっても、穴を開けては本末転倒です。私が実際にやったのは、列挙をやめて、接頭辞で一段広く塞ぎ、必要な例外だけ allow で開ける という書き換えでした。
Before は、思いついた順に足した列挙です。
{
"permissions" : {
"deny" : [
"Bash(rm -rf /:*)" ,
"Bash(rm -rf ~:*)" ,
"Bash(rm -rf /tmp:*)" ,
"Bash(rm -rf /var:*)" ,
"Bash(rm -rf ./node_modules:*)" ,
"Read(./.env)" ,
"Read(./.env.local)" ,
"Read(./.env.production)" ,
"Read(./config/secrets.yml)" ,
"Read(./config/master.key)" ,
"Bash(curl:*)" ,
"Bash(wget:*)"
]
}
}
After は、広く塞いでから例外を開けます。
{
"permissions" : {
"deny" : [
"Bash(rm -rf:*)" ,
"Read(./.env*)" ,
"Read(./config/*.key)" ,
"Read(./config/secrets*)" ,
"Bash(curl:*)" ,
"Bash(wget:*)"
],
"allow" : [
"Bash(rm -rf ./node_modules)" ,
"Bash(rm -rf ./.next)" ,
"Read(./.env.example)"
]
}
}
12 行が 9 行になっただけに見えますが、意味が変わっています。Before は列挙した 5 つのパスしか塞げていないので、rm -rf /usr は素通りでした。After は rm -rf を丸ごと塞いだうえで、日常的に使う 2 つだけを名指しで開けています。行数が減って、かつ安全になりました 。
この書き換えで気をつけた点が3つあります。
1つ目は、allow に入れる例外をワイルドカードなしの完全一致 にしたことです。Bash(rm -rf ./node_modules:*) と書いてしまうと、rm -rf ./node_modules; rm -rf / のような後続が通る余地を残します。例外は狭いほど良いという原則です。
2つ目は、Read(./.env*) が .env.example まで巻き込むため、明示的に allow へ逃がしたことです。広く塞ぐ設計は、こういう「巻き込み」を必ず生みます。ここが落とし穴で、私は最初、本番運用に入っているパイプラインで先に畳んでしまい、夜間の実行が .env.example の読み取りで止まりました。回避策は単純で、塞いだ直後に日常の作業を一度手元で通し、止まったものだけを allow へ移すことです。いきなり無人の枠へ入れないほうが安全でした。
3つ目は、書き換えの前後で find_shadowed.py をもう一度走らせた ことです。畳み込んだ結果、新しい被覆が生まれていないかの確認になります。
無人実行での deny-by-default の組み立て方は、無人実行で auto モードの確認要求を止めずに捌く — permission-prompt-tool で deny-by-default に応える で扱っています。ルールの中身そのものの設計は、Claude Code のツール権限を自分好みに設定する:安全性と開発効率を両立させる が入口として読みやすいはずです。
MCP ツールの本数にも同じコストが乗っている
パーミッションルールを削っても腑に落ちない重さが残る場合、ツールプールの側を疑う番です。
2.1.209 では print/SDK セッションでのツールプール組み立てがキャッシュされ、ツール数が多いほど効きが大きく、最大7倍速とされています。裏を返せば、ツール数が多いほど、そこにコストがあった ということです。キャッシュはコストを消すのではなく、一度きりに畳んでくれるだけです。開いているツールの本数そのものは、こちらが決めています。
まず、自分が何本のツールを開いているかを数えます。
#!/usr/bin/env bash
# count_mcp_tools.sh — 接続中の MCP サーバとツール本数を数える。
set -euo pipefail
echo "=== 設定ファイル上のサーバ ==="
for f in " $HOME /.claude.json" ".mcp.json" ; do
[ -f " $f " ] || continue
python3 - " $f " << 'PY'
import json, sys
from pathlib import Path
p = Path(sys.argv[1])
data = json.loads(p.read_text(encoding="utf-8"))
servers = data.get("mcpServers") or {}
# ~/.claude.json はプロジェクトごとに入れ子になっていることがある
for proj, cfg in (data.get("projects") or {}).items():
for name in (cfg.get("mcpServers") or {}):
servers.setdefault(f"{name} ({proj})", {})
print(f" {p}: {len(servers)} servers")
for name in sorted(servers):
print(f" - {name}")
PY
done
echo
echo "=== 実際に開いているツール本数 ==="
claude mcp list 2> /dev/null | sed 's/^/ /' || echo " (claude mcp list が取得できませんでした)"
私の場合、プロジェクトを問わず全サーバをユーザー設定に入れていたため、記事執筆用のセッションにまでデプロイ系のサーバがぶら下がっていました。使わないのに毎回組み立てられているぶんは、素直に無駄です。
対処はシンプルで、ユーザー設定にはどのプロジェクトでも使うものだけを置き、残りはプロジェクト直下の .mcp.json へ移す というものでした。
{
"mcpServers" : {
"deploy" : {
"command" : "npx" ,
"args" : [ "-y" , "@example/deploy-mcp" ],
"env" : { "DEPLOY_TOKEN" : "${DEPLOY_TOKEN}" }
}
}
}
env に実トークンを直書きせず ${DEPLOY_TOKEN} の形で参照している点は、設定ファイルをリポジトリへコミットできるようにするためです。プロジェクトごとにサーバを分ける設計の詳細は、無人で動くエージェントに渡すMCPツールを絞り込む — ポリシー強制で権限を deny-by-default にする にまとめています。
状況別に、どこから手をつけるか
全部を一度にやる必要はありません。私自身が使っている判断の目安で、上から順に効きます。
状況 最初にやること 期待できること
2.1.209 より前を使っている まずアップデート ルール数由来の毎ターン数秒が消えます
ルールが 100 を超えている audit_permissions.py で棚卸し重複・衝突が可視化されます
deny を足す一方だった find_shadowed.py → 列挙を接頭辞へ行数が減り、かつ穴が塞がります
MCP サーバが 5 本以上 プロジェクト単位へ分離 不要なツールの組み立てが消えます
それでも重い 常駐メモリの蓄積を疑う 別の出どころが見つかります
最終行の「別の出どころ」については、夜間セッションの常駐メモリが朝には数GB — 蓄積の四つの出どころと、RSS を刻んで切り分ける で切り分けの手順を書きました。パーミッションを削っても変わらない重さは、たいていそちらの話です。
今日できる最小の一歩
audit_permissions.py を一度走らせて、数を見るところまでです。それだけで十分だと考えています。
私の見積もりは 60 で、実際は 226 でした。この差が、そのまま設計の空白でした。削るかどうかは数字を見てから決めればよく、見ないまま「たぶん大丈夫」と思っている状態が一番もったいないと感じています。
安全のために足した設定が、いつのまにか自分の速度を削っていた。この構図は、パーミッションに限らず、たぶんいろいろな場所にあります。私自身まだ棚卸しの途中ですが、数えてみるだけで見え方が変わりました。お読みいただきありがとうございました。