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 件」の中身が Traceback と SyntaxError だったのは、これが理由だ。シェルのエラーではなく、埋め込まれた 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 を占める。これは出力ではなく区切りとして使われている。「=== 次のファイル === と印字してから次を表示する」という、人間向けの整形が入っている。do と done がほぼ同数(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: 付きは再注入されない ── 長いセッションで静かに落ちるのはどちらか、という章です。