権限確認プロンプトをChannelsで中継する実装手順(Claude Code)
Claude Code Channelsの権限確認プロンプトをチャットへ中継する実装です。4ステップのリレーの仕組みと、リクエストIDのサニタイズ履歴をコード付きでまとめました。
権限確認プロンプトの中継とは何か
Bashコマンドの実行やファイル編集をClaudeが求めると、ローカルの承認ダイアログが開き、セッションはユーザーの応答を待って止まります。手元にいなければ、この待ち時間はそのまま作業の停止時間になります。権限中継(permission relay)は、同じ承認プロンプトをTelegramやDiscordのようなチャットへも同時に転送し、スマートフォンから承認・拒否できるようにする仕組みです。
中継対象はBash・Write・Editのようなツール使用の承認です。プロジェクトの信頼確認やMCPサーバーの利用同意ダイアログは中継されず、ローカルターミナルにしか出ません。
この記事はChannels reply toolを実装するで組み立てた二方向チャットブリッジに、権限中継を追加する前提で進めます。reply toolがまだ無い場合は先にそちらを実装してください。
中継の4ステップ
権限プロンプトが開くと、以下の4ステップでリレーが進みます。
- Claude Codeが5文字のリクエストIDを生成し、チャネルサーバーに通知する
- サーバーがプロンプトとIDをチャットアプリへ転送する
- リモートのユーザーがそのIDを添えてyes/noを返信する
- サーバー側のハンドラーが返信を判定に変換し、Claude Codeは開いているリクエストのIDと一致する場合だけそれを適用する
ローカルのターミナルダイアログはこの間ずっと開いたままです。ターミナルで先に応答すればそちらが優先され、リモートからの保留中リクエストは破棄されます。逆にリモートから先に応答が届けば、それが適用されてローカルダイアログは閉じます。早い者勝ちで、どちらか一方だけが効きます。
Claude Code v2.1.234以降は、そのセッションでチャネルとして登録されたサーバーにしか権限リクエストを送りません。つまり中継は、メッセージ配信と同じ--channelsによるセッションのオプトインと組織のポリシー(Enterprise controls)の両方に縛られます。加えて、サーバー側が権限capabilityを宣言していなければ中継自体が始まりません。
リクエストの中身とサニタイズ
Claude Codeから送られる通知はnotifications/claude/channel/permission_requestで、4つの文字列フィールドを持ちます。
| フィールド | 内容 |
|---|---|
request_id | 内容a-zからlを除いた小文字5文字。電話で入力しても1やIと誤読しない設計 |
tool_name | 内容BashやWriteなど、Claudeが使おうとしているツール名 |
description | 内容何をする呼び出しかの要約。Bashではコマンドの説明文で、Claudeが説明を付けなければ定数Run shell commandになり、コマンド自体の情報は一切含まれない |
input_preview | 内容ツール引数をJSON形式に近い表示テキストにしたもの。Bashならコマンド本体、Writeならファイルパスと内容 |
descriptionはコマンドの詳細を保証しないため、承認可否を判断できる情報はinput_preview側にあります。表示領域が許すならinput_previewも一緒に出すのが安全です。
このフィールドには2段階のサニタイズ強化が入っています。
- v2.1.211以降: 方向反転文字・不可視文字・引用符やかっこの見た目を似せた文字を無害化し、空白の連続を1つに畳み、3,500コードポイントを超える値は先頭と末尾を残して
⋯ N code points elided ⋯で省略する。input_previewはトップレベルのフィールドごとにこの上限を適用する - v2.1.234以降: 循環構造や極端に大きな配列など安全にシリアライズできない
input_previewのフィールド値は(value unserializable)という文字列で中継される。この場合もキー自体は届き、他のフィールドの値は変わらない。加えて、APIキーや個人アクセストークンのような認証情報らしき文字列を[REDACTED]にマスクする。このマスキングはキー名自体も対象になるため、表示されたキー名が実際の入力のキー名と一致しないことがある。ただしシェル構文・パス文字・URL文字を含む範囲はマスクされない(コマンドや宛先そのものを隠さないため)。プレフィックスを持たない秘密情報や、複数行にまたがる秘密鍵ブロックのようなものはマスクされずそのまま届く
v2.1.211より前のクライアントはdescriptionを無加工で中継し、input_previewは200 UTF-16単位で切り詰めていました。中継先のクライアントバージョンを制御できない環境では、この2フィールドを常に信頼できない入力として扱うのが安全です。マスクは配信先の制限とは別物で、これらのフィールドが届くのはあくまで--channelsか開発フラグでオプトインしたサーバーだけです。
request_idはローカルのターミナルダイアログには表示されません。このIDを知る手段はチャネルサーバーの通知ハンドラーだけなので、サーバー側でプロンプトにIDを含め忘れると、リモートのユーザーはどのリクエストに答えればいいか分からなくなります。ログにIDを残しておくと、後から「どの承認がどのツール呼び出しに対応していたか」を追いやすくなります。
返信側の判定はnotifications/claude/channel/permissionで送ります。request_id(エコーバック)とbehavior('allow'または'deny')の2フィールドだけです。allowはツール呼び出しを進め、denyはローカルダイアログでNoを押したのと同じ扱いになります。どちらの判定も以降の呼び出しには影響しません。
チャットブリッジに権限中継を足す
実装は3つの部品でできています。
Serverコンストラクタのexperimentalcapabilitiesにclaude/channel/permission: {}を追加するnotifications/claude/channel/permission_requestのnotificationハンドラーを登録し、プラットフォームへプロンプトを整形して送る- 受信メッセージハンドラーに、
yes <id>/no <id>形式を検出して判定通知を送るチェックを差し込む
capabilityを宣言する
capabilities: {
experimental: {
'claude/channel': {},
'claude/channel/permission': {}, // 権限中継にopt-in
},
tools: {},
},Claude Code v2.1.234より前は、この値をfalseにしてもcapabilityが「宣言されている」扱いになるバグがありました。オプトアウトしたい場合はキー自体を省略します。
受信ハンドラーを書く
import { z } from 'zod'
const PermissionRequestSchema = z.object({
method: z.literal('notifications/claude/channel/permission_request'),
params: z.object({
request_id: z.string(),
tool_name: z.string(),
description: z.string(),
input_preview: z.string(),
}),
})
mcp.setNotificationHandler(PermissionRequestSchema, async ({ params }) => {
send(
`Claude wants to run ${params.tool_name}: ${params.description}\n` +
`${params.input_preview}\n\n` +
`Reply "yes ${params.request_id}" or "no ${params.request_id}"`,
)
})setNotificationHandlerはmethodフィールドのリテラルでディスパッチするため、このZodスキーマはバリデーションとルーティングキーを兼ねています。
返信を判定として解釈する
// "y abcde" / "yes abcde" / "n abcde" / "no abcde" にマッチ
// [a-km-z]はClaude Codeが使うID用アルファベット(小文字、lを除く)
const PERMISSION_REPLY_RE = /^\s*(y|yes|n|no)\s+([a-km-z]{5})\s*$/i
async function onInbound(message: PlatformMessage) {
if (!allowed.has(message.from.id)) return // 送信元の検証が先
const m = PERMISSION_REPLY_RE.exec(message.text)
if (m) {
await mcp.notification({
method: 'notifications/claude/channel/permission',
params: {
request_id: m[2].toLowerCase(),
behavior: m[1].toLowerCase().startsWith('y') ? 'allow' : 'deny',
},
})
return // 判定として処理したので通常チャットへは転送しない
}
// 判定形式にマッチしなければ、通常のチャット転送に回す
await mcp.notification({
method: 'notifications/claude/channel',
params: { content: message.text, meta: { chat_id: String(message.chat.id) } },
})
}/iフラグはスマートフォンの自動大文字化を許容するためで、送り返す前に必ず小文字化します。フォーマットが一致しない返信(approve itのような自然文や、IDなしのyes)は通常のチャットメッセージとしてClaudeへ転送されます。逆にフォーマットは正しくてもIDが一致しなければ、Claude Codeはその判定を黙って破棄します。どちらの場合もローカルのダイアログは開いたままです。
動作確認 — 手動モードで承認ダイアログを開く
自動モードだと分類器がツール呼び出しの可否を先に決めてしまい、承認ダイアログ自体が開きません。中継の動作を確認するには、セッションを手動モードに切り替えます。3つのターミナルを使って、セッション起動・イベント購読・指示送信・承認返信の流れを順に確認します。
claude --dangerously-load-development-channels server:webhookShift+Tabを押し続けてステータスバーが「⏸ manual mode on」になるまで切り替える別ターミナルでイベントストリームを購読し、3つ目のターミナルからファイル一覧のような読み取り専用の指示を送ります。
curl -d "list the files in this directory" -H "X-Sender: dev" localhost:8788-H "X-Sender: dev"は送信元を示すヘッダーですが、reply toolを実装する記事のサンプルサーバーには送信元を検証する処理が無いため、このヘッダー自体は動作に影響しません。読み取りだけなら承認なしで実行されますが、そのあとClaudeがreplyツールで結果を返そうとした瞬間に権限ダイアログが開き、/events側にリクエストIDが流れます。3つ目のターミナルから、そのIDを添えて承認を返します。
curl -d "yes <id>" -H "X-Sender: dev" localhost:8788このリクエストが届くと、ローカルのダイアログが閉じてreplyツールが実行され、Claudeの返信がストリームに乗ります。
よくあるつまずき
input_previewを省略してdescriptionだけ表示する
Bashの呼び出しでClaudeが説明を付けなかった場合、descriptionは定数Run shell commandになり、コマンド自体の情報を一切含みません。表示領域に余裕があるならinput_previewも一緒に送り、判断材料ゼロで承認を迫る事態を避けます。
z.string()で受けた値をそのまま信頼する
Zodスキーマは型のバリデーションであって内容の安全性は保証しません。descriptionとinput_previewはサニタイズ済みでも「信頼できない入力」として扱い、プラットフォーム側の表示エスケープを別途行います。
判定処理を通常チャットの転送より後ろに置いてしまう
受信ハンドラーではPERMISSION_REPLY_REのマッチ確認を先に行い、一致したらそこで処理を終えてチャットへは転送しません。この順序を逆にすると、yes abcdeのような返信がそのままClaudeへの通常メッセージとして渡ってしまい、判定として扱われません。
権限中継が前提にしているもの
権限中継はメッセージ配信より一段強い権限をチャネルに与えます。承認できる相手を増やすということは、セッションを乗っ取れる相手を増やすことと同義です。だからこそ公式ドキュメントも「送信元を検証済みのチャネルでだけ宣言する」ことを明記しています。Telegram・Discordのペアリングや、グループチャットでのfrom.idとchat.idの取り違えといった落とし穴も含めて、Channelsの受信メッセージをゲートする設計で扱っています。
権限中継自体はv2.1.81で追加された機能です。ただし、そのあとの2回のセキュリティ修正が実質的な信頼境界を作っています。v2.1.211でプロンプトインジェクションを狙った特殊文字の無害化が入りました。v2.1.234では認証情報のマスキングと、チャネルとして登録されていないサーバーには権限リクエストを送らない信頼ゲートが加わりました。この2つのバージョンより前のクライアントでリレーを使う場合は、descriptionとinput_previewをより強く疑ってかかる必要があります。
まとめ
権限中継は、reply toolによる二方向チャットブリッジと、送信元を検証する受信ゲートの両方が揃って初めて安全に運用に載ります。片方だけでは、承認プロンプトが届くだけで終わるか、検証していないチャネルの誰でもツール使用を承認できてしまう状態のどちらかになります。v2.1.234以降のクライアントで、claude/channel/permissioncapabilityを検証済みチャネルでのみ宣言する構成が最小の実用ラインです。
よくある質問
権限中継とreply toolは別々に実装できますか
reply toolだけの二方向チャネルは成立します。権限中継はその上に追加する任意の機能で、claude/channel/permissioncapabilityを宣言しなければ従来通りローカルターミナルだけで承認します。
複数の端末から同時に応答したらどうなりますか
Claude Codeは最初に届いた応答だけを適用し、残りは無視します。ローカルとリモートの両方が有効な間は常にこの早い者勝ちのルールです。
request_idをログに残しても安全ですか
IDだけなら5文字のランダム文字列で、有効なリクエストが開いている間しか意味を持ちません。ただしdescriptionやinput_previewは機密情報を含みうるため、ログに残す場合は別途マスキングを検討してください。
Bedrock経由のセッションでも権限中継は使えますか
使えません。Channelsそのものが、Amazon Bedrock・Google Cloud's Agent Platform・Microsoft Foundry経由の認証に対応していないためです。そのため権限中継を含むChannelsの全機能は、claude.aiアカウントかConsole APIキーでの利用に限定されます。