Claude Media
Claude Codeのmodでtool.callを観測・書き換え・代理応答する

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の出どころで決まります。

  1. 組み込みの保護 sec-default@builtin、組織が prependPlugins に挙げたmod、そのほか組織のmodとみなされて appendPlugins にないもの
  2. 自分でインストールしたmod
  3. 組織が appendPlugins に挙げたmod
  4. 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の追加で触れています。

この記事を共有:XはてブLinkedIn