Claude Media
Bashツールのallowlist実装が防げない抜け道 — 公式サンプルの限界

Bashツールのallowlist実装が防げない抜け道 — 公式サンプルの限界

AnthropicのBashツール実装ガイドにあるallowlist検証コードは、演算子が単語にくっつくと素通りする。公式が結論として置く「本当の防御線は隔離」の中身を読み解く。

Bashツールのallowlist検証は何のためにあるか

Bashツール(bash_20250124)はクライアントツールです。Claudeはコマンドを直接実行しません。tool_use ブロックで実行したいコマンド文字列を返し、実際に動かすのは呼び出し側のアプリケーションです。アプリケーションは1つのbashプロセスを維持し、作業ディレクトリや環境変数、実行結果をターン間で持ち越します。

Claudeが要求するコマンドをそのまま実行する以上、検証なしでは何でも実行できてしまいます。Agent SDKでカスタムツールを自作するときと同じく、実行の中身をどこまで検証するかは呼び出し側の設計次第です。Anthropicの実装ガイドは4ステップの最後に「Implement safety measures」を置き、次の方針を明記しています。

Use an allowlist rather than a blocklist: a blocklist misses any command it didn't anticipate.

ブロックリストではなく許可リストを使う理由は単純です。危険なコマンドを列挙する方式は、列挙し忘れたコマンドを素通しします。許可リストなら「想定した安全なコマンドだけ」に絞り込めるはずでした。

allowlist実装の中身 — 演算子はどう弾かれるか

サンプルの検証関数は3段構えです。Python版で見ると次のようになります。

import shlex
 
ALLOWED_COMMANDS = {"ls", "cat", "echo", "pwd", "grep", "find", "wc", "head", "tail"}
SHELL_OPERATORS = {"&&", "||", "|", ";", "&", ">", "<", ">>"}
 
 
def validate_command(command):
    # Allow only commands from an explicit allowlist
    try:
        tokens = shlex.split(command)
    except ValueError:
        return False, "Could not parse command"
 
    if not tokens:
        return False, "Empty command"
 
    executable = tokens[0]
    if executable not in ALLOWED_COMMANDS:
        return False, f"Command '{executable}' is not in the allowlist"
 
    # Reject shell operators written as separate words
    for token in tokens[1:]:
        if token in SHELL_OPERATORS or token.startswith(("$", "`")):
            return False, f"Shell operator '{token}' is not allowed"
 
    return True, None
手順

validate_commandの3段

  1. 1

    ① 単語に分割する

    shlex.split でコマンド文字列をトークンにします。パースに失敗した文字列と空文字列は、ここで拒否されます。

  2. 2

    ② 先頭の単語を照合する

    先頭の単語が ALLOWED_COMMANDS にあるかだけを見ます。

  3. 3

    ③ 残りの単語を検査する

    SHELL_OPERATORS と一致する単語、または $ かバッククォートで始まる単語を拒否します。

ls && rm -rf / のように演算子が独立した単語なら③で、許可外のコマンドが先頭にあれば②で弾かれます。

TypeScript・Go・Java・C#・PHP版には、Python・Ruby版にはないコメントが1行だけ付いています。

const bare = token.replace(/^["']+/, ""); // a quoted token can still smuggle an expansion

これらの言語は command.split(/\s+/) のような素朴な空白分割しか使っておらず、シェルのクォート規則を理解しません。そのため "$(rm -rf /)" のようにクォートで包まれた展開はトークンの先頭にクォート文字が残ったまま渡ってきます。先頭のクォートを剥がしてから $ やバッククォートを判定する1行が、この抜け道への対処です。クォート規則を解釈するトークナイザを使うPythonとRubyにはこの行が無いのは手抜きではなく、Pythonは shlex.split、Rubyは Shellwords.split がそれぞれクォート除去込みでトークン化するため、この特定の抜け道が最初から発生しないからです。

演算子が単語にくっつくと検証をすり抜ける

ここまでの対処は「演算子やクォートが独立した単語として現れる」ケースを前提にしています。ところが公式ガイド自身が、この前提が崩れるケースを名指ししています。

It does not catch an operator glued to a word, such as cat data.txt|grep x, because the tokenizer keeps data.txt|grep inside one token.

この文字列では | が独立したトークンにならず、token in SHELL_OPERATORS の判定に一度も引っかかりません。しかしシェルに渡せば、結果は次の図のとおり別物になります。

くらべる

同じ文字列の2通りの読み方

空白で区切る

検証コード

cat data.txt|grep x は3つの単語です。2つ目の data.txt|grep は演算子の一覧と一致せず、そのまま通ります。

メタ文字でも区切る

bash

| ; & < > は、前後に空白がなくても演算子です。cat data.txt の出力が grep x に渡ります。

攻撃パターン別に見る検証の有効範囲

同じ「演算子を使った抜け道」でも、書き方によって検証が効くかどうかが変わります。次の表は、サンプルのPython版(Python 3.12.2)に入力を渡した結果と、通過した入力を /bin/bash -c で動かした結果です。

入力検証bashでの結果
ls && rm -rf /検証&& が独立した単語なので③で拒否bashでの結果実行されない
rm -rf /検証rm が許可リスト外なので②で拒否bashでの結果実行されない
echo "$(rm -rf /)"検証$ 始まりの単語として③で拒否(TypeScriptなどは先頭のクォートを剥がして判定)bashでの結果実行されない
cat data.txt|grep hello検証通過bashでの結果hello が出力される(パイプが成立)
echo hi;echo second検証通過bashでの結果hi と second の2行が出力される
echo hi>out.txt検証通過bashでの結果out.txt が作られ、中身は hi
echo a$(echo b)検証通過bashでの結果ab が出力される(コマンド置換が実行される)

通過した4行に共通するのは、危険な部分が単語の中に埋まっている点です。③が見るのは、単語が $ やバッククォートで「始まる」かどうかだけです。a$(echo b) のように前に1文字あるだけで、コマンド置換は通ります。> が単語に付いた形では、許可リストにある echo だけでファイルを書けます。

許可されたコマンド自身が実行機能を持つ場合はどうなるか

演算子の抜け道とは別に、もう1つ見落としやすい死角があります。ALLOWED_COMMANDS には find が含まれていますが、find はGNU/POSIX標準の -exec オプションで任意のコマンドを起動できます。実際に公式サンプルの validate_command へ次の2つを渡すと、結果が分かれます。

validate_command("find . -exec rm -rf {} +")   # -> (True, None)
validate_command("find . -exec rm -rf {} \\;")  # -> (False, "Shell operator ';' is not allowed")

終端記号を \; ではなく + にした版は検証を通過します。+ は SHELL_OPERATORS に含まれておらず、シェル演算子でもないため、③のチェックには一切引っかかりません。先頭コマンドは find で許可リスト内、残りのトークンはどれも演算子扱いされない。この経路では | や ; のような文字を1つも使わずに、許可された find コマンド自身の機能だけで任意のコマンド実行に到達します。

これは演算子検出のロジックを回避しているのではなく、そもそも検証の対象外にある問題です。validate_command は「先頭コマンドが安全か」と「演算子が紛れていないか」しか見ておらず、「許可した個々のコマンドが引数次第で何をできるか」までは検証していません。同じ構造の死角は find の -exec に限らず、多機能なCLIツールを許可リストに加えるたびに再検討が必要になります。

演算子を全面禁止できない理由

SHELL_OPERATORS の判定だけを見ると、&& や | を検出したら即座に拒否すればよいように思えます。ところが公式ガイドが「Common patterns」として挙げる実行例は、そのほとんどが複数コマンドの連結です。テストの実行は pytest && coverage report、ビルドは npm install && npm run build、Git操作は git status && git add . && git commit -m "message"。ファイル検索の find . -name "*.py" | xargs grep "pattern" もパイプを使います。公式のCommon patternsは、こうした複合コマンドを実行例として挙げています。演算子を検出したら機械的に弾く実装にすると、例示された用途の多くが動かなくなります。

公式ガイドが「Decide which commands and operators your application allows」と書いているのは、この緊張関係を踏まえた指示です。演算子の有無を単純な合否判定に使うのではなく、どのコマンドとどの演算子の組み合わせを許すかをアプリケーション側で個別に設計する前提に立っています。サンプルコードの SHELL_OPERATORS を空集合にして演算子を素通しすれば pytest && coverage report は動きますが、それは同時に ls && rm -rf / も素通しすることを意味します。一律禁止では実務のワークフローが止まり、一律許可では安全性が消えます。

Bashツール自体の制約も検証設計の前提になる

allowlist実装を組む前に、Bashツールの設計上の制約も押さえておく必要があります。まずセッションの状態はクライアント側にあります。作業ディレクトリや環境変数、実行中のプロセスはAnthropicのサーバーではなくアプリケーション側のbashプロセスが保持しており、ターン間でこれを維持するかどうかはアプリケーションの責任です。Claudeが restart: true を返したときは、アプリケーション側でシェルプロセスを止めて起動し直します。再起動後は作業ディレクトリ・環境変数・実行中のプロセスが消えます。

出力の扱いにも制約があります。APIは大きすぎるツール結果を自動で切り詰めません。上限を超えたリクエストはそのまま拒否されるため、標準出力と標準エラーをどこまでClaudeに返すかはアプリケーション側で事前に切り詰める設計が要ります。Security節が挙げる「出力から資格情報をマスキングする」という対策は、切り詰め処理に組み込むと扱いやすくなります。出力が長いコマンドほどモデルへの入力トークンも増えるため、切り詰めとマスキングは安全性だけでなくコスト面でも効いてきます。

対話的なコマンドとGUIアプリケーションも実行できません。vim や less、パスワード入力を待つプロンプトが対象です。サンプルのセッションは、各コマンドの後ろに固有の目印行(センチネル)を出力させ、それを見て出力の終わりを判断します。入力待ちのコマンドでは目印行が届かず、セッションは待ち続けます。公式はコマンドごとに期限を設け、過ぎたらシェルと子プロセスを止めて再起動する実装を勧めています。許可リストにこうしたコマンドを含めても、検証を通過した先でセッションごと固まるだけです。allowlistの設計は、演算子の抜け道だけでなく、ツールが構造的に扱えない実行形態も同時に除外する作業です。

トークン単位の検証がシェルの文法に追いつけない理由

演算子の一覧を増やしても、この種の抜け道は塞ぎきれません。検証がしているのは単語へのパターンマッチで、シェルの構文解析ではないためです。

ここでPython・Ruby版が免除されるように見えたクォート抜け道と、glued operatorの抜け道は、実は別の性質の問題です。試しにPythonの shlex.split に3つの入力を渡すと、結果は次のようになります。

>>> import shlex
>>> shlex.split("cat data.txt|grep x")
['cat', 'data.txt|grep', 'x']
>>> shlex.split("echo hi>out.txt")
['echo', 'hi>out.txt']
>>> shlex.split("echo a$(echo b)")
['echo', 'a$(echo', 'b)']

shlex はクォート付き文字列の展開を正しく1トークンにまとめてくれますが、| ; & のようなシェル演算子をトークンの区切りとしては扱いません。shlex が実装しているのはPOSIXシェルのクォート規則であって、構文解析ではないためです。つまりPythonのshlexベース実装も、glued operatorの抜け道に関しては他の6言語とまったく同じ弱点を抱えています。クォート抜け道への耐性(shlexが持つ)と、glued operatorへの耐性(どの言語も持たない)は独立した軸であり、「shlexを使っていれば安全」という単純な結論にはなりません。

公式ガイドがこの節の最後に置く結論はここに直結します。

Decide which commands and operators your application allows. The real control is isolation: run the whole session inside a container or a virtual machine.

トークン一致という土俵で戦う代わりに、実行環境そのものを壁で囲ってしまう発想です。公式のWarningは、コンテナやVMのような隔離環境で、作業に足りる最小権限のユーザーとして動かし、すべてのコマンドを信頼できない入力として扱うよう求めています。Security節は、その上に重ねる追加策を4つ挙げています。

追加策

隔離の上に重ねる4つの層

  • コマンドの検証

    許可リストで実行前に絞ります。この記事で見てきた仕組みはここです。

  • リソース制限

    シェルプロセスのCPU・メモリ・ディスクに、ulimit などで上限を置きます。

  • ログ記録

    実行したコマンドと出力をすべて残し、何が動いたかを後から監査できるようにします。

  • 秘密情報のマスキング

    出力に含まれる資格情報などを、Claudeに返す前に伏せます。

Claude Codeのサンドボックス設定も同じ発想に立つ

この「ブロックリストでもallowlistの完全性でもなく、実行環境を隔離する」という設計判断は、Anthropic製品の実装にも表れています。Claude Codeのサンドボックスは既定では無効で、/sandbox か sandbox.enabled で有効にします。有効にすると、コマンド実行はその境界の中で行われます。ファイルシステムへの到達範囲を個別の設定で外すことも、IPv6ドメインだけ許可リストに追加することもできます。トークン単位の文字列検証だけに頼らず、実行そのものを閉じた環境に置くという優先順位は、Bashツールの実装ガイドが最後に示す結論と同じ方向を向いています。コマンド実行の入口になる claude コマンドとant CLIの違いはant CLIとClaude Codeの違いにまとまっています。Agent SDKで自前のエージェントをホストする場合も、サンドボックスと資格情報管理を含めたデプロイ設計が同じ土台になります。

まとめ

サンプルの検証は書き間違いを拾う仕掛けで、| ; > $( を単語に付けた入力は通します。Bashツールを組み込むときは、先にコンテナやVMで隔離を用意し、allowlistは find の -exec のような引数経由の実行まで見直す一層として重ねる、という順序になります。

この記事を共有:XはてブLinkedIn