0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

許可ルールを152件ためこんでいた。完全一致で書かれた92件は、10万9762回の実行で一度も効かなかった

0
Posted at

この記事は Zenn に投稿したもの の再掲です。内容は同じで、更新も両方に反映します。

Claude Code で「このコマンドを許可しますか」と聞かれて Yes, and don't ask again を押すと、~/.claude/settings.jsonpermissions.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個並べた pytestxattr -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章が、この記事と同じ「削る」側の話です。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?