🎃

むしろ--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.jsonpermissionsです。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 in bypassPermissions mode 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 /

これを受けて、以下の改善を行いました。

  1. クォート除去: チェック前にクォートとバックスラッシュを除去したnormalized_cmdを生成し、'r''m'のような分割を防止
  2. ワードバウンダリの拡大: 高リスクコマンド(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のテストケースを検証しました(すり抜け対策の追加分を含む)。
「偽陽性なし」とは、formatperformのように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, skillrm等を含む単語だが安全)
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にマージ。全メンバーが使える状態になりました。

  1. .claude/settings.jsonをGit管理 → pullするだけで全員に適用
  2. 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を確認してみてください。

参考リンク

Spectee Developers Blog

Discussion