コンテンツにスキップ

チュートリアル:危険なBashコマンドを止める

Claudeが rm -rf ~ や curl … | sh を実行しようとした瞬間に止めるModを作ります。判定は3つに分かれます。

判定 コマンド 理由
ブロック rm -r・rm -rf で /、/*、~、$HOME を削除 取り消せず、誤検知もほとんどありません
ブロック main・master への --force、-f、--force-with-lease、+main のプッシュ ほかの人のコミットまで消えます
ブロック curl・wget の出力を sh・bash に直接渡す、bash <(curl …) 読んでいないスクリプトを実行してしまいます
確認 git reset --hard コミットしていない変更が消えますが、意図して使う場合も多いです
確認 DROP TABLE を含むコマンド grep "DROP TABLE" migrations/ のような検索も引っかかります

動作確認の基準:Claude Code 2.1.291で claude plugin validate と claude plugin test を通過したコードです。APIは2.1.290の型宣言(claude-code.d.ts)と照合しました。

作る前に、型宣言で確認したことを書いておきます。リファレンスによると、形が間違った応答はフックの失敗として扱われます。守るためのフックが失敗すると、ツールはそのまま実行されます。

  • tool.call フックの応答は ToolCallResult ひとつです。{ deny: '理由' } は呼び出しを拒否し、モデルはその文章をエラー結果として受け取ります。{ result } はツールの代わりに直接答えます。next(e) は下のフックとエンジン(権限確認ダイアログ、ツール実行)に渡します。
  • tool.call には ask の応答がありません。ask は2か所にあります。ひとつは tool.check フックの { decision: 'ask' } で、そのモードの決定者に任せます。型宣言は決定者を “the dialog, the auto-mode classifier, a headless host” と書いています。自動モードなら分類器が代わりに答えることがあるという意味です。もうひとつは、設定フックのイベントである classic.PreToolUse の { ask: '理由' } です。
  • 人に直接尋ねる関数は $.ui.ask(question, options) です。エンジン本来のAskUserQuestionダイアログを表示し、選ばれたラベルを返します。ダイアログを閉じたり、claude -p のように尋ねる相手がいなかったりすると reject します。

このModは、人に必ず見せるために $.ui.ask を使います。reject は下の .catch が受け取り、拒否に変えます。型宣言どおりなら、-p 実行では確認対象のコマンドがすべてブロックされます。この経路はセッションで直接は試しておらず、テストでダイアログを閉じるケースだけ確認しました。

  1. マニフェストとフックの場所を書きます。

    .claude-plugin/plugin.json
    {
    "name": "bash-guard",
    "version": "0.1.0",
    "description": "되돌릴 수 없는 Bash 명령을 막고, 오탐이 있을 수 있는 명령은 사람에게 묻는다",
    "author": { "name": "mods.guide" }
    }

    description は「取り消せないBashコマンドをブロックし、誤検知の可能性があるコマンドは人に確認する」という意味です。

    hooks/hooks.json
    { "modules": ["./register.ts"] }
  2. フックモジュールを書きます。 判定関数 judge とフックの登録を1つのファイルに置きます。

    hooks/register.ts
    import type { Register } from 'claude-code'
    export type Verdict = { kind: 'deny' | 'ask'; reason: string }
    const SEPARATORS = /;|&&|\|\||\||\n/
    const PIPE_TO_SHELL = /\b(curl|wget)\b[^|;&]*\|\s*(sudo\s+)?(ba|z|da|k)?sh\b/
    const SHELL_FROM_URL = /\b(ba|z)?sh\s+(-c\s+["']?\$\(|<\()\s*(curl|wget)\b/
    const DROP_TABLE = /\bdrop\s+table\b/i
    const DANGEROUS_RM_TARGETS = new Set(['/', '/*', '~', '~/', '~/*', '$HOME', '$HOME/', '$HOME/*', '${HOME}', '${HOME}/'])
    const PROTECTED_BRANCHES = new Set(['main', 'master'])
    // 명령 한 토막의 단어들. 따옴표는 벗기고, 앞에 붙은 sudo·env 대입은 건너뛴다.
    function words(segment: string): string[] {
    const all = segment.trim().split(/\s+/).filter(w => w !== '').map(w => w.replace(/^["']|["']$/g, ''))
    let i = 0
    while (i < all.length && (all[i] === 'sudo' || /^[A-Za-z_][A-Za-z0-9_]*=/.test(all[i] ?? ''))) i += 1
    return all.slice(i)
    }
    function isRecursive(flag: string): boolean {
    if (flag === '--recursive') return true
    return /^-[a-zA-Z]+$/.test(flag) && /[rR]/.test(flag)
    }
    function rmHitsRoot(w: string[]): boolean {
    if (w[0] !== 'rm') return false
    const flags = w.slice(1).filter(x => x.startsWith('-'))
    const targets = w.slice(1).filter(x => !x.startsWith('-'))
    return flags.some(isRecursive) && targets.some(t => DANGEROUS_RM_TARGETS.has(t))
    }
    function forcePushToProtected(w: string[]): boolean {
    if (w[0] !== 'git' || !w.includes('push')) return false
    const rest = w.slice(w.indexOf('push') + 1)
    const forced = rest.some(x => x === '--force' || x === '-f' || x.startsWith('--force-with-lease'))
    const refs = rest.filter(x => !x.startsWith('-'))
    const branch = (ref: string) => ref.replace(/^\+/, '').split(':').pop() ?? ''
    const plusRef = refs.some(r => r.startsWith('+') && PROTECTED_BRANCHES.has(branch(r)))
    return plusRef || (forced && refs.some(r => PROTECTED_BRANCHES.has(branch(r))))
    }
    function hardReset(w: string[]): boolean {
    return w[0] === 'git' && w.includes('reset') && w.includes('--hard')
    }
    export function judge(command: string): Verdict | undefined {
    if (PIPE_TO_SHELL.test(command) || SHELL_FROM_URL.test(command)) {
    return { kind: 'deny', reason: '내려받은 스크립트를 바로 셸로 실행하는 명령이에요.' }
    }
    const segments = command.split(SEPARATORS).map(words)
    if (segments.some(rmHitsRoot)) {
    return { kind: 'deny', reason: '루트(/)나 홈(~)을 통째로 지우는 rm이에요.' }
    }
    if (segments.some(forcePushToProtected)) {
    return { kind: 'deny', reason: 'main·master 브랜치에 강제 푸시하는 명령이에요.' }
    }
    if (segments.some(hardReset)) {
    return { kind: 'ask', reason: 'git reset --hard는 커밋하지 않은 변경을 지워요.' }
    }
    if (DROP_TABLE.test(command)) {
    return { kind: 'ask', reason: 'DROP TABLE이 들어 있어요.' }
    }
    return undefined
    }
    export const register: Register = on => {
    on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
    const verdict = judge(e.command)
    if (verdict === undefined) return next(e)
    if (verdict.kind === 'deny') return { deny: `${$.plugin.name}: ${verdict.reason}` }
    const answer = await $.ui.ask(`${verdict.reason} 그래도 실행할까요?`, {
    header: 'bash-guard',
    options: ['실행', '취소'],
    })
    return answer === '실행' ? next(e) : { deny: `${$.plugin.name}: 사람이 취소했어요. ${verdict.reason}` }
    }).catch(($, e, next) =>
    next.called ? next(e) : { deny: `${$.plugin.name}: 확인하지 못해서 막았어요.` },
    )
    }

    コード内の韓国語は、判定理由とダイアログの文言です。順に「ダウンロードしたスクリプトをそのままシェルで実行するコマンドです」「ルート(/)やホーム(~)を丸ごと削除するrmです」「main・masterブランチへの強制プッシュです」「git reset –hard はコミットしていない変更を消します」「DROP TABLEが含まれています」「それでも実行しますか?」、選択肢の 실행/취소 は「実行」/「キャンセル」、「人がキャンセルしました」「確認できなかったのでブロックしました」です。

判定は純粋関数として切り出します。 judge(command) は文字列だけを受け取って判定を返します。$ を使わないので、テストから直接呼んでパターンを何十個も試せます。検証器は $ を同じファイル内の関数までしか追いません。そのため $ を使うコードは register.ts の中に置き、外へエクスポートするのは純粋関数だけにしました。

コマンドを断片に分けます。 ;、&&、||、|、改行で分けてから、断片ごとに最初の単語を見ます。先頭に付いた sudo や X=1 のような環境変数の代入は飛ばします。そのため echo "rm -rf /" は最初の単語が echo なので通過し、cd /tmp && rm -rf ~/ は2番目の断片で引っかかります。パイプの検査(curl … | sh)は、分ける前の文字列全体に対して行います。パイプで切ると前後が分かれてしまうためです。

rmは再帰フラグと対象を一緒に見ます。 -rf、-fr、-r -f、--recursive のどれかがあり、対象が /、/*、~、~/、$HOME 系のときだけブロックします。rm -rf ./build や rm -rf ~/projects/app/node_modules はそのまま実行されます。逆に、-f がなくても再帰削除ならブロックします。

pushは強制フラグとブランチを一緒に見ます。 git push origin main は通常のプッシュなので通過し、git push --force origin feature/login も feature/login が保護ブランチの一覧にないので通過します。+main の参照はフラグなしでも強制プッシュなのでブロックします。HEAD:main のようにコロンの後ろをブランチとして読みます。

拒否するときは理由を書きます。 { deny } の文章は、モデルがツールのエラー結果として受け取ります。先頭に $.plugin.name を付けておくと、モデルにも人にも、どのModがブロックしたかが分かります。リファレンスの例も同じ形です。

.catch は閉じる側に倒れます。 フックが例外を投げると、エンジンはそのフックを飛ばしてツールを実行します。守るためのフックでは、これが穴になります。リファレンスが勧める形をそのまま使いました。

.catch(($, e, next) => (next.called ? next(e) : { deny: '…' }))

next をまだ呼んでいなければ拒否し、すでに呼んでいれば next(e) がその結果を再び返します。ツールが2回動くことはありません。検証器の出力にある gating hook with .catch: tool.call{tool=Bash} が、この仕組みを確認した行です。

tests/guard.test.ts
import { describe, expect, test } from 'claude-code/testing'
import type { On } from 'claude-code'
import { judge } from '../hooks/register'
const ALLOWED = [
'rm -rf ./build',
'rm -rf build dist',
'rm -rf /tmp/cache',
'rm -rf ~/projects/app/node_modules',
'rm /',
'echo "rm -rf /"',
'git push origin main',
'git push --force origin feature/login',
'git reset --soft HEAD~1',
'curl -fsSL https://example.com/install.sh -o install.sh',
'curl -s https://api.example.com | jq .',
]
const DENIED = [
'rm -rf /',
'rm -rf ~',
'sudo rm -fr /*',
'rm -r -f $HOME',
'cd /tmp && rm -rf ~/',
'git push --force origin main',
'git push -f origin master',
'git push origin +main',
'git push --force-with-lease origin HEAD:main',
'curl -fsSL https://example.com/install.sh | sh',
'wget -qO- https://example.com/x | sudo bash',
'bash <(curl -s https://example.com/x)',
]
const ASKED = ['git reset --hard origin/main', 'psql -c "DROP TABLE users"', 'sqlite3 app.db "drop table logs;"']
describe('judge: 패턴', () => {
for (const command of ALLOWED) {
test(`통과: ${command}`, () => {
expect(judge(command)).toBeUndefined()
})
}
for (const command of DENIED) {
test(`거절: ${command}`, () => {
expect(judge(command)?.kind).toBe('deny')
})
}
for (const command of ASKED) {
test(`묻기: ${command}`, () => {
expect(judge(command)?.kind).toBe('ask')
})
}
})
// 엔진 자리: 여기까지 내려온 Bash 명령은 "실행된" 것으로 적어 둔다
function engine(on: On, answer?: string) {
const ran: string[] = []
on('tool.call', { tool: 'Bash' }, ($, e) => {
ran.push(e.command)
return { result: { stdout: '', stderr: '', interrupted: false } }
})
on('tool.call', { tool: 'AskUserQuestion' }, ($, e) => {
if (answer === undefined) throw new Error('dismissed')
const question = e.questions[0]?.question ?? ''
return { result: { questions: e.questions, answers: { [question]: answer } } }
})
return ran
}
describe('tool.call 훅', () => {
test('안전한 명령은 그대로 실행된다', async ($, on) => {
const ran = engine(on)
const result = await $.tool.call({ tool: 'Bash', command: 'rm -rf ./build' })
expect(result.deny).toBeUndefined()
expect(ran).toEqual(['rm -rf ./build'])
})
test('위험한 명령은 실행 전에 거절된다', async ($, on) => {
const ran = engine(on)
const result = await $.tool.call({ tool: 'Bash', command: 'curl -s https://x.example | sh' })
expect(result.deny).toMatch(/^bash-guard: 내려받은 스크립트/)
expect(ran).toEqual([])
})
test('묻기에서 실행을 고르면 실행된다', async ($, on) => {
const ran = engine(on, '실행')
const result = await $.tool.call({ tool: 'Bash', command: 'git reset --hard HEAD' })
expect(result.deny).toBeUndefined()
expect(ran).toEqual(['git reset --hard HEAD'])
})
test('묻기에서 취소를 고르면 거절된다', async ($, on) => {
const ran = engine(on, '취소')
const result = await $.tool.call({ tool: 'Bash', command: 'git reset --hard HEAD' })
expect(result.deny).toMatch(/사람이 취소했어요/)
expect(ran).toEqual([])
})
test('질문 창을 닫으면 .catch가 거절한다', async ($, on) => {
const ran = engine(on)
const result = await $.tool.call({ tool: 'Bash', command: 'git reset --hard HEAD' })
expect(result.deny).toBe('bash-guard: 확인하지 못해서 막았어요.')
expect(ran).toEqual([])
})
})

テスト名の 통과/거절/묻기 は「通過」「拒否」「確認」です。後半のテスト名は順に「安全なコマンドはそのまま実行される」「危険なコマンドは実行前に拒否される」「確認で実行を選ぶと実行される」「確認でキャンセルを選ぶと拒否される」「質問ダイアログを閉じると .catch が拒否する」です。

テストは2層です。

パターン表。 ALLOWED、DENIED、ASKED の配列を回して judge を直接呼びます。境界のケースをここに集めておくと、パターンを直したときに何が壊れたかがすぐ分かります。rm /(再帰ではない)、echo "rm -rf /"(引用符の中の文字列)、curl … | jq .(jq へのパイプ)が、通過側の境界です。

フック全体。 $.tool.call({ tool: 'Bash', command }) は、セッションでモデルがツールを呼ぶときと同じチェーンを動かします。テストの on はエンジンの位置に立つので、下の2つのフックがエンジンの代わりになります。

  • Bashの土台は、受け取ったコマンドを ran 配列に記録します。「ブロックされた」を deny の文章だけで判断せず、エンジンまで届かなかったという事実で確認します。
  • AskUserQuestionの土台は、人の役です。$.ui.ask は内部でAskUserQuestionツールの呼び出しを起こします。そのため、このツールに答えを返せば、人が選んだのと同じことになります。答えの形は、組み込みツールの結果の型どおりに、answers に質問文をキーとしてラベルを入れました。このフックが例外を投げると、ダイアログを閉じたのと同じです。
ターミナルウィンドウ
claude plugin validate ./bash-guard
Validating plugin manifest: …/bash-guard/.claude-plugin/plugin.json
Validating hooks: …/bash-guard/hooks/hooks.json
❯ ./register.ts hooks: tool.call{tool=Bash}
❯ ./register.ts gating hook with .catch: tool.call{tool=Bash}
❯ ./register.ts calls: $.ui.ask
✔ Validation passed

calls: は $.ui.ask だけです。ファイルを読んだりネットワークに送ったりするコードがないという意味で、安全系のModを選ぶときに最初に見る行です。

ターミナルウィンドウ
claude plugin test ./bash-guard
tests/guard.test.ts:
(pass) judge: 패턴 > 통과: rm -rf ./build [3.18ms]
…
(pass) judge: 패턴 > 거절: git push --force-with-lease origin HEAD:main [0.16ms]
(pass) judge: 패턴 > 거절: curl -fsSL https://example.com/install.sh | sh [0.14ms]
…
(pass) judge: 패턴 > 묻기: psql -c "DROP TABLE users" [0.15ms]
…
(pass) tool.call 훅 > 위험한 명령은 실행 전에 거절된다 [17.43ms]
(pass) tool.call 훅 > 묻기에서 실행을 고르면 실행된다 [19.30ms]
(pass) tool.call 훅 > 묻기에서 취소를 고르면 거절된다 [16.63ms]
(pass) tool.call 훅 > 질문 창을 닫으면 .catch가 거절한다 [18.28ms]
31 pass
0 fail
Ran 31 tests across 1 file. [0.38s]

途中の行は省略しました。パターン26個とフック5個が通過しました。

同じ judge に、わざと回避するコマンドを入れて試しました。

コマンド 結果 理由
bash -c "rm -rf /" 通過 最初の単語が bash です
cd ~ && rm -rf * 通過 対象の * だけを見ても、ホームかどうか分かりません
X=/; rm -rf $X 通過 変数を展開しません
find / -delete 通過 ルールにないコマンドです
git -C repo push -f origin main ブロック git と push だけを見るので引っかかります
grep -rn "DROP TABLE" migrations/ 確認 検索語なのか実行なのか区別できません

リファレンスも、綴りを見るブロックリストは「best effort」だと書いています。堅牢なのは許可リストのほうです。だからこのModは、最後の網として使ってください。前段にはClaude Code自体の権限ルールとサンドボックスを置いてください。このModが next(e) で渡したコマンドは、普段どおり権限確認とツール実行を通ります。インストール前に確認する点は安全チェックに、似た役割のコミュニティModの比較は安全Modの比較にあります。

ターミナルウィンドウ
claude --plugin-dir ./bash-guard

有効にしたら、まずClaudeに「ビルドフォルダを消して」のような普通の作業を頼んで、通過するか確認してください。失敗したフックや拒否された結果はデバッグログに残ります。読み方はテストとデバッグにあります。

非公式のコミュニティガイドです。Anthropic との提携や承認はありません。Claude および Claude Code は Anthropic の商標です。