Agent SDKのpermission_deniedイベントで拒否をUIに出す方法
permission_deniedが出る条件は、canUseToolやMCPプロンプトツールの有無で変わります。PreToolUse経由の拒否が出ない点と、permissionPrompts: 'none'の使いどころを示します。
Agent SDKのTypeScript版には、権限システムが確認なしでツール呼び出しを拒否したときに流れる permission_denied というシステムメッセージがあります。型名は SDKPermissionDeniedMessage です。拒否を画面やログに出したいとき、後から届く is_error のツール結果を待たずに済みます。
ただし、このイベントはどの構成でも同じように出るわけではありません。canUseTool の有無、MCPのプロンプトツール、permissionPrompts: 'none' の組み合わせで、報告される拒否の範囲が変わります。フックで決まった拒否は出ません。取りこぼしもあります。この記事では、その条件を表にして、受け取るコードと突き合わせ方まで示します。
permission_deniedは何を知らせるイベントか
SDKPermissionDeniedMessage は、権限システムが対話的な確認なしにツール呼び出しを拒否したときに出るストリームイベントです。UI側から見れば「いま拒否された」を即座に描画するための材料になります。
型は次の形です。
type SDKPermissionDeniedMessage = {
type: "system";
subtype: "permission_denied";
tool_name: string;
tool_use_id: string;
agent_id?: string;
decision_reason_type?: string;
decision_reason?: string;
message: string;
uuid: UUID;
session_id: string;
};各フィールドの意味は次のとおりです。
| フィールド | 内容 |
|---|---|
tool_name | 内容拒否されたツールの名前 |
tool_use_id | 内容この拒否が応答している tool_use ブロックのID |
agent_id | 内容拒否された呼び出しがサブエージェント内で起きたときのID |
decision_reason_type | 内容決定した部品の種別。"rule"、"mode"、"classifier"、"asyncAgent" など |
decision_reason | 内容決定した部品からの人間向けの理由(あるとき) |
message | 内容tool_result でモデルに返される拒否メッセージ |
decision_reason_type は「なぜ止まったか」を分類するのに使えます。ルールで止まったのか、モードで止まったのか、分類器で止まったのかを、UIの文言やログの重大度に振り分けられます。message はモデルに返る文面なので、人間向けの説明には decision_reason を優先して表示する構成が自然です。
どの構成でイベントが出るのか
出るかどうかは、権限確認をだれが受けるかで決まります。公式の型リファレンスにある4つの構成を並べます。
| 構成 | イベントの扱い |
|---|---|
canUseTool あり・permissionPrompts は既定の 'host' | イベントの扱い確認はコールバックに行く。イベントは、コールバックを呼ばずにClaude Code自身が決めた拒否を報告する |
canUseTool も permissionPromptToolName も無い | イベントの扱い確認になる呼び出しは拒否される(PermissionRequest フックが許可した場合を除く)。イベントは、その拒否もClaude Code自身が決めた拒否も報告する |
MCPプロンプトツールあり(permissionPromptToolName または --permission-prompt-tool)・permissionPrompts は 'host' | イベントの扱いイベントは一切出ない。ルールで自動的に決まった拒否でも出ない |
permissionPrompts: 'none' | イベントの扱い確認になる呼び出しは、canUseTool やMCPプロンプトツールがあっても拒否される。イベントは、その拒否もClaude Code自身が決めた拒否も報告する |
見落としやすいのは3行目です。MCPのプロンプトツールで確認を外部に出している構成では、ルール拒否を含めてイベントがまったく流れません。拒否をUIに出したいなら、この構成ではイベントに頼れません。
2行目には版の条件があります。コールバックの無い実行でイベントが出るのは、Claude Code v2.1.223以降です。それ以前のClaude Codeは、コールバックの無い実行でこのイベントを出しませんでした。
1行目の「自分で決めた拒否」とは
canUseTool を渡している構成では、確認が必要な呼び出しは canUseTool に届きます。そこで behavior: "deny" を返したのは自分のコードなので、イベントで知らせてもらう必要はありません。イベントになるのは、コールバックまで届かなかった拒否です。たとえばdenyルールに一致した呼び出しや、モードやルールで先に決まる拒否がこれにあたります。
canUseTool で承認を処理する側の実装は、Agent SDK canUseToolで承認と確認質問を処理するにあります。イベントはその補完で、コールバックに届かない拒否を拾う経路だと考えると整理しやすくなります。
イベントを受け取って画面に出す
query() が返すストリームを回しながら、type と subtype で絞ります。次のコードは例示です。公式の型定義に沿って書いていますが、そのまま動かした出力を保証するものではありません。
import { query } from "@anthropic-ai/claude-agent-sdk";
const denied: { tool: string; id: string }[] = [];
for await (const message of query({
prompt: "テストを実行して結果を要約して",
options: { permissionMode: "default" },
})) {
if (message.type === "system" && message.subtype === "permission_denied") {
denied.push({ tool: message.tool_name, id: message.tool_use_id });
renderDenied({
tool: message.tool_name,
reason: message.decision_reason ?? message.message,
kind: message.decision_reason_type,
fromSubagent: message.agent_id !== undefined,
});
}
}renderDenied はUI側で用意する関数です。ここでは次の3点を意識しています。
- 理由は
decision_reasonを優先し、無ければmessageにフォールバックする。 decision_reason_typeで見た目を分ける。ruleは設定の話、classifierは自動判定の話として扱える。agent_idがあればサブエージェント由来と分かるので、どのエージェントが止まったかを併記できる。
tool_use_id は、直後に届く is_error のツール結果と対応づけるキーにもなります。イベントで先に「拒否」を描き、あとでツール結果が来たら同じIDの行を更新する、という流れが作れます。
permissionPrompts: 'none' で確認を一律拒否にする
permissionPrompts は 'host' か 'none' を取るオプションで、既定は 'host' です。'host' は確認を canUseTool コールバックか permissionPromptToolName のツールに回します。'none' は、確認になるはずだった呼び出しを拒否します。Claude Code v2.1.259以降が必要です。
ホストが人間の応答を待てない無人実行で効きます。確認待ちで止まらず、拒否としてモデルに返って実行が続きます。
for await (const message of query({
prompt: "依存パッケージのピンを更新してテストを回して",
options: {
permissionMode: "auto",
permissionPrompts: "none",
},
})) {
// permission_denied を拾ってログへ
}押さえておく点が3つあります。
'none'でも先に効くものがある。権限ルール、PermissionRequestフック、設定した権限モードが先に判断し、どれも決めなかった呼び出しだけが拒否される。PermissionRequestフックが許可すれば、その呼び出しは通る。canUseToolを一緒に渡しても、'none'ならコールバックは呼ばれない。権限評価の最後の段階で、コールバックの代わりに拒否が返る。- 拒否はイベントで報告される。MCPプロンプトツールを併用していても、
'none'ならイベントが出る。3行目の構成との違いはここにある。
CLIでは --permission-prompts none が同じ役割です。ヘッドレス実行のドキュメントには、--output-format stream-json のとき拒否が permission_denied のシステムメッセージとして現れ、最終の結果メッセージの permission_denials に一覧されるとあります。モードとルールの評価順は、Agent SDKのパーミッション制御で扱っています。そこで述べている permissionPrompts: 'none' の仕様を、イベントの側から見るのがこの記事の立ち位置です。
イベントに載らない拒否と取りこぼし
公式の説明には、イベントが載せない拒否が2種類あります。
PreToolUseフックの経路で決まった拒否は、どの構成でも出ません。フックが自分で呼び出しを拒否した場合も、フックのallowやaskの判断をdenyルールが上書きした場合も同じです。フックで拒否の理由を作っている構成では、UIに出す材料をフック側から別に送る必要があります。
イベントはベストエフォートです。Claude Codeが拒否を記録しながら、イベントを出さないことが稀にあります。公式はこのため、結果メッセージの permission_denials を「正本の記録」としています。
結果メッセージと突き合わせる
結果メッセージの permission_denials は、SDKPermissionDenial の配列です。各要素は次の3つを持ちます。
type SDKPermissionDenial = {
tool_name: string;
tool_use_id: string;
tool_input: Record<string, unknown>;
};イベントで集めた tool_use_id と、結果メッセージのIDを比べれば、取りこぼしを検出できます。例示として次の形が考えられます。
if (message.type === "result") {
const seen = new Set(denied.map((d) => d.id));
const missed = message.permission_denials.filter(
(d) => !seen.has(d.tool_use_id),
);
for (const d of missed) {
renderDenied({ tool: d.tool_name, reason: "イベント未受信", kind: undefined });
}
}イベントは速報、結果メッセージは確定値という役割分担です。リアルタイム表示はイベントで行い、実行の最後に結果メッセージで不足分を補います。tool_input はイベントには無く、結果メッセージ側にだけあります。何を実行しようとして止まったかを後から見せたいなら、こちらを使います。
イベントが届かないときの切り分け
拒否されたのにUIへ何も出ないとき、次の順で確かめられます。
- MCPプロンプトツールを使っていないか。
permissionPromptToolNameや--permission-prompt-toolがあり、permissionPromptsが既定の'host'なら、イベントは出ません。 - 拒否がフック経由ではないか。PreToolUseフックが決めた拒否は、イベントになりません。
- Claude Codeのバージョン。コールバックの無い実行でイベントを期待するならv2.1.223以降です。
permissionPromptsを使うならv2.1.259以降です。 - 結果メッセージに載っているか。
permission_denialsに載っているのにイベントが無ければ、取りこぼしです。
permissionPrompts: 'none' を指定しても、v2.1.259より前のClaude Codeでは使えません。CLIフラグ側では、古い版は不明なオプションとしてエラーで終わります。SDKでエラーが出たときの一般的な切り分けは、Agent SDKのエラー集にあります。
拒否の後でモデルはどう動くか
拒否は、モデルへ is_error のツール結果として返ります。message フィールドはそのときの文面です。イベントを出しても、モデルへの返答は変わりません。
permissionPrompts: 'none' のときは、確認を受けられる人がいないこと、再試行しないことをClaudeに伝えたうえで実行が続きます。同じ拒否を繰り返してターンを使い切る事態を避ける設計です。ターンと上限の考え方は、Agent SDKのエージェントループの記事が詳しいです。
また 'none' ではAskUserQuestionのような人間の回答を要するツールが外れ、MCPのelicitationはElicitationフックが答えない限りキャンセルされます。ヘッドレス実行のドキュメントにある挙動で、SDK側のオプションでも同じ前提で設計しておくと迷いません。
拒否が生まれる段階とイベントの関係
権限の評価は、フック、denyルール、askルール、権限モード、allowルール、canUseTool の順に進みます。拒否はこのどこでも生まれますが、イベントと関係が深いのは次の段階です。
- denyルール: 一致した時点でブロックされます。
bypassPermissionsモードでも同じです。Bashのような名前だけのdenyルールはツール自体をClaudeの文脈から外すため、この段階では評価されません。範囲指定のあるBash(rm *)のようなルールが評価されます。 - 権限モード:
dontAskモードは確認を出さないモードで、確認になるはずの呼び出しを拒否します。autoモードのAgent SDKセッションでは、rmやrmdirの重要パスへの削除が、コールバックを呼ばずに既定で拒否されます。 - 最後の
canUseTool:dontAskモードではこの段階が飛ばされて拒否になります。permissionPrompts: 'none'ではコールバックの代わりに拒否が返ります。
dontAsk と permissionPrompts: 'none' は、どちらも「確認になる呼び出しを拒否する」点で似ています。違いは指定の置き場所です。dontAsk は権限モードなので、ほかのモードの自動承認を使えません。'none' は確認の宛先だけを変えるオプションなので、auto モードの分類器のような仕組みは生かしたまま、最後に残った確認だけを拒否にできます。ヘッドレス実行のドキュメントも、auto モードと組み合わせた無人実行を例にしています。
イベントの decision_reason_type に "mode" や "rule" が入るのは、こうした段階で決まった拒否を区別するためだと読み取れます。公式の説明は種別の例を挙げるにとどまり、どの段階がどの値になるかの対応表は載せていません。UIで種別ごとの文言を出し分けるときは、未知の値を既定の表示に落とす分岐を用意しておくと安全です。
使い分けの早見表
| やりたいこと | 向く構成 |
|---|---|
| 人が承認する。拒否もUIに出す | 向く構成canUseTool + 既定の 'host'。自動拒否はイベントで受ける |
| 人がいない実行で、確認を止めずに拒否する | 向く構成permissionPrompts: 'none'。拒否はイベントと permission_denials に残る |
| 外部のMCPツールで承認する | 向く構成permissionPromptToolName。拒否の通知はイベントに頼れない |
| 全呼び出しを自前で検査する | 向く構成PreToolUseフック。ただし拒否はイベントに載らない |
まとめ
permission_denied は、権限システムが自動で決めた拒否をUIに流す速報です。MCPプロンプトツールの構成では出ず、PreToolUse経由の拒否も出ません。取りこぼしもあるので、最終的な記録は結果メッセージの permission_denials を正とします。
無人実行では permissionPrompts: 'none' で確認待ちを拒否に変え、イベントで拾って結果メッセージで確定させる組み合わせが使えます。バージョンの目安はイベントがv2.1.223以降、オプションがv2.1.259以降です。