2026.03.26

Claude Codeでallowlist + PreToolUse hookによる多層防御で「安全なSSHコマンドだけ自動実行」を実現する

 

 

まとめ

  • Claude Code の allowlist だけでは扱いにくいケースの対処
  • PreToolUse hook でssh (gcloud compute ssh)kubectl execdocker execを安全に制御する方法
  • --command= 対応、チェーン演算子検出、確認プロンプト改善の実装例

を紹介しています!これのおかげでclaude codeからリモート環境を操作するのがとても快適になりました。

はじめに

M.S.です。Claude Code最高ッ!

さてClaude CodeのBashツールは強力ですが、GCPインスタンスへのSSH接続のように・「一部は自動で任せたいが、全ては任せたくない」・「そもそも重要なコマンドが後半に来て見づらい」というケースが出てきます。例えば:

  • gcloud compute ssh my-vm -- ls /tmp → 読み取りだけなので自動実行してほしい
  • gcloud compute ssh my-vm --command="ls -la" → これも同じく自動実行してほしい
  • gcloud compute ssh my-vm → インタラクティブSSHは確認を挟みたい
  • gcloud compute ssh my-vm -- ls && rm -rf / → チェーン攻撃は絶対に実行させたくない

また視認性については、以下のようなコマンドは様々な引数があるため、最も重要なssh接続先のインスタンスでどのようなコマンドを実行するのかが見えづらいという問題があります。

こちら最初のうちは内容をよく読んでYes/Noを押すのですが、毎日数十回と繰り返し承認をしているとどんどんツメが甘くなる傾向があるというのが筆者の実情として有りました、、!

 

本記事では、Claude Codeのallowlist(自動実行許可リスト)とPreToolUse hookを組み合わせて、この要件を実現する方法を解説します。また開発中に遭遇した落とし穴とその解決策も共有します。

技術おさらい: PreToolUse

図は公式ドキュメントより。

Claude Codeのセッションは上記のように構成されているようで、PreToolUseが実行されたあとにPermissionRequest(おなじみのYes, Noを選ぶ画面)が表示されます。

つまりここの設定のhookファイルを作ることで、今回の目的のPermissionRequestの内容を変更することができるようになるのです。

Claude Codeのallowlistの仕組み

基本

~/.claude/settings.jsonpermissions.allowにパターンを記述すると、マッチするコマンドは確認なしで自動実行されます。

{ "permissions": 
    { "allow": [ "Bash(npm run build)", "Bash(git status *)" ] 
    } 
}

allowlistだけで十分か?

1. &&チェーン問題

直感的には Bash(gcloud compute ssh * -- ls*) で安全に見える。しかし、以下のようなコマンドはどうでしょうか:

gcloud compute ssh my-vm -- ls && rm -rf /

Claude Code では permission / command validation 周辺で複数の修正が継続的に入っており、少なくとも 1.0.93 未満には一部 bypass の修正対象となった問題がありました。公開 issue を見る限り、permission pattern だけに依存した運用は避け、hook 側でも明示的に検証する設計が安全です。
実際、過去にはこれが防げておらず、以下の脆弱性が報告されていたようです。ただし、刻一刻と状況が変わる領域なので、詳細は都度都度でご確認ください:

2. --command= 形式の落とし穴

もう一つ見落としやすい問題があります。実はgcloud compute sshでリモートコマンドを渡す方法は2つあります:

# 形式1: -- 区切り
gcloud compute ssh my-vm -- ls -la

# 形式2: --command フラグ
gcloud compute ssh my-vm --command="ls -la"

Bash(gcloud compute ssh * -- ls*) というallowlistパターンは形式1にしかマッチせず、形式2で書かれたコマンドはallowlistをすり抜けて確認プロンプトが出てしまいます

実際のプロジェクトでは、--project, --zone, --tunnel-through-iap等のオプションと組み合わせて--command=形式が使われることが多い:

gcloud compute ssh my-instance 
 --project=my-project 
 --zone=asia-northeast1-a 
 --tunnel-through-iap  --command="ls -la"

 

hookの"ask"はallowlistより優先される

また開発中にもう一つ重要な発見がありました。自動実行ができるようになったのでもう一つの要件である視認性を高めるためにhookを開発していたところがpermissionDecision: "ask"を返すと、allowlistにマッチするコマンドでも確認プロンプトが出てしまいます

つまり、hookで全コマンドに一律"ask"を返す設計だと、lsのような安全なコマンドまで毎回確認が求められる。

解決策: allowlist + PreToolUse hookによる多層防御

設計方針

上記の問題を踏まえ、最終的に以下の設計に至りました:

  • allowlist: --形式の安全なコマンドのみをfallbackとして登録
  • hook: --形式と--command=形式の両方を統一的に処理。安全なコマンドは"allow"、危険なコマンドは"ask"、チェーン演算子はexit 2

アーキテクチャ

Claude が Bash コマンドを実行しようとする
  │
  ▼
PreToolUse hook (validate_gcloud_ssh.py)
  ├─ gcloud compute ssh でない → exit 0(他のコマンドには影響しない)
  ├─ リモートコマンドにチェーン演算子あり → exit 2(完全ブロック)
  ├─ 安全なコマンド(ls, cat等)→ "allow"(確認なし自動実行)
  ├─ インタラクティブSSH → "ask" + 警告メッセージ
  └─ それ以外 → "ask" + リモートコマンドを強調表示

hookが"allow"を返すことで、--command=形式でも確実に自動実行されます。そのため、allowlistのパターンマッチの制約に依存しません。

実装

1. PreToolUse hook: validate_gcloud_ssh.py

この関数は gcloud compute ssh の 2 つのリモートコマンド指定形式を正規化して抽出します

#!/usr/bin/env python3
"""PreToolUse hook: gcloud compute ssh コマンドの安全性を検証する。"""

import json
import re
import sys

CHAIN_OPERATORS = re.compile(
    r"&&"       # コマンドチェーン
    r"|\|\|"    # OR チェーン
    r"|;"       # 連続実行
    r"|\|"      # パイプ
    r"|`"       # コマンド置換 (backtick)
    r"|\$\("    # コマンド置換 $(...)
    r"|>>"      # 追記リダイレクト
    r"|>"       # リダイレクト
    r"|<"       # 入力リダイレクト
)

# --command="...", --command='...', --command ... の各形式にマッチ
COMMAND_FLAG_PATTERN = re.compile(
    r"""--command=["'](.+?)["']"""     # --command="cmd" or --command='cmd'
    r"""|--command=(\S+)"""            # --command=cmd (引用符なし)
    r"""|--command\s+["'](.+?)["']"""  # --command "cmd" or --command 'cmd'
    r"""|--command\s+(\S+)"""          # --command cmd (引用符なし)
)

# hookで直接許可する読み取り系コマンド
SAFE_CMD_PREFIXES = (
    "ls", "cat", "head", "tail", "grep", "find",
    "pwd", "whoami", "echo", "wc", "df", "du", "stat", "file",
)


def extract_remote_cmd(command: str) -> str | None:
    """gcloud compute ssh コマンドからリモートコマンド部分を抽出する。"""
    # 形式1: " -- " 区切り
    separator = " -- "
    idx = command.find(separator)
    if idx != -1:
        return command[idx + len(separator):]

    # 形式2: --command フラグ
    match = COMMAND_FLAG_PATTERN.search(command)
    if match:
        return next(g for g in match.groups() if g is not None)

    return None


def output_decision(decision: str, reason: str) -> None:
    """permissionDecisionをJSON出力して exit 0 する。"""
    result = {
        "hookSpecificOutput": {
            "hookEventName": "PreToolUse",
            "permissionDecision": decision,
            "permissionDecisionReason": reason,
        }
    }
    print(json.dumps(result, ensure_ascii=False))
    sys.exit(0)


def main():
    try:
        input_data = json.load(sys.stdin)
    except json.JSONDecodeError as e:
        print(f"Error: Invalid JSON input: {e}", file=sys.stderr)
        sys.exit(1)

    tool_name = input_data.get("tool_name", "")
    if tool_name != "Bash":
        sys.exit(0)

    command = input_data.get("tool_input", {}).get("command", "")

    if not command.startswith("gcloud compute ssh"):
        sys.exit(0)

    remote_cmd = extract_remote_cmd(command)

    if remote_cmd is None:
        # インタラクティブSSH → 確認プロンプトで警告
        output_decision("ask", "⚠️ gcloud compute ssh ━━▶ インタラクティブSSH接続 ◀━━")

    if CHAIN_OPERATORS.search(remote_cmd):
        print(
            f"ブロック: gcloud compute ssh のリモートコマンドに"
            f"チェーン演算子が含まれています: {remote_cmd}",
            file=sys.stderr,
        )
        sys.exit(2)

    # 安全なコマンドはhookから直接許可する
    cmd_name = remote_cmd.lstrip().split()[0] if remote_cmd.strip() else ""
    if cmd_name in SAFE_CMD_PREFIXES:
        output_decision("allow", f"gcloud compute ssh ━━▶ {remote_cmd} ◀━━")

    # それ以外 → 確認プロンプトでリモートコマンドを強調表示
    output_decision("ask", f"⚠️ gcloud compute ssh ━━▶ {remote_cmd} ◀━━")


if __name__ == "__main__":
    main()

ポイント:

  • extract_remote_cmd()--形式と--command=形式の両方からリモートコマンドを抽出
  • gcloud compute ssh以外のコマンドには一切影響しない
  • チェーン演算子あり → exit 2で確認プロンプトなしで完全ブロック
  • 安全なコマンド → "allow"でhookから直接許可(allowlistのパターンマッチに依存しない)
  • それ以外 → "ask"でリモートコマンド部分を━━▶ ◀━━で囲んで強調表示

2. settings.json

{
  "permissions": {
    "allow": [
      "Bash(gcloud compute ssh * -- ls*)",
      "Bash(gcloud compute ssh * -- cat *)",
      "Bash(gcloud compute ssh * -- head *)",
      "Bash(gcloud compute ssh * -- tail *)",
      "Bash(gcloud compute ssh * -- grep *)",
      "Bash(gcloud compute ssh * -- find *)",
      "Bash(gcloud compute ssh * -- pwd*)",
      "Bash(gcloud compute ssh * -- whoami*)",
      "Bash(gcloud compute ssh * -- echo *)",
      "Bash(gcloud compute ssh * -- wc *)",
      "Bash(gcloud compute ssh * -- df*)",
      "Bash(gcloud compute ssh * -- du *)",
      "Bash(gcloud compute ssh * -- stat *)",
      "Bash(gcloud compute ssh * -- file *)"
    ]
  },
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "python3 ~/.claude-hooks/validate_gcloud_ssh.py"
          }
        ]
      }
    ]
  }
}

Note:

allowlistは--形式のfallbackとして残しているが、実質的なコマンド判定はhookが担っている。--command=形式はhookの"allow"で対応する。

hookの戻り値を理解する

PreToolUse hookの戻り値はコマンドの運命を決める:

exit code / 出力 動作
exit 0 + JSON (permissionDecision: "allow") 確認なし自動実行
exit 0 + JSON (permissionDecision: "ask") 確認プロンプトにpermissionDecisionReasonのメッセージを付加
exit 0(JSON出力なし) 通過。allowlistの判定に進む
exit 2 完全ブロック。確認プロンプトも出ない
その他 非ブロッキングエラー(verbose時にstderrが表示される)

今回のhookでは4段階の判定を行っている:

  1. チェーン演算子あり → exit 2(完全ブロック)
  2. 安全なコマンド(ls, cat等)"allow"で直接許可
  3. インタラクティブSSH"ask" + 警告
  4. それ以外"ask" + リモートコマンドの内容を表示

確認プロンプトの視認性を高める

Unicode記号で視線を誘導する

また、確認が必要なコマンドも後半にあると視認性が極めて悪いため、目立たせるようにしました。

矢印記号(━━▶ ◀━━)でリモートコマンド部分を挟むことで、視線を自然に誘導できます:

⚠️ gcloud compute ssh ━━▶ touch ~/test.txt ◀━━

長い--project, --zone等のオプションは一切表示されず、実行されるリモートコマンドだけが目に入ります

Unicode記号が最も確実に視覚的なアクセントを付けられる手段です。

開発中に遭遇した落とし穴

落とし穴1: --command=形式とallowlistの不整合

問題: allowlistの Bash(gcloud compute ssh * --command="ls *) パターンが、実際のコマンドにマッチしませんでした。

原因: Claude Codeのパターンマッチエンジンはfnmatchとは異なり、クォートを含む複雑なパターンで予期しない挙動をするようです。公式ドキュメントでも「引数を制約するパターンは脆弱」と警告されています。

解決策: allowlistでの対応を諦め、hookの"allow"で統一的に処理しました。

落とし穴2: hookの"ask"がallowlistを上書きする

問題: hookで全コマンドに"ask"を返す設計にしたところ、allowlistにマッチする安全なコマンド(ls等)でも確認プロンプトが表示されてしまいました。

原因: Issue #18312で報告されていた「allowlistがhookの決定を無視する」バグが修正された結果、現在はhookの"ask"がallowlistより優先されるようです。

解決策: 安全なコマンドにはhookから"allow"を返し、"ask"は危険なコマンドにのみ使います。

カスタマイズのヒント

許可コマンドの追加

hookのSAFE_CMD_PREFIXESにコマンド名を追加する:

SAFE_CMD_PREFIXES = (
    "ls", "cat", "head", "tail", "grep", "find",
    "pwd", "whoami", "echo", "wc", "df", "du", "stat", "file",
    "python3",  # 追加
)

SSH以外への応用

hookのコマンド判定部分を変えれば、他のリモート実行コマンドにも同じパターンが適用できる:

# kubectl exec にも適用する例
REMOTE_EXEC_PREFIXES = [
    "gcloud compute ssh",
    "kubectl exec",
    "docker exec",
]
if not any(command.startswith(p) for p in REMOTE_EXEC_PREFIXES):
    sys.exit(0)

denyリストとの併用

allowlistの対義としてpermissions.denyも使える。完全に禁止したいコマンドはdenyに入れるとhookより手軽:

{
  "permissions": {
    "deny": [
      "Bash(gcloud compute instances delete *)"
    ]
  }
}

まとめ

  • Claude Codeのallowlistは強力だが、--command=形式への非対応やシェル演算子バイパスの歴史がある
  • hookで安全なコマンドを"allow"、危険なコマンドを"ask"、チェーン演算子をexit 2で処理することで、allowlistの制約に依存しない堅牢な制御が実現できる
  • permissionDecisionReasonにUnicode記号(━━▶ ◀━━)を使うことで、確認プロンプトの視認性を高められる
  • hookはexit codeベースのシンプルなインターフェースなので、Python数十行で実装可能
  • 同じパターンはkubectl execdocker execにも応用できる

AIエージェントにコマンド実行を委ねる時代だからこそ、「何を自動で任せるか」の設計は重要です。allowlistとhookの多層防御で、利便性と安全性のバランスを取りましょう。

最後に

グループ研究開発本部 AI研究開発室では、データサイエンティスト/機械学習エンジニアを募集しています。ビッグデータの解析業務などAI研究開発室にご興味を持って頂ける方がいらっしゃいましたら、ぜひ募集職種一覧 からご応募をお待ちしています。

参考リンク

 

  • Twitter
  • Facebook
  • はてなブックマークに追加

グループ研究開発本部の最新情報をTwitterで配信中です。ぜひフォローください。

 
  • AI研究開発室
  • 大阪研究開発グループ

関連記事