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?

エージェントが打ったシェル6,419件を測ったら、それは「コマンド」ではなかった

0
Posted at

3行で

  • 自分のセッションログから Claude Code が実行した Bash コマンド 6,419 件を取り出して形を測った。
  • **中央値 242 文字。80 文字以内に収まるのは 11.0%。26% はシェルではなく別言語のプログラムを流し込んでいる。**残りの純シェルも 91.3% が複合で、断片数の中央値は 5、最大 60。
  • コマンド名で承認や監査をする仕組みは、この形の入力を前提にしていない。前方一致の許可リストは 13.2% を素通しさせる

動機

前の記事で、ツール失敗 283 件のうち最多の原因がハーネスの承認ゲートだったことを書いた。承認は「コマンド」を単位に判定している。ではエージェントが渡している「コマンド」とは、実際にどういう形をしているのか。

人間がターミナルに打つものを想像していたら、まったく違った。

測り方

Claude Code は ~/.claude/projects/**/*.jsonl にセッションを残す。Bash 呼び出しは type: "tool_use" かつ name: "Bash" のブロックで、実行内容は input.command に文字列で入っている。

if blk.get("type") == "tool_use" and blk.get("name") == "Bash":
    cmd = (blk.get("input") or {}).get("command")

これを全部集めて、長さ・区切り記号・先頭語を数える。スクリプトは末尾に置いた。実行するとこう出る。

Bash コマンド 6,419 件
  別言語を埋め込んでいる   1661 =  25.9%
  シェルだけ               4758 =  74.1%
  python を標準入力へ       709  中央値 886 字 / 最大 13490 字

長さ: 中央値 242 / p90 1323 / p99 4795 / 最大 14489 字
      80字以内 708 = 11.0%

埋め込みの無い 4,758 件について
  複合(断片2個以上) 4345 = 91.3%
  断片数 中央値 5 / 平均 6.3 / 最大 60
  断片の先頭語 異なり 3,758 種 / 総数 30,176
  上位12: echo:4950, head:3092, grep:2275, cd:2128, ls:1475, git:1380, cat:1021, sed:805, gh:613, find:606, do:574, done:570

  許可リスト14語で判定すると
    先頭語だけ見る 2018 = 42.4%
    全断片を見る   1389 = 29.2%
    差 629 = 13.2% が前方一致なら素通しになる

4件に1件は、シェルですらない

1,661 件(25.9%)は、シェルの構文の中に別の言語のプログラムを丸ごと入れている。ヒアドキュメント、あるいは python3 -c

そのうち 709 件が python3 - << の形で、Python を標準入力から流し込んでいる。中央値 886 文字、最大 13,490 文字。

これは「シェルコマンドを実行した」と呼べる規模ではない。実態は、その場で書いた 886 文字のプログラムを、シェルを配管として提出している。

この事実の帰結が2つある。

1. シェルレベルの検査は中身を見られない。 python3 - <<'PY' の外側だけを見れば、これは python3 を呼ぶ無害なコマンドに見える。886 文字の中身は文字列リテラルの向こう側にある。

2. 失敗の出方が変わる。 前の記事で「実行時エラー 32 件」の中身が TracebackSyntaxError だったのは、これが理由だ。シェルのエラーではなく、埋め込まれた Python のエラーが返ってくる。

純シェルの側も、1行ではない

別言語を含まない 4,758 件だけを見ても、91.3% が複合だった。断片数の中央値は 5。最大 60。

上位語の並びが実態をよく表している。

echo:4950, head:3092, grep:2275, cd:2128, ls:1475, git:1380,
cat:1021, sed:805, gh:613, find:606, do:574, done:570

echo が最多で、断片総数 30,176 のうち 4,950 を占める。これは出力ではなく区切りとして使われている。「=== 次のファイル === と印字してから次を表示する」という、人間向けの整形が入っている。dodone がほぼ同数(574 / 570)で並んでいるのは、for ループが日常的に書かれているということだ。

つまりエージェントは、1回の呼び出しで調査手順をまるごと1本のスクリプトとして提出している。ディレクトリを見て、見出しを出して、ループで各ファイルの先頭を表示して、最後に集計する。人間なら5回に分けて打つものが1件になる。

理由は分かる。1回の呼び出しに往復のコストがかかるなら、まとめたほうが速い。合理的な最適化だ。ただ、この形を前提にしていない仕組みが周りにある。

許可リストが機能しない

読み取り系の 14 語 — ls cat head tail wc grep find echo pwd git python3 rg sed awk — を許可リストにしたとする。

  • 先頭語だけを見て判定すると 2,018 件(42.4%)が通る。
  • 全断片を見て判定すると 1,389 件(29.2%)しか通らない。
  • 差の 629 件(13.2%)は、前方一致では通るのに、中に許可外のコマンドを含んでいる。

これは実装の甘さの話ではなく、規模の話でもある。断片の先頭語は 3,758 種あり、そのうち 2,815 種が1回しか出てこない。上位何語で覆えるかを見ると、こうなる。

上位N語 断片のカバー率
10 60.8%
20 71.4%
50 77.3%
100 81.0%
500 87.7%

500 語を列挙しても 12.3% が残る。 許可リストは、この分布に対しては原理的に追いつかない。上位 10 語で 6 割を覆えるのは効率がいいように見えるが、残り 4 割が 3,748 種に散っている。

私はこの数字を出すのにパーサを3回書き直した。それでもまだ間違っている

最初は \n | && ; で単純に分割した。ヒアドキュメントの本文が全部「断片」として数えられ、異なり語数が 22,394 になった。Python のコード行を「シェルコマンド」として数えていた。

次にヒアドキュメントの本文を落とした。異なり語数は 5,601 に下がったが、まだ import def print( が上位に出ていた。終端タグの検出が甘く、拾いきれていない。

3回目で、埋め込みを含む呼び出しを分析対象から丸ごと外した。それが上の 4,758 件。それでもまだ漏れている。

清浄集合に漏れている Python の予約語: {'def': 50, 'class': 22, 'from': 3, 'import': 1} 合計 76
全断片 30176 に対し 0.25%

0.25% なので結論は動かない。動かないが、残っていることが重要だ。

シェルの文法は正規表現で分解できない。クォート、ネスト、コマンド置換、ヒアドキュメント、$() の中のさらなるクォート。私は3回試して、それでも 76 個の Python 予約語をシェルコマンドとして数えている。

そして承認や監査の仕組みは、まさにこれをやっている。前の記事で見た This Bash command contains multiple operations. The following part requires approval: という判定は、この分解の上に成り立っている。分解が近似である以上、判定も近似になる。

これは Claude Code の欠陥という話ではない。シェル文字列を単位に権限を判定するという設計そのものが、この入力の形に対して無理をしているという話だ。

実務としてどうするか

計測から言えることは3つ。

1. 許可リストで安全性を担保しない。 前方一致は 13.2% を素通しさせ、完全一致にしても 3,758 種の語彙には追いつかない。許可リストは「よくある操作を毎回聞かれないようにする利便性の道具」であって、境界ではない。境界はサンドボックスや作業ディレクトリの制限のような、シェル文字列を解釈しなくても効く層に置く。

2. 埋め込みプログラムは、埋め込まずにファイルへ出す。 886 文字の Python をヒアドキュメントで流すと、検査も差分も再実行もできない。ファイルに書いてから python3 file.py で呼べば、シェル側からは1コマンドになり、中身は普通のコードとしてレビューできる。私自身、この記事のスクリプトは全部ファイルに置いている。

3. 長い複合コマンドは、失敗の切り分けを潰す。 断片が5個あって exit≠0 のとき、どれが落ちたのかは出力から読むしかない。前の記事で見た「出力はあるのに exit≠0」の 60 件は、全部この形だった。

この記事の限界

  • 私1人のコーパスである。6,419 件は 35 プロジェクト分で、作業の内容(ログ解析やテキスト処理が多い)が語彙の分布に直接効いている。ビルドやデプロイが中心の環境ではまったく違う形になる。
  • 断片の分解は近似である。上に書いたとおり、0.25% の漏れが残っている。比率の議論には耐えるが、個別の数字を厳密なものとして扱わないでほしい。
  • 許可リスト 14 語は私が例として決めたもので、実在の設定ではない。「前方一致と完全判定でこれだけ差が出る」という構造を示すための例。
  • コマンドが実際に何をしたかは見ていない。形だけの分析であって、危険度の分析ではない。

自分のログで測る

#!/usr/bin/env python3
"""Claude Code のセッションログから Bash コマンドを取り出し、形を測る。

    python3 bash_shape.py [~/.claude/projects]
"""
import sys, os, glob, json, re, collections, statistics

# シェルの外側の言語を埋め込んでいる呼び出し
EMBED = re.compile(r"<<|(?:python3?|node|perl|ruby|uv run)\s+-[ce]\b")
# 承認の例として使う、読み取り系の許可リスト
ALLOW = {"ls", "cat", "head", "tail", "wc", "grep", "find",
         "echo", "pwd", "git", "python3", "rg", "sed", "awk"}


def commands(root):
    """全セッションから Bash ツールに渡された command 文字列を集める。"""
    for path in glob.glob(os.path.join(root, "**", "*.jsonl"), recursive=True):
        with open(path, encoding="utf-8", errors="replace") as fh:
            for line in fh:
                try:
                    ev = json.loads(line)
                except ValueError:
                    continue
                content = (ev.get("message") or {}).get("content")
                if not isinstance(content, list):
                    continue
                for blk in content:
                    if (isinstance(blk, dict) and blk.get("type") == "tool_use"
                            and blk.get("name") == "Bash"):
                        cmd = (blk.get("input") or {}).get("command")
                        if cmd:
                            yield cmd


def frags(cmd):
    """区切り記号で割って、各断片の先頭語を返す。

    これは近似でしかない。クォートもヒアドキュメントも解釈していないので、
    埋め込まれた別言語の行が「断片」として数えられることがある。
    """
    return [s.strip().split()[0]
            for s in re.split(r"\n|\||&&|;", cmd) if s.strip()]


def main():
    root = os.path.expanduser(sys.argv[1] if len(sys.argv) > 1 else "~/.claude/projects")
    all_cmds = list(commands(root))
    if not all_cmds:
        print("Bash コマンドが見つからない。パスを確認する。")
        return 1

    embed = [c for c in all_cmds if EMBED.search(c)]
    clean = [c for c in all_cmds if not EMBED.search(c)]
    py_stdin = [c for c in all_cmds if re.search(r"python3?\s+-\s*<<", c)]

    n = len(all_cmds)
    print(f"Bash コマンド {n:,}")
    print(f"  別言語を埋め込んでいる  {len(embed):>5} = {len(embed) / n * 100:>5.1f}%")
    print(f"  シェルだけ              {len(clean):>5} = {len(clean) / n * 100:>5.1f}%")
    if py_stdin:
        lens = [len(c) for c in py_stdin]
        print(f"  python を標準入力へ     {len(py_stdin):>5}"
              f"  中央値 {statistics.median(lens):.0f} 字 / 最大 {max(lens)}")

    lens = sorted(len(c) for c in all_cmds)
    pick = lambda p: lens[min(int(len(lens) * p), len(lens) - 1)]
    print(f"\n長さ: 中央値 {statistics.median(lens):.0f} / p90 {pick(.90)} "
          f"/ p99 {pick(.99)} / 最大 {max(lens)}")
    short = sum(1 for x in lens if x <= 80)
    print(f"      80字以内 {short} = {short / n * 100:.1f}%")

    # 以降は、埋め込みの無い部分集合だけを見る
    F = [frags(c) for c in clean]
    m = len(clean)
    cnt = [len(f) for f in F]
    comp = sum(1 for x in cnt if x > 1)
    print(f"\n埋め込みの無い {m:,} 件について")
    print(f"  複合(断片2個以上) {comp} = {comp / m * 100:.1f}%")
    print(f"  断片数 中央値 {statistics.median(cnt):.0f} / 平均 {sum(cnt) / m:.1f} "
          f"/ 最大 {max(cnt)}")

    u = collections.Counter()
    for f in F:
        u.update(f)
    print(f"  断片の先頭語 異なり {len(u):,} 種 / 総数 {sum(u.values()):,}")
    print("  上位12: " + ", ".join(f"{k}:{v}" for k, v in u.most_common(12)))

    first = sum(1 for f in F if f and f[0] in ALLOW)
    full = sum(1 for f in F if f and all(t in ALLOW for t in f))
    print(f"\n  許可リスト{len(ALLOW)}語で判定すると")
    print(f"    先頭語だけ見る {first} = {first / m * 100:.1f}%")
    print(f"    全断片を見る   {full} = {full / m * 100:.1f}%")
    print(f"{first - full} = {(first - full) / m * 100:.1f}% が前方一致なら素通しになる")
    return 0


if __name__ == "__main__":
    sys.exit(main())

まとめ

エージェントが Bash ツールに渡しているのは、コマンドではなくプログラムだった。

  • 中央値 242 文字。1行に収まるのは 11.0%。
  • 25.9% はシェルの中に別言語を丸ごと入れている。Python の場合、中央値 886 文字。
  • 純シェルでも 91.3% が複合。断片の中央値は 5。
  • 先頭語は 3,758 種、うち 2,815 種は1回きり。上位 500 語でも 87.7% しか覆えない。
  • コマンド名の前方一致で判定すると、13.2% が中身を見られずに通る。

「どのコマンドを許可するか」という問いの立て方が、入力の形に合っていない。境界は、シェル文字列を解釈しなくても効く場所に置く。


関連

「境界は、シェル文字列を解釈しなくても効く場所に置く」と書きました。規約もまったく同じで、書いた内容ではなく置いた場所で効き目が決まります。文書に書いただけの規則は、ここでの前方一致と同じく素通りします。

どの層に何を置くかと、効かなくなる4つのパターンをまとめた本があります。

AIコンテキスト設計ガイド ── 規約を配ったのに守られない理由(2,000円)

第3章「規約が効かなくなる4つのパターン」まで無料で読めます。
/compact の後もルートの CLAUDE.md は再注入されるが、サブディレクトリと paths: 付きは再注入されない ── 長いセッションで静かに落ちるのはどちらか、という章です。

0
0
1

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?