アフェリエイト広告

CLIオーケストレーションで踏んだ罠カタログ ― 3つのAI CLIを1日繋いで分かったこと

前の記事で、APIではなくすでに契約しているCLIをプロセスとして起動する方式のマルチエージェント基盤を作った話を書いた。サブスクリプションのまま使え、特定の基盤に縛られない。その代わり、プロセス管理と検証は自分の仕事になる。

本記事はその「自分の仕事になった部分」の記録である。3つのAI CLIを非対話モードで起動し、構造化出力を受け取り、タイムアウトで確実に殺す。それだけのことに、1日で20近い落とし穴があった。

同じことをやろうとしている人向けに、5つに分類して残しておく。最後にチェックリストを付けた。

内容はかなり細かい。通読して理解する必要はない。 同じようなものを作るなら、この記事ごとAIに読み込ませて一緒に作るのが早い。使い方は末尾に書いた。

前提となる構成は次のとおり。

  • 3つのAI CLIを subprocess で起動し、JSON Schemaで構造化出力を受け取る
  • 各CLIには作業ディレクトリを与え、その中だけで作業させる
  • タイムアウトしたらプロセスグループごと終了させる
  • 結果をDiscordのWebhookへ投稿する

アフェリエイト広告

分類1: CLI仕様の誤解 ― 記憶で書くと必ず落ちる

先に1,000行の仕様書を書いてから実装に入った。そこに書いたコマンドラインオプションは、半分近くが実際とは違っていた。

--tools は「許可」ではない

あるCLIには、使えるツールを列挙する --tools がある。これを指定して権限モードを「聞かない」にすれば非対話で動く、と考えた。

動かなかった。ファイル書き込みもコマンド実行も、片っ端から自動拒否された。

正解は --allowedTools である。--tools利用可能なツールの一覧であって、許可を与えるものではない。

さらに厄介なのは、最初の試験では動いてしまったことだ。そのときはグローバル設定を継承していたので、そちら側の許可ルールが効いていた。再現性のために設定の読み込みを切った瞬間、許可ルールがゼロになって全拒否に変わった。

"permission_denials": [
  {"tool_name": "Bash",  "tool_input": {"command": "ls -la artifacts ..."}},
  {"tool_name": "Write", "tool_input": {"file_path": ".../artifacts/smoke.md"}}
]

このCLIは拒否した内容を配列で返してくれる。ハングもしない。この配列を必ずログに残すべきである。 空でなければ、何かが期待どおりに動いていない。

存在しないオプションを仕様書に書いていた

別のCLIの非対話サブコマンドに --ask-for-approval never を渡すよう仕様書に書いていた。実行するとこうなる。

error: unexpected argument '--ask-for-approval' found

そのオプションは対話モード専用だった。非対話サブコマンドは既定で承認を求めないので、そもそも不要である。

「制限モード」が必要な機能ごと削っていた

安全のために用意されている制限モードのオプションがある。ヘルプを読むと、こう書いてあった。

Bash, PowerShell, REPL とその他コード実行系ツール、および WebFetch を(明示指定しない限り)削除する

つまりこれを付けると、コマンド実行もWeb取得もできなくなる。 「コマンド実行は許可する」という自分の方針と真っ向から矛盾していた。使うのをやめ、代わりに許可リストと拒否リストで境界を作った。

教訓。 実装前に --help を通しで読み、短いスモークテストで1つずつ確かめる。記憶とドキュメントは信用しない。ただし仕様書を書いたこと自体は無駄ではなかった。何を確かめるべきかのリストとして機能した。


分類2: 成功を装う失敗 ― 終了コードもステータスも信用できない

これがもっとも危険な種類である。

"status": "SUCCESS" と言いながら、中身が空

サンドボックスオプションだけを付けて、あるCLIにファイル作成をさせた。返ってきたのがこれだ。

{"conversation_id": "12a627e6-...", "status": "SUCCESS", "response": "",
 "duration_seconds": 6.49, "num_turns": 1}

ステータスは成功。所要6.5秒。ファイルは作られていない。

実際にはツールの権限が自動拒否されていた。ハングしなかったのは良いが、それが SUCCESS として返る。素直に信じれば「完了しました」と報告しながら何もしていないエージェントができあがる。

対策は2つ。権限を明示的に付与するオプションを併用すること。そして成否を status ではなく、構造化出力フィールドの有無で判定すること。

structured = envelope.get("structured_output")
if structured is None:
    raw = (envelope.get("response") or "").strip()
    if not raw:
        result.error = f"空の応答を返しました(status={envelope.get('status')})"
        return result

なお、このCLIに JSON Schema を渡すと、封筒に structured_output という解析済みオブジェクトが入る。response 文字列のほうにはSchemaにないキーが混ざることがあったので、必ず structured_output を優先して読んでいる。

「作成しました」と言われたファイルが、どこにも無い

これが一番ぞっとした。

エージェントに artifacts/smoke.md を作らせ、「作成しました」という応答を得た。作業ディレクトリを見ると、無い。探したら、まったく別の場所にあった。

指定した場所:
/private/tmp/claude-501/-Users-hiro-.../scratchpad/claude_smoke2/artifacts/smoke.md

実際に作られた場所:
/private/tmp/claude-501/-Users-hiro-...-scratchpad-claude_smoke2/artifacts/smoke.md

よく見てほしい。スラッシュがハイフンに化けている。CLIが自前の作業領域を作り、そこ基準で相対パスを解決していた。 親プロセスから継承した環境変数を見て、そう判断していたのである。

エージェントは嘘をついていない。指定した場所とは違う場所に、確実に作っていた。

対策は2つ入れた。

  1. 子プロセスに渡す環境変数を最小限まで削る(後述)
  2. 申告された成果物が実在するかを、システム側で毎回確認する
def _verify_files(self, job_dir, parsed):
    existing = []
    for rel in parsed.handoff.files:
        if (job_dir / rel).exists():
            existing.append(rel)
        else:
            parsed.problems.append(f"申告された成果物が存在しません: {rel}")
            parsed.valid = False
    return existing

「AIがそう報告した」と「システム上で確認できた」を分ける。これが設計の原則になった。


分類3: 環境の汚染 ― 隔離しすぎても、しなさすぎても壊れる

隔離しなさすぎ: 環境変数の継承

上のファイル行方不明事件の原因である。親プロセスの環境をそのまま渡すと、CLIが独自の作業領域を選ぶ。

渡す変数を次だけに絞ったら、相対パスが作業ディレクトリ基準に戻った。

ENV_ALLOWLIST = ("HOME", "USER", "LOGNAME", "LANG", "LC_ALL", "TMPDIR", "LC_CTYPE")

def scrubbed_env(extra=None, path=None):
    env = {k: os.environ[k] for k in ENV_ALLOWLIST if k in os.environ}
    env["PATH"] = path or f"{env.get('HOME')}/.local/bin:/opt/homebrew/bin:/usr/bin:/bin"
    ...

HOME は各CLIの認証情報の在り処なので外せない。

隔離しすぎ: 認証情報まで隔離して無限待機

別のCLIには設定ディレクトリを指すenv varがある。ユーザーの設定を汚さないよう、専用の空ディレクトリを与えた。仕様書にもそう書いた。

5分待っても返ってこなかった。 プロセスグループごと強制終了するしかなかった。

原因は認証情報だった。そのCLIは認証ファイルを設定ディレクトリの中に置く。空のディレクトリを指した結果、未ログイン状態になり、ログインを待って固まっていたのである。

ヘルプにはこう書いてあった。

設定ファイルを読み込まない。認証は引き続き設定ディレクトリを使う

読んでいたはずなのに、意味を理解していなかった。設定の隔離は「設定を読まない」オプションだけで行い、ディレクトリ自体は既定のままにした。

教訓。「汚染を避けたい設定」と「実行に必要な状態」は別物である。 一括で隔離すると後者まで巻き込む。


分類4: 並行性 ― 実プロセスを起動しないと出てこない

この2つは、コードを眺めていても永遠に見つからない。

SQLiteのトランザクションは接続単位である

受信スレッドとワーカースレッドで同じDB接続を共有していた。並行動作のテストを書いた瞬間、これが出た。

sqlite3.OperationalError: cannot start a transaction within a transaction

スレッドセーフの設定(check_same_thread=False)は入れてあった。しかしトランザクションはスレッド単位ではなく接続単位である。片方が BEGIN している最中に、もう片方が BEGIN すれば当然こうなる。

対策として接続にロックを持たせようとしたら、次で弾かれた。

AttributeError: 'sqlite3.Connection' object has no attribute '_gc_lock'
and no __dict__ for setting new attributes

sqlite3.Connection はC実装で __dict__ を持たないため、属性を後付けできない。サブクラスにする必要がある。

class GCConnection(sqlite3.Connection):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.gc_lock = threading.RLock()

conn = sqlite3.connect(path, ..., factory=GCConnection)

「停止しました」と返しながら、走り続ける

最悪の壊れ方である。

停止処理はこう動く。ジョブを実行中に更新し、プロセスを起動し、そのプロセスIDをDBへ記録する。ところが「実行中に更新」から「プロセスID記録」までの数ミリ秒に停止命令が入ると、こうなった。

  1. 停止命令がジョブ状態を cancelled にする
  2. しかしプロセスIDが未記録なので、プロセスは殺されない
  3. その直後にプロセスID記録の処理が走り、状態を waiting_model巻き戻す
  4. ジョブは何事もなかったように完走する

利用者には「停止しました」と表示されている。3か所直した。

  • 進捗更新は、状態が実行中のときだけ行う
  • プロセスIDを記録した直後に停止要求を再確認し、その場でプロセスグループを終了させる
  • ホップ完了後とルート完走後にも状態を確認し、cancelled を上書きしない

実CLIでも検証した。実行中のジョブへ停止命令を送ると、プロセスグループが全滅し(ゾンビ2つを含む3プロセス)、孤児プロセスも残らず、状態は cancelled を保った。

教訓。 停止処理は「シグナルを送る」より「状態機械と競合しないこと」のほうが難しい。そしてこれは、実際にプロセスを起動して実際に強制終了シグナルを撃つテストでしか出てこない。


分類5: 指示の矛盾 ― プロンプトが実質的な権限として働く

これが今回いちばん驚いた発見である。おそらくLLMエージェント特有で、一般的なアクセス制御の常識にない。

作業ディレクトリの外にファイル置き場を作り、エージェントに読ませようとした。設定を入れ、CLIにもディレクトリ追加のオプションを渡した。

あるエージェントが「許可されていない」と返してきた。

権限設定を疑って調べたが、どこも間違っていない。原因は役割定義の文章にあった。

- 作業ディレクトリの外を読み書きしない        ← 古い記述が残っていた

さらに、毎回のプロンプトにもこう書いていた。

## 作業ディレクトリ
/path/to/job
この配下のみ読み書きできます。

エージェントは、厳しいほうのルールに従って自主的に断っていた。 権限は通っていたのに、指示が拒否させていたのである。真面目な挙動と言える。

つまり、追加した許可を実際に使わせるには3層すべてを揃える必要がある。

内容見落としやすさ
① システム側のパス検証成果物パスの妥当性判定
② CLIへのディレクトリ指定これが無いとCLI自体が拒否する
プロンプトと役割定義の記述これが無いとエージェントが自主的に拒否する

権限の不具合は普通①②を疑う。しかしLLMエージェントでは、プロンプトの文言そのものが実質的なアクセス制御として機能する。

おまけ: 同じ矛盾でも、モデルによって反応が割れた

4体のエージェントに同じ矛盾した指示を与えていた。拒否したのは1体だけで、残り3体は「読んで」という明示指示を優先して実行していた。

裏を返せば、その1体が断ってくれたおかげで矛盾に気づけた。 他の3体だけを見ていたら「動いているから問題なし」と判断し、指示の不整合を抱えたまま運用に入っていた。

厳格なモデルが混ざっていることには、こういう価値がある。


番外: 3つのCLIに同じSchemaを食わせる代償

構造化出力のJSON Schemaを1つ書いて、3つのCLIすべてに渡そうとした。それぞれ別の理由で拒否された。

正規表現エンジンの違い

パスの形式を pattern で縛ったところ、あるCLIがSchema自体を受け付けなかった。

Error: invalid --json-schema: ... is not valid against metaschema:
  at '/properties/handoff/properties/files/items/pattern':
  '^artifacts/[^./][^]*$' is not valid regex:
  error parsing regexp: invalid escape sequence: `\u`

そのCLIの正規表現エンジンはGo製で、\u エスケープを解釈しない。同じSchemaを複数のエンジンに食わせるなら、いちばん制約の厳しいものに合わせるしかない。

パターンを移植性のある形に直し、Schemaに \u が現れたらテストが落ちるようにした。

strict モードの要求

別のCLIに同じSchemaを渡すと、こうなった。

400 invalid_json_schema:
  'required' is required to be supplied and to be an array
  including every key in properties. Missing 'files'.

そちらのstructured outputsは、required に全プロパティを含めることを要求する。さらに maxLength / pattern / maxItems を解釈しない。

2つのSchemaを手で管理するとずれるので、正規Schemaから機械的に変換する関数を書いた。任意だったフィールドは null 許容にして意味を保つ。

def to_strict_schema(schema: dict) -> dict:
    # required に全キーを入れ、非対応キーワードを落とし、
    # 元は任意だったフィールドを null 許容にする

Cloudflareに弾かれる

最後の関門はDiscordだった。ジョブは完了する。ログにも成功と出る。なのに何も投稿されない。

例外を握りつぶしていたので原因が見えなかった。手動でHTTPリクエストを投げて、ようやく分かった。

HTTP 403
error code: 1010

DiscordのAPIの前段にいるCloudflareが、Python標準ライブラリの既定User-Agent(Python-urllib/3.14)を拒否していた。正規のUser-Agentヘッダを付けたら204で通った。

このとき同時に、失敗を黙って飲み込む設計が間違いだったと気づいた。「投稿に失敗しても成果物はローカルに残す」という方針自体は正しいが、理由をログに残さないのはただの隠蔽である。


テストが見つけた、禁止コマンド検査の抜け穴

エージェントに実行させてはいけないコマンドを検査する仕組みを書いた。書いた直後は自信があった。テストを書いたら、3つ素通りした。

入力なぜ見逃したか
ls \| xargs 禁止コマンド実行本体が行頭に来ない
echo `禁止コマンド`バッククォートの中を見ていない
ls + 改行 + 禁止コマンド改行をコマンド境界として扱っていない

一方、$(...)bash -c "..."env FOO=1 禁止コマンド/usr/bin/禁止コマンドgit "push"(引用符での分断)は最初から防げていた。防げているものと抜けているものの区別に、自分の直感はまったく役に立たなかった。

対策として、xargstimeout のようにコマンドを引数として受け取るラッパを認識し、その場合はすべてのトークンを実行体候補として検査するようにした。バッククォートの中身は再帰的に検査し、改行は別コマンドとして分割する。

教訓。 文字列に禁止語が含まれるかではなく、シェルの構文上どの位置からコマンドが実行され得るかで考える必要がある。そして自分の防御は必ずテストで殴ってみる。


この記事の使い方 ― 真似して作る人へ

ここに書いたことは、正直に言って細かい。半分も分からなくても構わない。

同じようなものを作るつもりなら、この記事をそのままAIに読み込ませて、一緒に作ることを勧める。

理由がある。ここに並べた罠は、どれも実際に動かすまで気づけない種類のものだ。設計の段階で「終了コードが0でも成功とは限らない」と自力で思いつくのは難しい。だが、この記事を渡されたAIは思いつく。「ここは実機で確かめましょう」と言ってくれる。それだけで、私が1日かけて踏んだ穴のいくつかは避けられる。

読ませ方にコツがある。「このオプションを使え」ではなく「この種の落とし穴があるから確認せよ」として読ませることだ。

本記事の内容は2026年8月時点のものである。CLIのオプション名も、エラーメッセージも、いずれ変わる。しかし罠の種類は変わらない。 成功を装う失敗はなくならないし、環境の隔離しすぎ・しなさすぎの綱引きもなくならない。プロンプトが実質的な権限として働くことも、当分は変わらないだろう。

つまりこの記事の使いどころは、具体的なフラグの綴りではなく、確認すべき観点のリストである。下のチェックリストをAIに渡し、「これを1つずつ潰しながら作って」と頼むのが、いちばん効率が良いと思う。

そして、もし新しい罠を踏んだら、それは私が知らない罠である。ぜひ書き残してほしい。


まとめ ― 同じことをやる人へのチェックリスト

1日で200件のテストを書き、そのうちのいくつかが自分で書いたバグを5つ見つけた。禁止コマンド検査の抜け穴3件、DBの並行性1件、停止処理の競合1件である。どれもコードを読んでいて気づいたものではない。

外部CLIを組み合わせて何かを作るなら、以下を確かめることを勧める。

起動まわり

  • --help を通しで読み、使うオプションを1つずつスモークテストで確認したか
  • 子プロセスに渡す環境変数を最小限に絞ったか
  • 標準入力を塞いだか(開いたままだと追加入力として読むCLIがある)
  • 認証情報の在り処を把握し、それを隔離していないか

結果の判定

  • 終了コード0を成功と見なしていないか
  • status: SUCCESS を成功と見なしていないか
  • 空応答を検出しているか
  • 申告された成果物の実在を確認しているか
  • 権限拒否のログを残しているか

プロセス制御

  • 独立したプロセスグループで起動しているか
  • タイムアウトで確実に終了させられるか(子プロセスを含めて)
  • 停止処理と状態更新が競合しないか
  • プロセスID再利用による誤停止を防いでいるか

指示

  • 権限設定と、プロンプト・役割定義の記述が矛盾していないか
  • 複数エージェントに同じ原文を渡していないか(先頭が全部やってしまう)

外部との境界

  • Schemaを複数のエンジンに食わせるなら、いちばん厳しいものに合わせたか
  • HTTPリクエストに正規のUser-Agentを付けたか
  • 通知の失敗を握りつぶしていないか

最後にひとつ。AIエージェントは嘘をつかない。ただ、こちらの想定と違うことを、確信を持ってやることがある。 だから確認する。この一点さえ設計に入っていれば、上の罠のほとんどは検出できる側に回る。


関連記事: 「サブスクのまま4つのAIを統率する ― APIを使わないマルチエージェント基盤を作った」

コメント

タイトルとURLをコピーしました