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

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

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

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

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

Claudeが要求するコマンドをそのまま実行する以上、検証なしでは何でも実行できてしまいます。Agent SDKでカスタムツールを自作するときと同じく、実行の中身をどこまで検証するかは呼び出し側の設計次第です。Anthropicの実装ガイドは6ステップの最後に「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

①コマンド文字列を単語(トークン)に分割 ②先頭トークンが許可リストにあるか確認 ③残りのトークンにシェル演算子や $・バッククォートで始まる単語がないか確認、の3段です。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.

cat data.txt|grep x を空白で分割すると ["cat", "data.txt|grep", "x"] になります。| は独立したトークンではなく data.txt|grep という1つの単語の中に埋もれているため、token in SHELL_OPERATORS の判定に一度も引っかかりません。しかし、この文字列を実際のシェルに渡せば | はれっきとしたパイプ演算子として解釈され、cat data.txt の出力が grep x に渡ります。検証コードが「1トークン=1つの安全な単語」という前提で組み立てられているのに対し、シェルは空白の有無に関係なく | ; & < > を単語境界として扱う。この前提のズレが、そのまま抜け道になります。

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

同じ「演算子を使った抜け道」でも、書き方によって検証が効くかどうかが変わります。

入力パターン検証の挙動実際に防げるか
ls && rm -rf /検証の挙動&& が独立トークンとして③で検出実際に防げるか防げる
rm -rf / (先頭コマンドそのもの)検証の挙動rm が②の許可リスト外で拒否実際に防げるか防げる
echo "$(rm -rf /)"検証の挙動クォート除去後に $ を③で検出(TS/Go/Java/C#/PHP実装。Python・Rubyはトークナイザが同等に処理)実際に防げるか防げる
cat data.txt|grep x検証の挙動|data.txt|grep という1トークンに埋没し③に引っかからない実際に防げるか防げない
cat data.txt;rm -rf /(空白なし)検証の挙動data.txt;rm が1トークン化し、②の先頭コマンド判定にも③にも掛からない実際に防げるか防げない

表の下2行に共通するのは、危険な部分が「許可されたコマンド名の後ろに空白なしで連結される」形になっている点です。検証コードは先頭トークンだけを許可リストと照合し、以降のトークンは演算子っぽい単語かどうかしか見ていないため、この形は死角になります。

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

演算子の抜け道とは別に、もう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" もパイプを使います。Bashツールはもともと、こうした複合コマンドを1回のツール呼び出しで実行させるために設計されています。演算子を検出したら機械的に弾く実装にすると、ツールが想定している用途そのものを塞いでしまいます。

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

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

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

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

対話的なコマンドとGUIアプリケーションも実行できません。vimless、パスワード入力を待つプロンプトはハングしたまま応答が返らず、セッションが止まります。許可リストにこうしたコマンドを含めても、検証を通過した先でセッションごと固まるだけです。allowlistの設計は、演算子の抜け道だけでなく、ツールが構造的に扱えない実行形態も同時に除外する作業になります。

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

なぜこの種の抜け道は塞ぎきれないのでしょうか。検証コードがしているのは「空白で区切った単語の集合」に対するパターンマッチであって、シェルの構文解析そのものではありません。POSIXシェルは | ; & < > をメタ文字として扱い、前後に空白があってもなくても演算子として認識します。一方、command.split(/\s+/)strings.Fields はメタ文字を特別扱いせず、空白の有無だけでトークンを切ります。この2つの「単語の区切り方」がそもそも別のルールで動いているため、検証側がどれだけ演算子のリストを増やしても、シェル側の解釈と完全には一致しません。

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

>>> import shlex
>>> shlex.split("cat data.txt|grep x")
['cat', 'data.txt|grep', 'x']

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.

トークン一致という土俵で戦う代わりに、実行環境そのものを壁で囲ってしまう発想です。Security節では、隔離を前提にしたうえでの追加策として、①ulimit などのリソース制限 ②実行コマンドと出力のログ記録 ③出力に含まれる資格情報のマスキング、の3点を挙げています。allowlist検証はこれらと並ぶ一層に過ぎず、単独で防御を完結させる仕組みとしては設計されていません。

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

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

まとめ

公式サンプルのallowlist検証は、演算子やクォートによる明らかな書き間違いを弾く「トリップワイヤ」であり、演算子が単語に空白なしで連結される入力までは検出できません。この事実は隠されているわけではなく、実装ガイド自身が具体例付きで明記しています。Bashツールをアプリケーションに組み込む場合、この検証コードをコピーして終わりにせず、①コンテナやVMでのセッション隔離 ②最小権限のユーザーでの実行 ③リソース制限とログ記録、をセットで用意することが前提です。allowlist検証はその上に重ねる一層として位置付けるのが実装ガイドの一貫した立場です。

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