Claude Codeのmodでtool.checkからサブエージェントと組織承認を見分ける
modのtool.checkフックは、v2.1.290からe.agentIdとe.ceilingを読めます。サブエージェントの呼び出しと、組織が承認を求めるコネクタツールを見分ける書き方と注意点です。
modのtool.checkフックは、ツール呼び出しを実行してよいかを決める場面に割り込めます。v2.1.290からは、そのイベントにe.agentIdとe.ceilingの2つのフィールドが加わりました。前者は「誰が呼んだか」、後者は「組織がそのツールに承認を求めているか」を示します。
2つのフィールドは、メインの会話以外が呼んだときと、組織がコネクタツールに承認を求めているときに値が入ります。この記事では、サブエージェントだけに厳しい規則を課すフックと、組織の承認要件を壊さないフックを書きます。
tool.checkはどこで動くイベントか
tool.checkは、Claude Codeがツール呼び出しの可否を決める最後の段階で発火します。権限ルールと、tool.call・PreToolUseのフックがすでに結論を出したあとです。next(e)を呼ぶと、そこまでの結論がallow・ask・denyのいずれかで返ります。
フックが返せるのは{ decision }で、値は同じ3つです。次のように書くと、結論を受け取ってから上書きするかどうかを選べます。
on('tool.check', { tool: 'Bash' }, async ($, e, next) => {
// ルールと設定フックが出した結論を先に受け取る
const decided = await next(e)
// 触らない呼び出しは、結論をそのまま返す
return decided
})tool.callとの違いは時点です。tool.callは実行の直前に引数を書き換えたり、結果を差し替えたりできます。tool.checkは可否の判定だけを扱います。引数にはe.inputでアクセスし、Bashならe.input.commandです。
modの基本構造はClaude Codeのmodを80行で作るで扱っています。
e.agentIdで呼び出し元を見分ける
e.agentIdは、サブエージェントかインプロセスのチームメイトが呼んだときに入ります。メインの会話が呼んだ呼び出しには入りません。
ここがポイントです。フィールドの有無がそのまま「メインか、それ以外か」の判定になります。
| 呼び出し元 | e.agentId |
|---|---|
| メインの会話 | e.agentIdなし |
| サブエージェント | e.agentIdあり |
| インプロセスのチームメイト | e.agentIdあり |
値の中身(文字列の形式)について、リファレンスには記載がありません。$.agent.list()が返すidと同じ種類のものとして扱えますが、形式を前提にした分岐は避け、有無だけで判定するのが安全です。
サブエージェントのgit pushだけを止める
サブエージェントには広い権限を持たせたくないが、メインの操作は妨げたくない、という場面を考えます。git pushをサブエージェントからは拒否し、メインは従来どおりにする例です。公式の例にある、ブランチで分岐するフックの形をそのまま使っています。
on('tool.check', { tool: 'Bash' }, async ($, e, next) => {
const decided = await next(e)
// メインの会話の呼び出しは、これまでの結論のまま通す
if (!e.agentId) return decided
if (!e.input.command.includes('git push')) return decided
return {
decision: 'deny',
reason: 'サブエージェントからは push できません。変更を報告してください。',
}
})メインの会話でも、サブエージェントでも、git pushを含まないコマンドは元の結論のままです。サブエージェントがgit pushを試みると拒否され、reasonのテキストは理由として返ります。
コマンド文字列の一致で判定しているので、あくまでClaudeへの注意喚起です。リモートへの書き込みを確実に防ぐなら、Gitホスト側でブランチを保護してください。
同じ区別ができる他のイベント
agentIdを持つのはtool.checkだけではありません。turn.stepとturn.completeにも入ります。サブエージェントのリクエストやターン終了で発火したときに、値がセットされます。
turn.step: サブエージェントが送るリクエストごとに発火し、トークン使用量を読めるturn.complete: サブエージェントのターンが終わるたびに発火するagent.spawn: サブエージェントやチームメイトの起動前に発火し、{ deny }で起動自体を止められる
「起動させない」ならagent.spawn、「起動は許すが特定のツール呼び出しを止める」ならtool.check、という使い分けです。トークン消費を見たいだけならturn.stepで足ります。
tool.callのイベントについては、リファレンスにagentIdの記載がありません。呼び出し元で分岐したいなら、tool.checkを使うのが確実です。
e.ceilingで組織の承認要件を読む
e.ceilingは、組織がコネクタのツールをaskに設定しているときにaskが入ります。claude.aiのコネクタに対して管理者がツール単位で設けた制御が、Claude Codeの権限判定に届いたことを示す値です。
組織の制御にはaskとblockedの2種類があり、次のように扱いが違います。
組織が設けるコネクタツールの制御
ask
呼び出しのたびに「Your organization requires approval for this tool」という理由で確認が出ます。acceptEdits・auto・bypassPermissionsでも出て、選択を記憶する選択肢はありません。
blocked
Claudeに見える前にツールが外れます。ツールの一覧に載らないので、tool.checkに呼び出しが届くことはありません。
つまりe.ceilingで観測できるのはaskの側だけです。blockedのツールは、そもそもClaudeが呼べません。
値が入らないケース
e.ceilingが入るのは、組織の設定がClaude Codeに届くセッションに限られます。デスクトップアプリのローカル・SSHセッションでは、askの設定がClaude Codeに届きません。そこではセッションの通常の権限ルールが適用され、毎回確認にもなりません。
このため、e.ceilingが空であることは「組織が承認を求めていない」ことを意味しません。デスクトップアプリ経由のセッションで、設定が届いていないだけの可能性があります。ceilingの有無だけで承認の要否を断定するフックは、この環境で見落としが出ます。
また、ceilingが取る値として、リファレンスに載っているのはaskだけです。blockedなどの別の値は載っていないので、e.ceiling === 'ask'の一致で判定してください。
ceilingを見るフックの書き方
組織がaskにしたツールの呼び出しを、modがどう扱うかが肝心です。自動承認する権限系のmodは、この呼び出しを素通しにしかねません。
権限のドキュメントは、設定ファイルのPreToolUseフックがallowを返しても、組織がaskにしたコネクタツールは確認が出ると説明しています。一方、modのtool.checkがallowを返した場合にどうなるかは、権限のドキュメントにも、modsのリファレンスのceilingの説明にも記載がありません。確実を期すなら、ceilingがaskの呼び出しではallowに書き換えないことです。
次のフックは、ceilingがaskの呼び出しには判定を触らず、そうでない呼び出しにだけ自前の規則を適用する形です。
on('tool.check', async ($, e, next) => {
const decided = await next(e)
// 組織が承認を求める呼び出しは、組織側の判定をそのまま返す
if (e.ceiling === 'ask') {
$.ui.log('組織の承認が必要なコネクタツールの呼び出しです')
return decided
}
// ここから先に、自分のチームの規則を書く
return decided
})$.ui.logは、Claudeには読まれない薄い行をtranscriptに足します。承認要件のある呼び出しがどれだけ流れているかを後から追うのに使えます。
matcherを付けない書き方は、すべてのツールが対象になります。MCPツールの名前を指す指定は、modsのリファレンスのtool.checkの説明に記載がありません。ツール単位で絞る必要があるなら、まず手元でeの中身を確かめてからにしてください。
返すdecisionで気をつける3点
modがallowを返すと、元の判定より緩くなる場面があります。次の3点を押さえておくと、意図しない素通しを避けられます。
allowを返す前に確認すること
askルールで確認が出るはずの呼び出しも、modが承認すれば確認なしで動く- auto modeでは、modが承認した呼び出しは分類器の確認なしで動く
- 管理設定の
PreToolUseフックによるブロックは、modでは覆せない
denyルールは、管理設定があるマシン、またはTeam・Enterpriseプランでサインインしている場合、modより優先されます。modがdenyルールで拒否される呼び出しを承認しようとすると、呼び出しは拒否されたままで、tried to lift a deny rule in your settingsという行が出ます。原因の切り分けはmodが動かないときの原因別トラブルシューティングにあります。
つまり、承認する方向に回すフックほど、decidedを返すか、denyで締めるかの2択に寄せたほうが安全です。緩める必要があるのは、判定がaskで、自分のチームが条件を明確に満たしていると確認できたときだけです。
フックが落ちたときの扱い
ブロックを担うフックは、失敗したときの挙動も決めておきます。tool.checkでは、nextが解決したあとに返した拒否は有効です。そのため、.catchハンドラではnext.calledを確かめずにdenyを返せます。
on('tool.check', { tool: 'Bash' }, guard).catch(async ($, e, next) => {
// 失敗したら拒否側に倒す(tool.checkでは next.called の確認は不要)
return { decision: 'deny', reason: '判定フックが失敗したため実行しません: ' + next.error.kind }
})claude plugin validateは、modが登録したゲート位置のフックごとに.catchの有無を表示します(--jsonではgatingHooks)。この表示もv2.1.290で加わりました。
他のmodと並んだときの順序
tool.checkのフックは、複数のmodが同じイベントに登録すると1本の連鎖になります。先頭のmodが最も外側で、イベントを最初に見て、結果を最後に受け取ります。後ろのmodは、前のmodがイベントを見るのを止められません。
並びは、組み込みのガード、組織がprependPluginsに挙げたmod、利用者が入れたmod、組織がappendPluginsに挙げたmod、そのほかの組み込みmodの順です。自分のmodがceilingを読んで判定を返しても、前にいる組織のmodはその結論を受け取ってから最終的な答えを出せます。next(e)が返すdecidedに入っているのは、自分より後ろのmodと権限判定の結論です。
したがって、agentIdで分岐する規則は、結論を上書きする側に回るか、結論に従う側に回るかを、フックの冒頭で決めておきます。上のサンプルは、どちらもdecidedを基準にしています。後ろのmodが先頭のmodの結論を覆せない以上、順序は自分の規則が効く範囲を決めます。
どのバージョンから使えるか
e.agentIdとe.ceilingは、どちらもv2.1.290以降で使えます。v2.1.290の変更点はClaude Code v2.1.290にまとめています。サブエージェントの扱いでは、auto modeで最終報告を審査するSubagentHandbackもあります。こちらはtool.checkが個々のツール呼び出しを止めるのに対し、サブエージェントが返す最終報告を審査します。
まとめ
tool.checkのフックは、e.agentIdの有無で「メインか、サブエージェントか」を、e.ceilingがaskかどうかで「組織が承認を求めているか」を判断できます。前者はサブエージェントだけを締める規則に、後者は組織の判定を壊さないための目印に使えます。
ceilingが空でも承認不要とは限らない点は、デスクトップアプリ経由のセッションを使うチームで特に効いてきます。まず$.ui.logで観測し、実際の値を確かめてから拒否の規則を足す順序が堅実です。