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プロセスを起動し、すべてのコマンドをその中で実行します。構成は公式ドキュメントのPython例と同じです。作業ディレクトリや環境変数、コマンドが作ったファイルは次のコマンドにも引き継がれます。
複数ターンのタスクで、この引き継ぎがどう効くかを公式の例で追うと次のとおりです。依頼は「requestsライブラリをインストールし、APIからジョークを取得するPythonスクリプトを作って実行する」です。
Claudeが3回に分けて呼び出すコマンド
- 1
1回目: パッケージをインストールする
{"command": "pip install requests"} - 2
2回目: スクリプトをファイルに書き出す
cat > fetch_joke.py << 'EOF'で始まるヒアドキュメントがcommandに入ります。 - 3
3回目: 書き出したスクリプトを実行する
{"command": "python fetch_joke.py"}2回目のファイルが3回目で使えるのは、同じセッションの中でコマンドをつないでいるからです。
セッションを毎回使い捨てにする実装では、この連携が成立しません。
ここで最初の壁にぶつかります。生きているプロセスへのパイプは、コマンドが終わっても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つのプロセスグループにまとめます。次のステップのタイムアウト処理で、このグループごと終了させます。
この最小実装が取りこぼす出力
上のコードをPython 3とbashでそのまま動かすと、cd /tmp && pwdに続けてpwdを実行したとき、どちらも/tmpを返します。状態の引き継ぎは期待どおりです。一方、コマンドによっては出力が欠けたり、セッションがエラーを出さずに終了したりします。
| 実行したコマンド | execute_commandの戻り値 |
|---|---|
printf abc(末尾に改行なし) | execute_commandの戻り値空文字列 |
ls /nonexistent_dir | execute_commandの戻り値ls: /nonexistent_dir: No such file or directory\n |
exit 3 | execute_commandの戻り値空文字列。次の呼び出しはBrokenPipeError |
1行目は、改行のない出力がセンチネルと同じ行に連結されるためです。abc__CLAUDE_BASH_DONE_…__という1行になり、sentinel in lineで見つかった時点でその行ごと捨てられます。3行目は、シェル自体が終了して標準出力がEOFになり、forループがセンチネルなしで終わるためです。標準エラーはstderr=subprocess.STDOUTのおかげで通常どおり拾えます。
ループを次のように変えると、2つとも防げます。センチネルより前の文字列を残し、センチネルが届かないままEOFになったら例外にします。
output = []
for line in self.process.stdout:
if sentinel in line:
output.append(line.split(sentinel)[0]) # 改行なしの出力を残す
break
output.append(line)
else:
raise RuntimeError("shell exited before the sentinel arrived")
return "".join(output)この版ではprintf abcが'abc'を返し、exit 3はRuntimeErrorで表に出ます。Claudeに返すtool_resultの文面は、この例外をアプリケーション側で拾って組み立てます。なお、センチネルの前後に終了コードを出す設計にしていない限り、falseのような失敗コマンドの終了コードは戻り値に現れません。
ステップ2: コマンドにタイムアウトを設定し、ハング時にプロセスグループごと再起動する
上のコードにはもう1つ重大な弱点があります。標準入力を待つコマンド(パスワード入力プロンプトなど)を実行すると、センチネル行が永遠に届かず、セッションが固まったまま戻ってきません。すべてのコマンドに期限を設け、期限を過ぎたらシェルとその中で起動したすべてのプロセスを止め、セッションを再起動します。
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した場合とプロセスグループごと終了した場合では、子プロセスの扱いが変わります。sleep 313 &でバックグラウンドの子プロセスを作り、そのPIDをpsで見ると次のとおりです。
シェルだけ kill と killpg の違い
process.kill()
子プロセスは残ります。ps -p <PID> -o pid=,ppid=の出力は、親PIDが1の行(<PID> 1)になります。その状態で走り続けます。
os.killpg()
子プロセスも終了します。同じpsコマンドは何も返しません。
restart()が呼ばれた直後のセッションは、作業ディレクトリも環境変数も初期状態に戻っています。cd /tmpしたあとにタイムアウトで再起動すると、次のpwdはbashを起動したディレクトリを返します。
タイムアウト値は用途によって調整します。単純なファイル操作なら数秒で足りますが、ビルドやテストの実行を任せるなら数分単位の余裕が要ります。短すぎると、正常に完了するはずのコマンドまで打ち切られます。
ステップ3: 結果をClaudeへ返し、リクエストを組み立てる
Bashツールの宣言はtypeとnameの2フィールドのみで、nameは必ずbashにします。入力スキーマは提供しません。Claudeが呼び出すときの入力フィールドはcommandとrestartの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-5",
"max_tokens": 1024,
"tools": [
{"type": "bash_20250124", "name": "bash"}
],
"messages": [
{"role": "user", "content": "現在のディレクトリのPythonファイルを一覧表示して"}
]
}'Claudeは複数のtool_useブロックを1つの応答で返すこともあります。その場合は同じセッション内で順番に実行し、すべての結果を1つのuserメッセージにまとめて返します。
どの失敗にis_error: trueを付けるか
失敗したtool_resultは、メッセージをcontentに入れてis_error: trueを付けて返します。公式ドキュメントが挙げる例は3つです。タイムアウトのError: command did not finish within 30 secondsに加えて、bash: nonexistentcommand: command not foundとbash: /root/sensitive-file: Permission deniedにも同じくis_error: trueが付いています。
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01A09q90qw90lq917835lq9",
"content": "Error: command did not finish within 30 seconds",
"is_error": true
}
]
}シェルが出した文面をそのままcontentに入れれば、Claudeはその文字列を読んでコマンドを直すか、別の方法を試すかを決められます。ただし上の最小実装は、コマンドの終了コードを持ちません。command not foundにis_errorを付けるには、終了コードを返す設計に変えるか、出力の文面から判定する処理が別途必要です。
他のツールと組み合わせるときの注意
Bashツールはtext editor toolと相性が良く、Claudeは一方のツールでファイルを編集し、もう一方でそれを実行するコマンドを要求する、という組み合わせて使えます。この場合もセッションの状態管理はBashツール側だけが担当します。
code execution toolを併用する場合は環境が分かれます。Bashツールが動かすのはこの記事で組み立てたローカルのbashセッション、code execution toolが動かすのはAnthropicが用意するサンドボックス化されたコンテナで、両者の間で状態は共有されません。片方で作ったファイルがもう片方から見えると期待すると、ファイルが見つからないエラーになります。プロンプトで、どちらの環境で作業しているかを明示しておくと混同を避けやすくなります。
コスト面では、Bashツールの定義がリクエストの入力トークンをわずかに押し上げます。Claude Opus 5・Opus 4.8・Opus 4.7では325トークン、Opus 4.6・Sonnet 4.6以前のモデルでは244トークンが、ツール利用に共通するシステムプロンプト分とは別に加算されます。コマンドの出力やエラーメッセージ、大きなファイルの内容も入力トークンとして消費されます。
安全チェックの許可リストはどこまで守れるか
公式ドキュメントは、コマンドの検証に許可リスト(allowlist)を使うよう求めています。理由は、ブロックリストでは想定していなかったコマンドを素通ししてしまうからです。サンプルのvalidate_commandは、lsやcatなど9個のコマンドだけを許可し、&&や|といったシェル演算子が単独の語として現れたら拒否します。
このサンプルをそのまま動かすと、次の結果になります。
| 入力 | 判定 |
|---|---|
echo hi && pwd | 判定拒否(&&が単独の語) |
echo hi&&pwd | 判定許可 |
echo hi;pwd | 判定許可 |
echo $(pwd) | 判定拒否 |
echo a$(pwd) | 判定許可 |
find . -delete | 判定許可 |
空白を詰めたecho hi&&pwdは1語として扱われるため、検査をすり抜けます。bashにそのまま渡すとhiに続けてpwdの出力が返りました。許可リストは入口の見張りにはなりますが、これだけで守れる仕組みではありません。
公式ドキュメントも、Security節で隔離環境(コンテナや仮想マシン)、ulimitなどによるCPU・メモリー・ディスクの制限、全コマンドと出力のログ、出力に含まれる認証情報の除去を並べています。
よくあるつまずき
Bashツール実装で見落としやすい2点
- 出力を無制限にAPIへ返す: APIはtool_resultを切り詰めません。サイズ超過のリクエストはそのまま拒否されるため、大きな出力はアプリケーション側で切り詰めてから返します
- ストリーミングを期待する: Bashツールの出力はストリーミングされません。次のリクエストで
tool_resultを返すまで、Claude側には何も届きません
まとめ
最小構成は、センチネル行で完了を検知する永続セッションと、プロセスグループごと終了させるタイムアウト処理の組み合わせです。公式のサンプルは改行のない出力とシェルの終了を扱わないので、そのまま本番に使う前に手元で動かして確かめる価値があります。
実行環境の隔離やクレデンシャル管理まで含めた本番運用の設計はAgent SDK安全なデプロイ、コードからサンドボックスを制御する方法はAgent SDKのsandbox設定をコードから制御するにあります。Claude Code自身のBashツールのタイムアウト調整は、BASH_DEFAULT_TIMEOUT_MSでBashコマンドのタイムアウトを変更するで扱っています。