はじめに
Slack Botや社内ツールから claude-agent-sdk の ClaudeSDKClient を呼び出し、ユーザーが指定したセッションIDで会話を再開する仕組みを作ったことがある人はどれくらいいるだろうか。resume や session_id にはUUID文字列を渡す想定だが、そこにUUID以外の文字列——たとえば外部連携やWebhook経由で紛れ込んだ値——が入ってくる可能性を考えたことは意外と少ない。
claude-agent-sdk-python のCHANGELOGを眺めていたところ、v0.2.121に「argv フラグインジェクションの修正」という一文を見つけた。resume と session_id に渡した値がハイフンで始まっていると、SDKが組み立てるコマンドライン上で独立したフラグとして誤解釈される、という内容だ。これが実際どのコードで起きていて、いま手元の最新版ではどう直っているのか、隔離環境にSDKを入れて実際に確かめた。
この記事で分かること
-
claude-agent-sdk-pythonのresume/session_idに起きていたargvインジェクションの仕組み - 修正後のバージョンで実際に組み立てられるコマンドライン引数を実機で確認した結果
-
claude --helpから読み取れる、--resumeが「オプション値」フラグである根拠 - SDK利用者が今後も気をつけるべき実務上のポイント
対象読者
-
claude-agent-sdk(Python/TypeScript)でエージェントを組んでいる人 - ユーザー入力やDBの値をそのままCLI引数に渡す設計をしたことがある人
- SDKのCHANGELOGを「バンドルCLI更新」だけ見て読み飛ばしがちな人
前提環境
- 実行環境: Claude Code on the web(クラウド実行環境・コンテナ)
- Python: 3.11(
python3 -m venvで作成した隔離venv) -
claude-agent-sdk: 0.2.123(pip install claude-agent-sdkで当セッション時点の最新版としてインストールされたもの) -
claudeCLI: 2.1.211(実行コンテナに事前インストールされていたバージョンをclaude --versionで確認。SDKにはバージョン2.1.215がバンドルされている表記だが、shutil.which("claude")で見つかるシステム側CLIとは別物)
TL;DR
-
resume/session_idは以前、cmd.extend(["--resume", value])という2トークン形式でCLIに渡されていた -
--resumeはCLI側で「オプション値」(-r, --resume [value])として宣言されており、値がハイフンで始まると次の独立したフラグとして解析されてしまう - v0.2.121(PR #1123)で
cmd.append(f"--resume={value}")という=結合の1トークン形式に修正された - 隔離環境で0.2.123を実際にインストールし、
SubprocessCLITransport._build_command()を直接呼び出して、ハイフン始まりの値でも1トークンに束縛されることを確認した - 同じ修正が
session_id(--session-id <uuid>・必須値フラグ)にも横展開されている
何が起きていたのか: argvインジェクションの仕組み
CLIの引数解析には、フラグの後ろの値が「必須」か「オプション」かという区別がある。claude --help で該当箇所を確認すると、次のように書かれている。
-r, --resume [value] Resume a conversation by session ID, or
open interactive picker with optional
search term
--session-id <uuid> Use a specific session ID for the
conversation (must be a valid UUID)
[value] は角括弧、つまりオプション値であることを示す。<uuid> は山括弧で必須値だ。オプション値のフラグをGNUスタイルの2トークン形式(--resume xxx)で渡すと、パーサーは「次のトークンがハイフンで始まっていたら、それは値ではなく別のフラグの可能性が高い」と判断し、xxx を --resume の値として結びつけない実装が一般的である。
claude-agent-sdk-python のマージ済みPR #1123(2026-07-16マージ)によると、修正前の SubprocessCLITransport._build_command() は以下のように2トークン形式でコマンドを組み立てていた。
# 修正前(PR #1123 の記載より・二次情報)
cmd.extend(["--resume", self._options.resume])
cmd.extend(["--session-id", self._options.session_id])
この状態で resume にたとえば --dangerously-skip-permissions のような、既存の別フラグと同じ文字列を渡すと、CLI側からは「--resume の値」ではなく「独立した --dangerously-skip-permissions フラグ」として渡されたように見えてしまう。resume や session_id に外部由来の文字列をそのまま渡す設計をしていた場合、意図しないCLIフラグを注入できる余地があったことになる。
実機検証: 修正後のargvを実際に組み立ててみる
修正が実際にどう効いているのかを、インストール済みの0.2.123で直接確認した。SubprocessCLITransport は内部クラスだが、コマンド組み立てだけならサブプロセスを起動せずに呼び出せる。
from claude_agent_sdk import ClaudeAgentOptions
from claude_agent_sdk._internal.transport.subprocess_cli import SubprocessCLITransport
malicious_resume = "--dangerously-skip-permissions"
malicious_session_id = "--settings=/tmp/evil-settings.json"
options = ClaudeAgentOptions(resume=malicious_resume, session_id=malicious_session_id)
transport = SubprocessCLITransport(prompt="hello", options=options)
transport._cli_path = transport._find_cli() # connect() が本来行う解決処理を先取り
cmd = transport._build_command()
出力された実際のargv(該当部分のみ抜粋)は以下の通りだった。
[6] '--resume=--dangerously-skip-permissions'
[7] '--session-id=--settings=/tmp/evil-settings.json'
--resume に渡した --dangerously-skip-permissions という、既存フラグとまったく同じ文字列の値が、独立したトークンに割れることなく1つの引数として束縛されている。--session-id も同様に、--settings=... という別フラグ相当の文字列を値として渡しても、フラグとしては割り込まなかった。
該当箇所のソースには次のコメントが残っている。
# Pass these as --flag=value rather than as two argv tokens. The CLI
# declares --resume with an optional value, so in the two-token form a
# dash-leading value is not bound to the flag and is instead parsed as
# a separate CLI flag -- letting an untrusted value inject arbitrary
# flags. The equals form always binds the value to the flag.
if self._options.resume:
cmd.append(f"--resume={self._options.resume}")
if self._options.session_id:
cmd.append(f"--session-id={self._options.session_id}")
PR #1123の説明にも、修正後は「CLIがハイフンで始まる値を無効なセッションIDとして拒否する」とあり、実際に --session-id の <uuid> バリデーションに引っかかって弾かれる設計になっている。PR記載のテストは1058件パス・5件スキップで、既存の全テストスイートを壊さずに修正が入ったとのことだ。
ハマりどころ: --session-id は必須値フラグなのに、なぜ同じ修正が入ったのか
claude --help を見る限り --session-id は <uuid>(必須値)であり、--resume の [value](オプション値)と同じ理屈ではハイフン始まりの値を弾ききれない可能性がある。多くのCLIパーサーは必須値フラグであっても、次のトークンが既知の別フラグと完全一致する場合はそちらを優先することがあるため、--session-id 側は「同じ脆弱性クラスに確実に該当する」とまでは記事執筆時点のソースコード・PR記載からは断定できなかった。ただしPR #1123は --resume と --session-id の両方を対象に一括で = 結合形式へ変更しており、SDK開発チームが必須値フラグ側についても予防的に同じパターンへ統一したと読み取れる。挙動の断定よりも「疑わしきは両方直す」という設計判断がされている点は、外部入力をCLI引数に渡す実装をする上で参考になる。
実務での対策
claude-agent-sdk を使う側の対策として、今回の修正だけに頼らず以下を徹底したい。
-
resume/session_idに渡す値は、SDKに渡す前にUUID形式かどうかバリデーションする(--session-id <uuid>の仕様上、正規表現での事前チェックは容易) - ユーザー入力・Webhookペイロード・DBの値など、外部由来の文字列を検証なしでオプション引数にそのまま渡す設計を避ける(
claude-agent-sdkに限らず、サブプロセスにargvを渡すあらゆるライブラリで同じ注意が要る) -
claude-agent-sdkは 0.2.121以降 を使う(pip show claude-agent-sdkでインストール済みバージョンを確認できる)
著者視点の発見ポイント
CHANGELOGの「バンドルされたClaude CLIを更新」という淡々とした一行に埋もれがちだが、claude-agent-sdk-python はセキュリティ関連の修正もリリースの中に混在させて出している。今回実際にソースを読んで分かったのは、--resume [value](オプション値)と --session-id <uuid>(必須値)という宣言の違いを claude --help の角括弧・山括弧から読み取れることと、SDK側はこの違いに関わらず両方を同じ安全なパターンに統一していたことだ。CLIラッパー系のSDKを自作する際、「値は = で結合する」を既定にしておけば、この種のクラスの脆弱性はそもそも作り込まずに済む。
まとめ
-
claude-agent-sdk-pythonのresume/session_idは、v0.2.121(PR #1123)で2トークン形式から=結合の1トークン形式に修正された - 実機検証(0.2.123)で、ハイフン始まりの値を渡しても独立したCLIフラグとして分離されないことを確認した
-
--resume [value](オプション値)はGNUスタイルCLIパーサーの一般的な挙動としてこの種の注入を受けやすく、--session-id <uuid>(必須値)側にも予防的に同じ修正が適用されている - 外部由来の値をそのままCLI引数に渡す設計は、SDK側の修正に関わらずアプリ側でも事前バリデーションしておくべき
関連記事
- Claude Agent SDKのSessionStore、自作して分かった罠3つ
- Claude Agent SDK入門 — Claude Codeのエージェント基盤をPython/TSで活用する
- can_use_toolはallowed_toolsの丸ごと許可で呼ばれなくなっていた
参考リンク
- claude-agent-sdk-python CHANGELOG.md — v0.2.121〜v0.2.123の変更点
- PR #1123: Pass --resume and --session-id as a single argv token — 修正前後のコード・テスト内容
- claude-agent-sdk (PyPI) — インストール方法・バージョン一覧