Claude Codeのmodでtool.callを観測・書き換え・代理応答する
Claude Codeのmodでtool.callを扱う3つの型を、next(e)の置き方で分けて見ます。観測、引数の書き換え、代理応答に加え、他のmodとの順序と失敗時の挙動まで押さえます。
tool.call は、Claude Codeがツールを実行する直前に発火するイベントです。modからこのイベントを扱うとき、できることは next(e) をどこに置くかでほぼ決まります。前後に挟めば観測、引数を変えて渡せば書き換え、呼ばずに返せば代理応答です。
ここでは tool.call に絞って、3つの型の書き分けと、他のmodと同居するときの順序・失敗時の挙動を押さえます。modの作り方そのものはToken Weatherの手順にあるので、ここでは扱いません。
tool.callのhookは何を受け取り、何を返せるか
hookは3つの引数を受け取ります。modsのAPIを束ねた $、イベントの中身 e、次のhandlerを呼ぶ next です。e は深く凍結されたプレーンなデータで、フィールドへ代入すると例外になります。変えたいときはコピーを作って next に渡します。
tool.call では、e.tool にツール名、ツールの引数が e の直下のフィールドに入ります。Bashなら e.command、EditとWriteなら e.file_path です。MCPツールの呼び出しやサブエージェントの呼び出しでも発火します。
返せる値は3種類です。
| 返すもの | 意味 | next |
|---|---|---|
next(e) または next({ ...e, ... }) | 意味次のhandlerへ渡す。以降は権限チェックとツール実行 | next呼ぶ |
{ deny: '理由' } | 意味呼び出しを拒否する。Claudeは理由の文面をツールの結果として読む | next呼ばない |
{ result: '...' } | 意味ツールを実行せず、自分で結果を返す | next呼ばない |
登録は on('tool.call', matcher, hook) の形で、第2引数のmatcherで絞れます。値、配列、正規表現が使えます。
on('tool.call', { tool: 'Bash' }, hook)
on('tool.call', { tool: ['Edit', 'Write'] }, hook)
on('tool.call', { tool: /^mcp__github__/ }, hook)同じイベントをmatcherなしで2回登録すると、モジュールの読み込み自体が失敗します。同じmatcherで2回書かず、1つのhookにまとめます。
型1: 観測する — next(e)の前か後に仕事を置く
何も変えずに見るだけなら、仕事をして return next(e) します。実行前に記録する形です。
on('tool.call', async ($, e, next) => {
$.ui.log('Claude is about to use ' + e.tool)
return next(e)
})実行後に記録したいなら、await next(e) で結果を受け取り、そのまま返します。EditとWriteで変更された .mdx ファイルだけを拾う例を、少し実用寄りに書き直します。
on('tool.call', { tool: ['Edit', 'Write'] }, async ($, e, next) => {
const result = await next(e)
// 拒否された呼び出しは { deny }、失敗した呼び出しは isError が立つ
const changed = !result.deny && !result.isError
if (changed && e.file_path.endsWith('.mdx')) {
$.ui.log('Claude changed ' + e.file_path)
}
return result
})ポイントは3つです。
await next(e)の結果には、後段のmodの判断も、権限チェックの拒否も含まれる。成功とみなす前にdenyとisErrorを見る$.ui.logが出す薄い行は、トランスクリプトに載るがClaudeは読まない。Claudeの判断を変えずに記録だけ残せる- 結果をそのまま返すかぎり、Claudeが受け取る内容は変わらない
前に置くか後に置くかは、実行の前に記録したいか、結果を見てから記録したいかで決めます。
型2: 書き換える — 引数を変えて渡す、結果を変えて返す
引数を変えたいときは、e のコピーを作って next に渡します。後段のmodも、権限チェックも、ツール本体も、変更後の引数を見ます。元の e は誰にも渡りません。
たとえばBashのコマンドに、確認用の --dry-run を足す書き換えは次の形になります(説明用の例です)。
on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
if (!/^\s*rsync\b/.test(e.command)) return next(e)
return next({ ...e, command: e.command + ' --dry-run' })
})書き換えの注意は、権限チェックが書き換え後の内容にかかることです。next(e) を呼ぶと権限チェックが走り、そのあとツールが動きます。書き換え後の形が許可ルールに合わなければ、その形に対して確認や拒否が働きます。
もう1つの書き換えは、結果の側です。await next(e) のあとで、結果のフィールドを差し替えたコピーを返します。この場合Claudeが読むのは差し替え後の結果です。
結果の側の書き換えには、再試行もあります。最初の結果が isError だったら、next(e) をもう一度呼んで2回目の結果を返せます。
on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
const first = await next(e)
if (!first.isError) return first
// 失敗したときだけ、同じ呼び出しをもう一度通す
return next(e)
})再試行は、ツールを2回実行する意味になります。副作用のあるコマンドでは、同じ操作が2度走る点を踏まえて対象を絞ります。
型3: 代理応答する — nextを呼ばずに答える
next を呼ばずに値を返すと、連鎖が止まります。後ろのmodも、権限チェックも、ツール本体も動きません。
拒否は deny、自前の結果は result です。
on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
if (/git push .*--force/.test(e.command)) {
return { deny: 'Force pushes are not allowed in this repository. Push to a new branch instead.' }
}
return next(e)
})git push --force では権限の確認ダイアログも出ず、コマンドも動きません。deny の文面はClaudeがそのまま読むので、「次に何をすればいいか」を書く文にします。上の例は「新しいブランチにpushする」という行動を添えています。
result のほうは、ツールを動かさず結果だけ返す使い方です。{ result: 'Skipped by my-mod' } のように返すと、Claudeにとってはそれがツールの出力になります。権限の確認は出ず、ツールも動かないため、Claudeは返した文面からしか何が起きたかを知れません。
この result は、テストで特に役立ちます。claude plugin test のテストでは on('tool.call', () => ({ result: 'ok' })) と書いて、実際のツールを動かさずに自分のhookの動きを確かめられます。
ユーザーに聞いてから決める
代理応答には、返す前に待つという形もあります。tool.call のhookは next を呼ぶ前や値を返す前に await でき、そのあいだ呼び出しは保留されます。ユーザーに尋ねるなら $.ui.ask を使います。
const RISKY = /\brm\s+-rf?\b|\bgit\s+reset\s+--hard\b|\bgit\s+push\b.*--force/
export function register(on) {
on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
if (!RISKY.test(e.command)) return next(e)
let answer = 'Refuse'
try {
answer = await $.ui.ask('Run this command? ' + e.command, ['Run it', 'Refuse'])
} catch {
// 質問が閉じられた、または claude -p で答える人がいない
}
if (answer !== 'Run it') {
return { deny: 'The user declined this command. Ask before trying a different approach.' }
}
return next(e)
})
}答えの初期値を Refuse にしておくと、誰も答えなかったときに拒否へ倒れます。ユーザーが自由入力した場合も、Run it と一致しなければ拒否です。この例の RISKY が拾うのは rm -r と rm -rf、git reset --hard、--force つきの git push までで、git push -f は拾いません。
待ち時間の数え方にも注意が要ります。$.ui.ask のようなmods APIの呼び出しの中で待つ時間は、hookの制限時間に数えられません。自分で作ったPromiseを await する時間は数えられます。hookの実行時間の上限は10秒で、超えるとhookは飛ばされ、止めたはずのコマンドが走ります。
他のmodと同居するとき — 順序と、hookが失敗したとき
同じイベントを複数のmodが扱うと、hookは1本のミドルウェアの連鎖になります。最初のmodが一番外側で、イベントを最初に見て、結果を最後に見ます。後ろのmodが前のmodを止める手段はありません。
連鎖の順序は、modの出どころで決まります。
- 組み込みの保護
sec-default@builtin、組織がprependPluginsに挙げたmod、そのほか組織のmodとみなされてappendPluginsにないもの - 自分でインストールしたmod
- 組織が
appendPluginsに挙げたmod - Claude Code組み込みのそのほかのmod
自分でインストールしたmodのあいだでは、dependencies に挙げた依存先より前に自分が走ります。1つのモジュールの中では、register が on を呼んだ順です。
settings hookとの前後関係
settingsのPreToolUse hookも、連鎖の決まった位置で走ります。
| hookの出どころ | 走る位置 | 結果 |
|---|---|---|
| managed settingsのPreToolUse | 走る位置最初のmodの tool.call より前 | 結果ここでの拒否は最終。どのmodもその呼び出しを見ない |
上記以外のsettingsファイルとプラグインの hooks/hooks.json | 走る位置最後のmodが next を呼んだ後 | 結果途中のmodが next を呼ばずに答えると、これらは走らない |
2行目の意味は大きいです。modが代理応答すると、settingsに置いたPreToolUseのhookは呼ばれません。逆に next を呼べば、そのhookの判断が返り値に入って見えます。従来のhooksの監査ログをsettingsで取っているなら、tool.call で next を呼ばずに返す経路ではログが残らない点に注意します。
hookが失敗したら
hookが例外を投げる、時間切れになる、形の違う値を返す。こうした失敗でもセッションは壊れず、.catch ハンドラの有無で動きが変わります。ハンドラがなければ、失敗したタイミングで分かれます。
nextを呼ぶ前に失敗: そのhookは飛ばされ、次のhandlerが代わりに動くnextが返った後に失敗: その結果が採用され、再実行はされない
拒否が目的のhookでは、「失敗したら飛ばされて、コマンドが通る」が穴になります。.catch ハンドラで、失敗時も閉じる側に倒せます。
on('tool.call', { tool: 'Bash' }, guard).catch(async ($, e, next) => {
// guard が next を呼んだ後に失敗したなら、返ってきた結果を採用する
if (next.called) return next(e)
return { deny: 'The command guard failed, so this command was not run: ' + next.error.kind }
})next.error.kind には throw か timeout が入ります。.catch ハンドラ自身の制限時間は1秒で、ここも超えると飛ばされます。
tool.check では扱いが違います。next の後に返した拒否も有効なので、next.called を確かめずに { decision: 'deny', reason } を返せます。
拒否と許可は、tool.callとtool.checkのどちらで書くか
tool.call で deny を返すと、呼び出しは確実に止まります。ただし許可には使えません。許可、確認、拒否の判断そのものを動かすイベントは tool.check で、権限ルールとsettings hookが決めたあとに発火します。next(e) は allow、ask、deny のどれかに解決され、hookは同じ値か別の値を返せます。
使い分けの目安は次のとおりです。
| やりたいこと | 向くイベント |
|---|---|
| 引数の書き換え、実行前後の記録、代理応答 | 向くイベントtool.call |
| 現在のGitブランチなど、その時点の状態で許可・拒否を決める | 向くイベントtool.check |
| 固定のコマンドやパスの許可 | 向くイベント権限ルール(コード不要) |
tool.check なら、ルールが許可した git push を、現在のブランチが main のときだけ拒否できます。
on('tool.check', { tool: 'Bash' }, async ($, e, next) => {
// 権限ルールとsettings hookが決めた結果: 'allow'、'ask'、'deny'
const decided = await next(e)
if (!e.input.command.includes('git push')) return decided
const branch = await $.process.run(['git', 'branch', '--show-current'])
if (branch.stdout.trim() !== 'main') return decided
return { decision: 'deny', reason: 'Push from a branch other than main' }
})tool.call のhookと見比べると、書き方が3か所違います。
- 引数の置き場所:
tool.callはe.commandのようにeの直下、tool.checkはe.input.commandのようにe.inputの中。tool.callの書き方のままe.commandと書くと、tool.checkでは値が取れない - 返す値:
tool.callは{ deny: '理由' }、tool.checkは{ decision: 'deny', reason: '理由' } next(e)の結果:tool.callではツールの実行結果、tool.checkではallow、ask、denyの判断
この例は、mainのとき以外は decided をそのまま返すので、modを入れない場合と同じ判断になります。コマンド文字列に対する照合なので、Claudeへの注意喚起として使うのが前提です。全員がmainへ直接pushできないようにしたいなら、Git側でブランチを保護するのが確実です。
tool.check はmanaged settingsの外にあるPreToolUseが拒否した呼び出しを、承認する側にも回れます。どの判断がmodに勝つかは、権限のドキュメントにある一覧で確認できます。
書いたhookを確かめる
modのhookは、セッションを起動せずにテストできます。*.test.ts を書き、claude plugin test で実行します。テストのなかの $.tool.call({ tool: 'Bash', command: 'ls' }) が、modのhookを通ってイベントを流します。
test('tool.call hook runs', async ($, on) => {
// 実際のツールを動かさず、Claude Codeの代わりに答えるスタブ
on('tool.call', () => ({ result: 'ok' }))
await $.tool.call({ tool: 'Bash', command: 'ls' })
})スタブは on で登録し、hookが next(e) で先へ渡した呼び出しに、Claude Codeの代わりに答えます。登録は、テストが $ を最初に呼ぶより前に済ませる決まりで、あとから on を呼ぶとエラーになります。
hookが $.ui.log のようなmods APIを呼ぶなら、その呼び出しにもスタブが要ります。たとえば型1の観測hookをテストするなら、on('ui.log', () => ({ value: undefined })) を足します。スタブがないと no implementation for ui.log という失敗になります。
$.tool.call には、ツール名と引数を tool: 'Bash', command: 'ls' のように同じ階層のフィールドとして渡します。型1〜3のhookが e.command で読めるのは、この形のイベントが届くからです。
出力の検査には expect を使い、toBe、toEqual、toMatch、toContain などが使えます。1つのテストの制限時間は、指定しなければ5秒です。
まとめ
tool.call の型は next の使い方で3つに分かれます。
- 観測は前後に挟み、結果をそのまま返す
- 書き換えはコピーを作って
nextに渡す - 代理応答は
nextを呼ばずにdenyかresultを返す
他のmodと共存させるときは、外側ほど先にイベントを見ること、代理応答するとsettingsのPreToolUseが走らないこと、拒否のhookには .catch を付けることの3点が効きます。settingsのhook側で失敗時に止める選択肢は、onFailureの追加で触れています。