Claude Code に任せる作業が増えると、「rm -rf だけは勝手に実行してほしくない」という場面が出てきます。Mod を使えば、ツール呼び出しが実行される直前にコードを挟み、止める、本人に確認する、状況によって判断を変える、といったことができます。この記事では、3 つの書き方を、動作を確かめたコードで紹介します。コードは Claude Code 2.1.289 の claude plugin validate と claude plugin test で確認しました。
作り方の基本(plugin.json、hooks.json、register.js の 3 ファイル)は、初めての Mod の作り方 で説明しています。この記事では、各 Mod の register.js だけを示します。plugin.json の name と description を、それぞれの Mod に合わせて書き換えてください。
先に結論
- 止めて理由を返す(
tool.callで{ deny: 理由 }):最も簡単です。コマンドは実行されず、権限確認も出ません。Claude は理由を読んで、別の方法を考えます。 - 本人に確認する(
tool.callで$.ui.ask):コマンドの実行を保留して、本人に選ばせます。答えがなければ実行しない側に倒します。 - 状況で決める(
tool.check):「今のブランチがmainのときだけ」のように、その時点の状態で判断したいときに使います。 - どの書き方でも、正規表現による判定は完全ではありません。Claude への注意喚起として考え、本当に守りたいものは、Git ホスト側のブランチ保護などの別の仕組みで守ってください。
1. 止めて、理由を Claude に返す
tool.call は、Claude がツールを使う直前に呼ばれます。matcher で { tool: 'Bash' } と絞れば、Bash の呼び出しだけを受け取れます。next を呼ばずに { deny: 理由 } を返すと、コマンドは実行されず、権限確認のプロンプトも出ません。Claude は、返した理由を、ツールの結果として読みます。
// 止めたい Bash コマンドと、Claude に返す理由。上から順に調べる
const RULES = [
{ pattern: /\brm\s+-[a-z]*r[a-z]*f|\brm\s+-[a-z]*f[a-z]*r/, reason: 'rm -rf は使えません。消す対象を 1 つずつ指定してください。' },
{ pattern: /\bgit\s+push\b.*(--force\b|-f\b)/, reason: '強制 push は使えません。新しいブランチに push してください。' },
{ pattern: /\bgit\s+reset\s+--hard\b/, reason: 'git reset --hard は使えません。変更を残したいなら git stash を使ってください。' },
]
// 判定は関数に出しておく。.catch から同じ形で使えるように
async function guard($, e, next) {
for (const rule of RULES) {
// next を呼ばずに返すと、そのコマンドは実行されない
if (rule.pattern.test(e.command)) return { deny: rule.reason }
}
return next(e)
}
export function register(on) {
on('tool.call', { tool: 'Bash' }, guard).catch(async ($, e, next) => {
// guard が next を呼んだ後に失敗したなら、その結果をそのまま返す
if (next.called) return next(e)
// 判定できなかったときは通さない(fail closed)
return { deny: 'コマンドの検査に失敗したため実行しませんでした: ' + next.error.kind }
})
}理由の書き方が重要です。理由は Claude が読むので、「禁止」だけでなく、代わりに何をすればよいかを書いてください。上の例の「消す対象を 1 つずつ指定してください」「新しいブランチに push してください」がそれです。理由がそっけないと、Claude は別のコマンドで同じことをしようとして、堂々巡りになりがちです。
失敗したときに通すか、通さないか
フックが例外を投げたり、時間制限(1 つのイベントで 10 秒)を超えたりすると、Claude Code はそのフックを飛ばし、コマンドはそのまま実行されます。つまり、何もしなければ検査が壊れたときに危険なコマンドが通ります(fail open)。.catch を付けると、検査が失敗したときの動作を決められます。
上のコードでは、.catch のハンドラが 2 つのことを区別しています。next.called が true のとき、guard は next を呼んだ後に失敗しているので、その結果を返します。false のときは、判定ができていないので、{ deny } で止めます(fail closed)。ハンドラ自身の時間制限は 1 秒です。ハンドラも失敗すると、フックは飛ばされます。
動作の確認
テストを書くと、セッションを起動せずに確かめられます。
import { expect, test } from 'claude-code/testing'
test('rm -rf は拒否され、理由が Claude に返る', async ($, on) => {
on('tool.call', () => ({ result: 'ran' }))
const out = await $.tool.call({ tool: 'Bash', command: 'rm -rf build' })
expect(out.deny).toMatch('rm -rf は使えません')
})
test('ふつうのコマンドはそのまま実行される', async ($, on) => {
on('tool.call', () => ({ result: 'ran' }))
const out = await $.tool.call({ tool: 'Bash', command: 'npm test' })
expect(out).toEqual({ result: 'ran' })
})on('tool.call', ...) は、Claude Code の代役です。Mod が next を呼んだときに、本物のツールの代わりに答えます。私たちが確かめたテストは、ほかに強制 push の拒否と、Bash 以外のツールが検査されないことの 2 つを含む 4 件で、すべて通りました。書き方は Mod のテストの書き方 で詳しく説明します。
2. 実行の前に、本人に確認する
拒否するより「本人に聞いて決める」方が合う場面もあります。tool.call のフックは await で待てるので、$.ui.ask で質問して、答えが出るまでツール呼び出しを保留できます。質問は、Claude が質問するときのダイアログで出ます。
// 実行前に本人へ確認したいコマンド
const RISKY = /\brm\s+-rf?\b|\bgit\s+reset\s+--hard\b/
export function register(on) {
on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
// 危険でなければ、確認せずそのまま通す
if (!RISKY.test(e.command)) return next(e)
// 答えが無いときの初期値は「中止」。誰も答えなければ実行されない
let answer = '中止'
try {
// 選ばれるまで、ツール呼び出しはここで待つ
answer = await $.ui.ask('このコマンドを実行しますか? ' + e.command, ['実行する', '中止'])
} catch {
// 質問が閉じられた、または claude -p のように聞く相手がいない
}
if (answer !== '実行する') {
return { deny: '本人が実行を断りました。別の方法を提案する前に、もう一度確認してください。' }
}
// 許可されても、通常の権限チェックはこの後に走る
return next(e)
})
}動きは次のとおりです。
- 本人が「実行する」を選ぶと、
next(e)が呼ばれ、通常の権限チェックを通ってコマンドが動きます。 - 「中止」を選ぶと、コマンドは実行されず、Claude は
denyの文を読みます。 - 質問の欄に自分で文字を入力すると、
$.ui.askはその文字で終わります。「実行する」と一致しなければ中止になります。 - 質問を閉じた、または対話のない
claude -pの実行では、$.ui.askが失敗します。catchが答えを「中止」のままにするので、実行されません。
待っている時間は、フックの時間制限(10 秒)に数えられません。ただし、$.ui.ask のような Mods API の呼び出しの中で待つことが条件です。自分で作った Promise を待つ時間は数えられるので、制限を超えるとフックは飛ばされ、コマンドが実行されてしまいます。
3. 状況で決める:tool.check
固定のコマンドやパスの許可・拒否なら、コードは要りません。権限ルール(Bash(npm test) のような書き方)で足ります。Mod が出番になるのは、判断が「その時点で何が真か」に依存するときです。そのためのイベントが tool.check で、権限ルールと設定フックが決めた後に呼ばれます。next(e) は、その時点の判断(allow、ask、deny)に解決されます。
export function register(on) {
// 権限ルールと設定フックが決めた「後」に呼ばれる。next(e) は、その時点の判断を返す
on('tool.check', { tool: 'Bash' }, async ($, e, next) => {
const decided = await next(e)
if (!e.input.command.includes('git push')) return decided
// 今のブランチは、その時点でしか分からない。だから権限ルールではなく Mod で決める
const branch = await $.process.run(['git', 'branch', '--show-current'])
if (branch.stdout.trim() !== 'main') return decided
return { decision: 'deny', reason: 'main ブランチからは push しません。作業用のブランチに切り替えてください。' }
})
}main にいるときは、権限ルールが許可していても deny が返ります。ほかのブランチや、ほかのコマンドでは、Mod がなければ出たはずの判断のままです。
注意 tool.check は、権限の確認が出る前に、呼び出しを許可することもできます。ask ルールで確認が出るはずの呼び出しや、管理設定以外の PreToolUse フックが止めた呼び出しも許可できてしまいます。自分で書く Mod では allow を返さない、他人の Mod を入れるときは tool.check を持っていないかを validate で確かめる、というのが安全な使い方です。安全チェック で扱いました。
限界:これは「注意喚起」であって、防壁ではない
この記事のコードは、コマンドの文字列を調べています。次のような書き方には、効かない可能性があります。
rm -r -f buildのようにオプションを分ける、変数やサブシェル、sh -c "..."に包む、別のスクリプトの中で呼ぶ。git push origin +mainのように、+を付けて強制を指定する書き方。このコードの正規表現は、--forceと-fだけを見ています。- Bash 以外のツール(ファイルの書き込みや、別の MCP ツール)で同じ結果を得る。
公式ドキュメントも、tool.check の例について「コマンドの文字列を見ているので、Claude への注意喚起として扱ってください。全員に main への push を止めたいなら、Git ホストでブランチを保護してください」と書いています。守りたいものが重いなら、次を併用してください。
- 権限ルールで、
denyを設定する。 - Git ホストのブランチ保護、バックアップ、読み取り専用の認証情報など、Claude Code の外の仕組み。
- サンドボックス。ただし、守られるのは Claude が実行する Bash コマンドだけです。
他の人は、どう書いているか
「拒否」のパターンには、公開されている Mod の実際のコードがあります。英語版のサイトの ツール呼び出しを拒否する書き方 には、最小の形と、実際の Mod の抜粋がライセンスつきで載っています。日本語で探したいときは、ModsCode の MCP に「危険なコマンドを拒否する」と聞くと、同じパターンのコードが返ります。公式のサンプルにも、危険なシェルコマンドを止めて、変更内容を見せてから進める・中止するボタンを出す blast-radius があります。
まとめ
止めるだけなら { deny: 理由 }、本人に決めさせるなら $.ui.ask、状態で決めるなら tool.check です。どれも、失敗したときにどちらへ倒すかを決めてから公開してください。書いたら、テスト で動作を固定しておくのがお勧めです。