BashツールをAPIで実装するセンチネル行とタイムアウト処理
Anthropic APIのBashツールはClaudeが実行するコマンドを返すだけで、実行環境は自前実装です。センチネル行での完了検知とプロセスグループごとのタイムアウト処理をコード付きで解説します。
Bashツールは実行環境を自前で用意する必要がある
AnthropicのBashツールはクライアントツール(実行そのものを呼び出し側のアプリケーションが担うツール)です。Claude自身はコマンドを実行しません。リクエストにこのツールを含めると、Claudeは実行してほしいコマンドをtool_useブロックで返すだけで、実際にコマンドを実行するbashセッションを持ち、出力をtool_resultとして返す責任はアプリケーション側にあります。シェルプロセスの管理、タイムアウト、安全チェックのすべてが実装対象です。
現行バージョンはbash_20250124で、ベータヘッダーは不要です。Claude Sonnet 3.7以降のすべての現行モデルが対応します。1世代前のbash_20241022は2024年10月のClaude Sonnet 3.5専用で、ベータヘッダーcomputer-use-2024-10-22が必要でした。新規に実装するならbash_20250124一択です。APIはステートレスなので、シェルセッションの開始・維持・再起動のタイミングはすべてアプリケーション側の判断です。
このチュートリアルで組み立てるのは、①コマンドの完了を確実に検知する永続bashセッション、②ハングしたコマンドを検知してプロセスグループごと終了させるタイムアウト処理、の2つです。前提として、コンテナや仮想マシンなど隔離された実行環境と、作業に必要な最小権限のユーザーを用意しておきます。Bashツールは任意のコマンドをそのまま実行するため、公式ドキュメントも「すべてのコマンドを信頼できない入力として扱う」ことを求めています。
ステップ1: 永続bashプロセスとセンチネル行で完了を検知する
1つの長命なbashプロセスを起動し、すべてのコマンドをその中で実行します。作業ディレクトリや環境変数、コマンドが作ったファイルは次のコマンドにも引き継がれます。
ここで最初の壁にぶつかります。生きているプロセスへのパイプは、コマンドが終わってもEOF(ファイル終端)を返しません。標準出力を読み続けるだけでは「このコマンドの出力はどこまでか」が分からないのです。対処法は、コマンドの直後に一意な文字列(センチネル行)を出力させ、それが現れた行までを今回の出力とみなすことです。
import subprocess
import uuid
class BashSession:
"""コマンド間で状態を保つため、生きたままのbashプロセスを保持する"""
def __init__(self):
self.process = subprocess.Popen(
["/bin/bash"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, # エラーも出力に混ぜ、発生順を保つ
start_new_session=True, # 独自のプロセスグループ。タイムアウト時に子プロセスごと終了できる
text=True,
)
def execute_command(self, command):
"""セッション内でコマンドを実行し、出力を返す"""
sentinel = f"__CLAUDE_BASH_DONE_{uuid.uuid4().hex}__" # 呼び出しごとに一意
self.process.stdin.write(f"{command}\necho {sentinel}\n")
self.process.stdin.flush()
output = []
for line in self.process.stdout:
if sentinel in line: # この行が来たらコマンドの出力は完了
break
output.append(line)
return "".join(output)
def restart(self):
self.process.kill()
self.process.wait()
self.__init__()uuid.uuid4()でセンチネルを毎回作り直すのは、コマンドの出力自体に同じ文字列がたまたま含まれる事故を避けるためです。start_new_session=Trueは、このシェルとその子プロセス全体を1つのプロセスグループにまとめます。次のステップのタイムアウト処理で、このプロセスグループごと終了させます。
状態が引き継がれることの効果は、複数ターンにまたがるタスクで分かりやすく現れます。たとえば「requestsライブラリをインストールし、APIからジョークを取得するPythonスクリプトを作って実行する」という依頼では、Claudeは3回に分けてツールを呼び出します。1回目はpip install requests、2回目はスクリプトをファイルに書き出すcatコマンド、3回目はpython fetch_joke.pyです。2回目で作ったファイルを3回目でそのまま実行できるのは、同じセッションの中でコマンドをつないでいるからです。セッションを毎回使い捨てにする実装では、この連携が成立しません。
ステップ2: コマンドにタイムアウトを設定し、ハング時にプロセスグループごと再起動する
上のコードには重大な弱点があります。標準入力を待つコマンド(パスワード入力プロンプトなど)を実行すると、センチネル行が永遠に届かず、セッションが固まったまま戻ってきません。すべてのコマンドに期限を設け、期限を過ぎたらシェルとその中で起動したすべてのプロセスを止め、セッションを再起動します。
import concurrent.futures
import os
import signal
def execute_with_timeout(session, command, timeout=30):
"""セッション内でコマンドを実行する。ハングした場合はセッションを丸ごと差し替える"""
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
future = pool.submit(session.execute_command, command)
try:
return future.result(timeout=timeout)
except concurrent.futures.TimeoutError:
# プロセスグループには、このシェルとコマンドが起動した全プロセスが含まれる
os.killpg(session.process.pid, signal.SIGKILL)
session.restart()
return f"Error: command did not finish within {timeout} seconds"session.process.pidをos.killpgに渡すのがポイントです。シェルのプロセスIDだけをkillすると、シェルが生んだ子プロセス(ビルドやテストランナーなど)が残り続けることがあります。ステップ1でstart_new_session=Trueにしてプロセスグループを独立させておいたのは、このタイムアウト処理で一括終了させるためです。タイムアウト値は用途によって調整します。単純なファイル操作なら数秒で十分ですが、ビルドやテストの実行を任せる場合は数分単位の余裕を持たせておかないと、正常に完了するはずのコマンドまで打ち切られてしまいます。
タイムアウトしたtool_resultはis_error: trueを付けてClaudeへ返します。
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01A09q90qw90lq917835lq9",
"content": "Error: command did not finish within 30 seconds",
"is_error": true
}
]
}コマンドが存在しない場合や権限エラーの場合、シェルのエラーメッセージは標準出力に混ざってそのまま返ります。is_error: trueを立てるのはタイムアウトのようにハーネス側で失敗を検知したときで、シェル自体のエラーは通常の出力としてClaudeに渡り、Claudeがその文字列を読んでコマンドを修正し再試行するかどうかを判断します。
ステップ3: セッションのリクエストを組み立てる
Bashツールの宣言はtypeとnameの2フィールドのみで、nameは必ずbashにします。入力スキーマは提供しません。Claudeが呼び出すときの入力フィールドはcommand(実行するコマンド)とrestart(セッションを再起動する場合にtrue)の2つです。restart: trueが来たら、シェルプロセスを終了して新しいプロセスを起動し、再起動が完了したことをtool_resultで返します。再起動後のセッションは作業ディレクトリも環境変数もリセットされた状態から始まります。
curl https://api.anthropic.com/v1/messages \
-H "content-type: application/json" \
-H "x-api-key: $ANTHROPIC_API_KEY" \
-H "anthropic-version: 2023-06-01" \
-d '{
"model": "claude-opus-5",
"max_tokens": 1024,
"tools": [
{"type": "bash_20250124", "name": "bash"}
],
"messages": [
{"role": "user", "content": "現在のディレクトリのPythonファイルを一覧表示して"}
]
}'Claudeは複数のtool_useブロックを1つの応答で返すこともあります。その場合は同じセッション内で順番に実行し、すべての結果を1つのuserメッセージにまとめて返します。
他のツールと組み合わせるときの注意
Bashツールはtext editor toolと相性が良く、Claudeは一方のツールでファイルを編集し、もう一方でそれを実行するコマンドを要求する、という組み合わせ方をよくします。この場合もセッションの状態管理は今回のBashツール側だけが担当します。
code execution toolを併用する場合は環境が分かれる点に注意します。Bashツールが動かすのはこの記事で組み立てているローカルのbashセッション、code execution toolが動かすのはAnthropicが用意するサンドボックス化されたコンテナで、両者の間で状態は共有されません。片方の環境で作ったファイルがもう片方から見えると期待すると、実行時にファイルが見つからないというエラーで気づくことになります。Claudeへのプロンプトで、どちらの環境で作業しているかを明示しておくと、この混同を避けやすくなります。
コスト面では、Bashツールの定義自体がリクエストの入力トークンをわずかに押し上げます。Claude Opus 5・Opus 4.8・Opus 4.7では325トークン、Opus 4.6・Sonnet 4.6以前のモデルでは244トークンが、ツール利用に共通するシステムプロンプト分とは別に加算されます。頻繁に短いコマンドを繰り返すループでは、この固定分がリクエスト全体に占める割合も意識しておくと、コスト試算の精度が上がります。
よくあるつまずき
Bashツール実装で見落としやすい6点
- プロセスグループを独立させ忘れる:
start_new_session=True(Pythonの場合)を省略すると、シェルが起動した子プロセスがタイムアウト処理のkillをすり抜けて残り続けます。ビルドプロセスやサーバーが終了せずゾンビ化する原因になります - ブロックリストでコマンドを制限しようとする: 公式ドキュメントは許可リスト(allowlist)方式を推奨しています。禁止コマンドの列挙(ブロックリスト)は、パイプやリダイレクトを使った迂回を防ぎきれません
- 隔離環境の外でセッションを動かす: Bashツールが実行するコマンドはすべて信頼できない入力として扱う前提です。コンテナや仮想マシンでの隔離に加えて、CPU・メモリー・ディスクのリソース制限(
ulimit等)、実行コマンドと出力のログ記録、レスポンスに含める前のクレデンシャル除去(redaction)を組み合わせておくと、想定外のコマンドが実行された場合の被害範囲を絞れます - 出力を無制限にAPIへ返す: APIはtool_resultを切り詰めません。サイズ超過のリクエストはそのまま拒否されるため、大きな出力はアプリケーション側で切り詰めてから返します
- restart: trueの状態リセットを見落とす: 再起動後は作業ディレクトリも環境変数も消えます。再起動直後にセッション開始時のセットアップコマンド(作業ディレクトリの
cdなど)を再実行する処理を忘れると、直前までの前提が崩れたままコマンドが走ります - ストリーミングを期待する: Bashツールの出力はストリーミングされません。次のリクエストで
tool_resultを返すまで、Claude側には何も届きません
まとめ
Bashツールの自前実装は、①センチネル行でコマンドの完了を検知する永続セッション、②プロセスグループごと終了させるタイムアウト処理、の2つを組み合わせれば最小構成が動きます。この2つさえ押さえておけば、複数ターンにまたがるタスクも、ハングするコマンドが混ざるタスクも、安全に扱えるようになります。実行環境の隔離やクレデンシャル管理まで含めた本番運用の設計はAgent SDK安全なデプロイ、コードからサンドボックスを制御する方法はAgent SDKのsandbox設定をコードから制御するにまとめています。なお、Claude Code自身のBashツールにはタイムアウトや出力上限の調整が環境変数として組み込まれています。挙動を比較したい場合はBASH_DEFAULT_TIMEOUT_MSでBashコマンドのタイムアウトを変更するを参照してください。