むしろ--dangerously-skip-permissionsの方が安全かも? Claude Codeのhooksで危険コマンドを止める
Claude Codeを並列で5つ立てて、次々タスクを投げる。返ってくる実行確認にYes、Yes、Yes。気が付くと確認せずにYesを押している自分がいました。
「これ、rm -rf /が来てもYesって押すんじゃないか?」
その瞬間、背筋が凍りました。
この記事では、チームで--dangerously-skip-permissionsを安心して使うためにPreToolUse hooksを導入した経緯と、88のテストケースで検証した実装の詳細を紹介します。
Yes連打の恐怖
自分のチームではClaude Codeを業務全般で活用しています。文字起こしからバックログ作成、実装、レビュー、デプロイ、検証まで全工程。効率を求めて複数インスタンスを並列で回すようになり、ツールの実行確認にはどんどんYesを押すようになっていました。
問題は、効率を追求するほど安全確認をスキップする習慣がついてしまうこと。
2025年12月には、Claudeがrm -rfでMacを葬り去ったという事例がありました。こうならないように気をつけて使おうとチームで話していましたが、Yesを連打している現状、このままでは危ない。
サンドボックスは思ったよりも使いにくい
最初に検討したのはDockerコンテナによるサンドボックス方式でした。しかし、思ったよりも使いにくい。
- AWS認証の引き回し: コンテナ内に認証情報を渡す仕組みが必要
- ファイルの同期: ホストとコンテナ間のマウント・同期の手間
- アップデート対応: Claude Codeやツールのバージョン更新のたびにイメージ再ビルド
- 毎回コンテナを立てなきゃいけない: セッションごとにコンテナ起動が必要で、開発体験が悪い
チームで個人がそれぞれ試して使いやすい方法を模索してみたものの、全員が毎日使う環境として採用するところには至りませんでした。
settings.jsonの落とし穴
次に試したのが.claude/settings.jsonのpermissionsです。denyとaskのルールを書いて、危険コマンドを避けるセッティングをリポジトリに入れてチームで運用していました。
{
"permissions": {
"deny": [
"Bash(command:rm -rf*)",
"Bash(command:*cdk destroy*)",
"Bash(command:*terraform destroy*)"
]
}
}
しかし、このpermissionsが確実に効く保証がないということがわかりました。
GitHub Issues(#18846)で「Bash permissions in settings.json not enforced」というバグが報告されています。denyルールを設定しているのに、実際にはブロックされないケースがある。
設定したのに効かない。これは怖い状態でした。
hooksという選択肢
チームのレトロスペクティブでこの課題感が上がり、hooksでの対策を決定しました。
hooksとsettings.jsonのpermissions(先ほどのdenyルール)の違いはこうです。
| 観点 |
permissions(settings.json) |
hooks(PreToolUse) |
|---|---|---|
| 制御方式 | パターンマッチ | コード実行(任意のロジック) |
--dangerously-skip-permissions |
多くのケースでバイパスされる | バイパスされない(設計上) |
| 柔軟性 | パターンマッチのみ | 正規表現、外部API、複雑なロジック |
公式ドキュメントにも明記されています。
"PreToolUse hooks fire before any permission-mode check. A hook that returns
permissionDecision: "deny"blocks the tool even inbypassPermissionsmode or with--dangerously-skip-permissions."
設計上は、hooksで「ダメ」と言ったら止まる仕組みです。--dangerously-skip-permissionsを付けていてもhooksはfireします。
実装: settings.jsonとenforce-permissions.sh
settings.jsonのhooks設定
.claude/settings.jsonにPreToolUseフックを登録します。
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|Read|Write|Edit",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/enforce-permissions.sh"
}
]
}
]
}
}
matcherでフック対象のツールを|区切りで指定します。$CLAUDE_PROJECT_DIRはClaude Codeがプロジェクトルートに展開してくれる環境変数です。これだけで、Bash・Read・Write・Editの実行前に毎回enforce-permissions.shが呼ばれます。stdinにJSONが渡され、終了コードとstdoutのJSONで制御します。
enforce-permissions.shの設計
スクリプトの構造は次の通りです。
#!/usr/bin/env bash
set -euo pipefail
# jq がなければ即ブロック(fail-closed)
if ! command -v jq &>/dev/null; then
echo "enforce-permissions: jq が見つかりません" >&2
exit 2
fi
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // ""')
deny() { ... } # JSON で permissionDecision: "deny" を返す
ask() { ... } # JSON で permissionDecision: "ask" を返す
if [ "$TOOL_NAME" = "Bash" ]; then
cmd=$(echo "$INPUT" | jq -r '.tool_input.command // ""')
# クォート分割バイパス対策
normalized_cmd=$(echo "$cmd" | tr -d "'\"\\\\" )
# deny: rm, sudo, cdk destroy, terraform destroy, chmod 777,
# kill, dd, ssh, eval, exec, docker rm 等(約20パターン)
echo "$normalized_cmd" | grep -qE '\brm\b' && deny "rm はブロックされています"
echo "$normalized_cmd" | grep -qE '\bsudo\b' && deny "sudo はブロックされています"
# ...
# ask: git push, git reset, terraform apply, cdk deploy,
# snowsql, curl, wget, docker run 等(約15パターン)
echo "$normalized_cmd" | grep -qE '\bgit push\b' && ask "git push は確認が必要です"
# ...
fi
# ファイル操作: Read/Write/Edit ごとに .env, .aws/, .ssh/, 秘密鍵等をチェック
exit 0
実際のスクリプト全文(enforce-permissions.sh)
#!/usr/bin/env bash
# enforce-permissions.sh
#
# PreToolUseフックスクリプト
# --dangerously-skip-permissions でもバイパスされない決定論的な権限制御を提供する。
#
# 動作:
# - denyパターンに一致 → permissionDecision: "deny" を返す(確実にブロック)
# - askパターンに一致 → permissionDecision: "ask" を返す(確実に確認を要求)
# - どちらにも一致しない → exit 0(通常のパーミッション制御に委ねる)
#
# 入力: stdin から JSON(tool_name, tool_input を含む)
# 出力: stdout に JSON(hookSpecificOutput を含む)
set -euo pipefail
# jq の存在チェック(未インストール時はfail-closedで全ツールをブロック)
if ! command -v jq &>/dev/null; then
echo "enforce-permissions: jq が見つかりません。brew install jq でインストールしてください" >&2
exit 2
fi
# stdin の読み取りとパース(JSON不正時もfail-closedでブロック)
INPUT=$(cat)
if ! TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // ""' 2>/dev/null); then
echo "enforce-permissions: 入力JSONのパースに失敗しました" >&2
exit 2
fi
# --- ユーティリティ関数 ---
# deny判定を出力して終了
deny() {
local reason="$1"
jq -n --arg reason "$reason" '{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": $reason
}
}'
exit 0
}
# ask判定を出力して終了
ask() {
local reason="$1"
jq -n --arg reason "$reason" '{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "ask",
"permissionDecisionReason": $reason
}
}'
exit 0
}
# --- Bashコマンドのチェック ---
check_bash() {
local cmd
cmd=$(echo "$INPUT" | jq -r '.tool_input.command // ""')
# クォート分割によるバイパス対策: クォート・バックスラッシュを除去してからチェック
# 例: 'r''m' -rf / → rm -rf / として検知する
local normalized_cmd
normalized_cmd=$(echo "$cmd" | tr -d "'\"\\\\" )
# askパターン(denyより先にチェック): git rm は確認要求
# ※ denyセクションの \brm\b より先にチェックしないと、git rm がrmとして誤ブロックされる
if echo "$normalized_cmd" | grep -qE '\bgit rm\b'; then
ask "git rm は確認が必要です"
fi
# denyパターン: 危険なコマンドを確実にブロック
# パターン方針:
# \b...\b : xargs経由バイパスを防ぐ必要がある高リスクコマンド(rm, sudo, kill等)
# (^|[;&|]\s*) : xargs経由の可能性が低い、または短い単語で偽陽性リスクがあるコマンド(dd, nc等)
# ファイル削除(xargs rm -rf 等も検知するため \brm\b を使用)
if echo "$normalized_cmd" | grep -qE '\brm\b'; then
deny "rm コマンドはブロックされています"
fi
# インフラ破壊(xargs sudo 等も検知するため \bsudo\b を使用)
if echo "$normalized_cmd" | grep -qE '\bsudo\b'; then
deny "sudo コマンドはブロックされています"
fi
if echo "$normalized_cmd" | grep -qE '\bcdk destroy\b'; then
deny "cdk destroy はブロックされています"
fi
if echo "$normalized_cmd" | grep -qE '\bterraform destroy\b'; then
deny "terraform destroy はブロックされています"
fi
# AWS設定変更
if echo "$normalized_cmd" | grep -qE '\baws configure\b'; then
deny "aws configure はブロックされています"
fi
# システム操作
if echo "$normalized_cmd" | grep -qE '(^|[;&|]\s*)chmod 777\b'; then
deny "chmod 777 はブロックされています"
fi
# xargs kill 等も検知するため \bkill\b を使用
if echo "$normalized_cmd" | grep -qE '\bkill(all)?\b'; then
deny "kill/killall コマンドはブロックされています"
fi
if echo "$normalized_cmd" | grep -qE '(^|[;&|]\s*)dd\b'; then
deny "dd コマンドはブロックされています"
fi
# ネットワーク
if echo "$normalized_cmd" | grep -qE '(^|[;&|]\s*)nc\b'; then
deny "nc (netcat) コマンドはブロックされています"
fi
if echo "$normalized_cmd" | grep -qE '(^|[;&|]\s*)ssh\b'; then
deny "ssh コマンドはブロックされています"
fi
if echo "$normalized_cmd" | grep -qE '(^|[;&|]\s*)scp\b'; then
deny "scp コマンドはブロックされています"
fi
# シェルインジェクション
if echo "$normalized_cmd" | grep -qE '(^|[;&|]\s*)eval\b'; then
deny "eval コマンドはブロックされています"
fi
if echo "$normalized_cmd" | grep -qE '(^|[;&|]\s*)exec\b'; then
deny "exec コマンドはブロックされています"
fi
# xargs bash -c 等も検知するため \b を使用
if echo "$normalized_cmd" | grep -qE '\bbash -c\b'; then
deny "bash -c はブロックされています"
fi
if echo "$normalized_cmd" | grep -qE '\bsh -c\b'; then
deny "sh -c はブロックされています"
fi
# Docker破壊操作
if echo "$normalized_cmd" | grep -qE '\bdocker rm\b'; then
deny "docker rm はブロックされています"
fi
if echo "$normalized_cmd" | grep -qE '\bdocker rmi\b'; then
deny "docker rmi はブロックされています"
fi
# パッケージ管理(破壊的)
if echo "$normalized_cmd" | grep -qE '\bbrew (uninstall|remove|upgrade)\b'; then
deny "brew uninstall/remove/upgrade はブロックされています"
fi
# askパターン: 確認を要求するコマンド
# Git操作(破壊的)
# NOTE: git rm はdenyセクションの前でaskチェック済み
if echo "$normalized_cmd" | grep -qE '\bgit push\b'; then
ask "git push は確認が必要です"
fi
if echo "$normalized_cmd" | grep -qE '\bgit reset\b'; then
ask "git reset は確認が必要です"
fi
if echo "$normalized_cmd" | grep -qE '\bgit rebase\b'; then
ask "git rebase は確認が必要です"
fi
# インフラ操作
if echo "$normalized_cmd" | grep -qE '\bterraform apply\b'; then
ask "terraform apply は確認が必要です"
fi
if echo "$normalized_cmd" | grep -qE '\bcdk deploy\b'; then
ask "cdk deploy は確認が必要です"
fi
# パッケージ管理
if echo "$normalized_cmd" | grep -qE '\bpnpm remove\b'; then
ask "pnpm remove は確認が必要です"
fi
if echo "$normalized_cmd" | grep -qE '\bnpm (uninstall|remove)\b'; then
ask "npm uninstall/remove は確認が必要です"
fi
# Docker
if echo "$normalized_cmd" | grep -qE '\bdocker run\b'; then
ask "docker run は確認が必要です"
fi
# データベース
if echo "$normalized_cmd" | grep -qE '\bsnowsql\b'; then
ask "snowsql は確認が必要です"
fi
# パッケージインストール
if echo "$normalized_cmd" | grep -qE '\bbrew install\b'; then
ask "brew install は確認が必要です"
fi
# AWS操作
if echo "$normalized_cmd" | grep -qE '\baws sts get-caller-identity\b'; then
ask "aws sts get-caller-identity は確認が必要です"
fi
# ネットワーク(外部通信)
if echo "$normalized_cmd" | grep -qE '(^|[;&|]\s*)curl\b'; then
ask "curl は確認が必要です"
fi
if echo "$normalized_cmd" | grep -qE '(^|[;&|]\s*)wget\b'; then
ask "wget は確認が必要です"
fi
}
# --- ファイル操作のチェック ---
check_file_operation() {
local file_path
file_path=$(echo "$INPUT" | jq -r '.tool_input.file_path // ""')
case "$TOOL_NAME" in
Read)
# 機密ファイルの読み取りをブロック
if echo "$file_path" | grep -qE '\.env($|\.)'; then
deny ".envファイルの読み取りはブロックされています"
fi
if echo "$file_path" | grep -qE '(^|/)id_rsa$'; then
deny "SSH秘密鍵の読み取りはブロックされています"
fi
if echo "$file_path" | grep -qE '(^|/)id_ed25519$'; then
deny "SSH秘密鍵の読み取りはブロックされています"
fi
if echo "$file_path" | grep -qiE '(^|/)\.aws/credentials'; then
deny "AWS credentialsの読み取りはブロックされています"
fi
if echo "$file_path" | grep -qiE '(token|credential|secret|key)'; then
deny "機密情報を含む可能性のあるファイルの読み取りはブロックされています"
fi
;;
Write)
# 機密・設定ファイルへの書き込みをブロック
if echo "$file_path" | grep -qE '\.env($|\.)'; then
deny ".envファイルへの書き込みはブロックされています"
fi
if echo "$file_path" | grep -qE '(^|/)secrets/'; then
deny "secretsディレクトリへの書き込みはブロックされています"
fi
if echo "$file_path" | grep -qE '(^|/)\.aws/'; then
deny ".awsディレクトリへの書き込みはブロックされています"
fi
if echo "$file_path" | grep -qE '(^|/)\.ssh/'; then
deny ".sshディレクトリへの書き込みはブロックされています"
fi
if echo "$file_path" | grep -qE '(^|/)\.(bashrc|bash_profile|zshrc|zprofile)$'; then
deny "シェル設定ファイルへの書き込みはブロックされています"
fi
if echo "$file_path" | grep -qE '(^|/)\.config/fish/'; then
deny "fishシェル設定への書き込みはブロックされています"
fi
;;
Edit)
# 機密ファイルの編集をブロック
if echo "$file_path" | grep -qE '(^|/)id_rsa$'; then
deny "SSH秘密鍵の編集はブロックされています"
fi
if echo "$file_path" | grep -qE '(^|/)id_ed25519$'; then
deny "SSH秘密鍵の編集はブロックされています"
fi
if echo "$file_path" | grep -qE '(^|/)\.(bashrc|bash_profile|zshrc|zprofile)$'; then
deny "シェル設定ファイルの編集はブロックされています"
fi
if echo "$file_path" | grep -qE '(^|/)\.config/fish/'; then
deny "fishシェル設定の編集はブロックされています"
fi
;;
esac
}
# --- メインルーティング ---
case "$TOOL_NAME" in
Bash)
check_bash
;;
Read|Write|Edit)
check_file_operation
;;
esac
# どのパターンにも一致しなかった場合、通常のパーミッション制御に委ねる
exit 0
設計のポイント: fail-closed
設計で意識したのはfail-closed(壊れたら全停止)です。
-
jqが未インストール →exit 2でブロック - JSONのパースに失敗 →
exit 2でブロック - スクリプト自体がエラー →
exit 2でブロック
「ガードレールが壊れたら素通し」ではなく「ガードレールが壊れたら全停止」で安全側に倒しています。
パイプ内の危険コマンドも検知
パイプでつないだコマンドの中に危険なものが含まれるケースにも対応しています。
# パイプの途中の sudo も検知される
echo "data" | sudo tee /etc/config
\bsudo\bのようにワードバウンダリでマッチしているので、パイプの途中にsudoが紛れていてもブロックされます。
すり抜けパターンの発見と改善
レビューで、すり抜けるケースが見つかりました。
# xargs 経由: rm がコマンド引数のため検知されなかった
find . -name "*.tmp" | xargs rm -rf
# クォート分割: シェルでは rm に展開されるがパターンマッチでは検知されなかった
'r''m' -rf /
これを受けて、以下の改善を行いました。
-
クォート除去: チェック前にクォートとバックスラッシュを除去した
normalized_cmdを生成し、'r''m'のような分割を防止 -
ワードバウンダリの拡大: 高リスクコマンド(
rm,sudo,kill等)の正規表現を\b...\bに変更し、xargs rmのようなケースも検知
テストをClaudeに作らせた
hooksを設定した後、一番気になったのは「本当に止まるのか?」でした。
でも「テストする = 危険なコマンドを実際に試す」ことになるので、正直心配でした。特にテスト自体もClaude Codeに作らせようとしていたため、不意に実行されるリスクを懸念していました。
そこで、安全にテストするよう気をつけて指示したら、実際に危険なコマンドを実行せず、hookスクリプトにJSONをstdinでパイプする方法を提案してくれました。
# rm -rf のテスト(安全)
echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf /tmp/test"}}' \
| .claude/hooks/enforce-permissions.sh
# → {"hookSpecificOutput":{"permissionDecision":"deny",...}}
# 安全なコマンドのテスト
echo '{"tool_name":"Bash","tool_input":{"command":"ls -la"}}' \
| .claude/hooks/enforce-permissions.sh
# → (出力なし = 通過)
hookスクリプトはstdinからJSONを受け取って判定するだけなので、もしhooksがきかなくてコマンドが実行されても安全です。Claudeに任せたら安全なテスト方法を考えてくれて、私自身非常に勉強になりました。
88テストケースの内訳
PRで88のテストケースを検証しました(すり抜け対策の追加分を含む)。
「偽陽性なし」とは、formatやperformのようにrmという文字列を含む単語が誤ってブロックされないことを確認するテストです。正規表現でワードバウンダリ\bを使っているので、単語の一部としてのrmには反応しないため、誤ブロックの心配はありません。
| カテゴリ | テスト数 | 例 |
|---|---|---|
| deny(Bash) | 30 |
rm -rf, sudo apt install, cdk destroy, xargs rm -rf
|
| クォート分割バイパス検知 | 8 |
'r''m' -rf /, "su""do" rm
|
| ask(Bash) | 15 |
git push, curl, wget
|
| deny(ファイル操作) | 18 |
.env, .aws/credentials, id_rsa, id_ed25519, .bashrc
|
| pass(偽陽性なし) | 13 |
format, perform, rmdir, skill(rm等を含む単語だが安全) |
| fail-closed | 4 |
jq未インストール、JSON不正 |
導入後: Yes待ちイライラが消えた
まずは自分の環境で運用してみた結果です。hooksを入れる前と後で、日常がどう変わったか。
Before:
- Claude Codeに指示を出して離席 → 戻るとYes待ちで止まっている
- お昼休みに時々Yesを押しに行く
- 翌朝出社して、もう1回Yesを押すところからスタート
- 気がついたらYes待ちで止まっていた → イライラ
After:
- お昼休みや退勤間際にタスクやレビューの初期調査をいくつか走らせておく
- 確認待ちにならないので、基本的にほとんど最後まで完走してくれている
- 翌朝は結果を読むところからスタート
自分がボトルネックになっている感じから解放されました。また、読まずにYesを押していた背徳感もなくなり、気兼ねなくClaudeに頼めるようになりました。
実際にhooksが機能した場面もあります。askにしているコマンドをClaudeが実行しようとして確認を聞いてきたので、Yesを押しました。聞かれること自体が珍しいので、危ないコマンドはしっかり確認でき、すごく安心しました。

これが、タイトルで「むしろ--dangerously-skip-permissionsの方が安全かも?」と書いた理由です。permissionsだけの運用では、すべてのコマンドに確認が求められます。重要でない承認を繰り返すうちに感覚が麻痺して、本当に危険な操作にも反射的にYesを押してしまう。いわゆるアラート疲れです。hooksで危険コマンドをブロックし、--dangerously-skip-permissionsでそれ以外の確認をスキップすれば、聞かれるのは本当に注意が必要な操作だけ。確認の質が上がるので、むしろ安全になりました。
チームへの展開
チームの反応は速かったです。PRを出したら「これはすぐに導入しなきゃいけないですね」と即座にレビューが入り、修正後にもう一度全テストを回してリグレッションがないことを確認し、すぐmainにマージ。全メンバーが使える状態になりました。
-
.claude/settings.jsonをGit管理 → pullするだけで全員に適用 - hookスクリプトもリポジトリに含めてバージョン管理
運用で気をつけていること
ブロックリストの定期見直し
一度作って終わり、ではありません。チームで使っているツールやシステムに合わせて、ブロック対象のコマンドリストは定期的に見直す必要があります。
たとえばうちのチームはSnowflakeを使っているので、snowsqlをaskパターンに入れて確認を必須にしています。環境が変わったら当然見直しが必要で、CDKからTerraformに移行したらcdk destroyの代わりにterraform destroyを追加する、といった具合です。
hooksの限界を知っておく
hooksは万能ではありません。正直に書きます。
今回の実装はdenylist(拒否リスト)方式で、リストにないコマンドを通過させます。allowlist(許可リスト)のほうが安全ですが、開発コマンドの網羅的なリスト化が現実的ではありませんでした。
具体的にすり抜けるケースもあります。たとえばpython -c "import os; os.system('rm -rf /')"のように、別言語経由で危険コマンドを実行するケースはBashパターンでは検知できません。ネットワーク制御もできません。
完全な隔離が必要な場面(セキュリティ監査、信頼できないコードのレビュー)では、DockerコンテナやDev Containerとの併用を検討すべきです。うちのチームの場合、日常の開発ではhooksで十分と割り切っています。
まとめ
名前だけ見ると危険そうな--dangerously-skip-permissionsですが、hooksと組み合わせることで確認のノイズが減り、本当に重要な操作だけに集中できるようになります。すべてに確認が出る状態は、一見安全に見えて、実はアラート疲れを起こして判断力を鈍らせます。重要な承認だけが求められる環境のほうが、結果として安全です。
2026年3月に登場したAuto modeなど、まだ試せていないものもあります。この記事は2026年4月時点の情報なので、導入するときは最新のドキュメントとGitHub Issuesを確認してみてください。
Discussion