ModsCode日本語ブログ Claude から Mod のコード例を探す

ModsCode / 日本語ブログ / 危険な Bash コマンドを止める Claude Code Mod の書き方

危険な Bash コマンドを止める Claude Code Mod の書き方

rm -rf や git reset --hard などを Claude が実行する前に止める Mod を、拒否、本人への確認、ブランチによる判断の 3 通りで解説。失敗時に通さない書き方と限界も、確認済みのコードで紹介します。

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-guard/hooks/register.js
// 止めたい 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 秒です。ハンドラも失敗すると、フックは飛ばされます。

動作の確認

テストを書くと、セッションを起動せずに確かめられます。

bash-guard/tests/bash-guard.test.ts
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 が質問するときのダイアログで出ます。

ask-guard/hooks/register.js
// 実行前に本人へ確認したいコマンド
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)に解決されます。

main-guard/hooks/register.js
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 ホストでブランチを保護してください」と書いています。守りたいものが重いなら、次を併用してください。

  1. 権限ルールで、deny を設定する。
  2. Git ホストのブランチ保護、バックアップ、読み取り専用の認証情報など、Claude Code の外の仕組み。
  3. サンドボックス。ただし、守られるのは Claude が実行する Bash コマンドだけです。

他の人は、どう書いているか

「拒否」のパターンには、公開されている Mod の実際のコードがあります。英語版のサイトの ツール呼び出しを拒否する書き方 には、最小の形と、実際の Mod の抜粋がライセンスつきで載っています。日本語で探したいときは、ModsCode の MCP に「危険なコマンドを拒否する」と聞くと、同じパターンのコードが返ります。公式のサンプルにも、危険なシェルコマンドを止めて、変更内容を見せてから進める・中止するボタンを出す blast-radius があります。

まとめ

止めるだけなら { deny: 理由 }、本人に決めさせるなら $.ui.ask、状態で決めるなら tool.check です。どれも、失敗したときにどちらへ倒すかを決めてから公開してください。書いたら、テスト で動作を固定しておくのがお勧めです。