Codex Hooks入門|秘密情報チェックと終了時テストを自動化する

Codexの入力時と終了時にHooksで品質ゲートを置くイメージ

Codexの入力時と終了時にHooksで品質ゲートを置くイメージ

Codex Hooksは、Codexの処理前後に定型チェックを差し込む仕組みです。最初の用途には、UserPromptSubmitで入力時の事故を早めに検出し、Stopで完了条件を再確認する組み合わせが向いています。

ただし、Hooksだけで秘密情報漏えいや危険操作を完全に防げるわけではありません。対象外のtool経路や誤検知があり、複数Hookが並行に動く場合もあります。権限、secret scanner、CIと組み合わせる補助的な品質ゲートとして設計します。

3行まとめ

  • 送信前のプロンプト検査はUserPromptSubmit、ツール実行前の制御はPreToolUse、終了条件の再確認はStopを使います。
  • プロジェクト設定は.codex/hooks.jsonへ置き、/hooksで内容をレビューして信頼します。Hook変更後は再レビューが必要です。
  • Stopではstop_hook_activeを確認し、同じ失敗で無限に作業を継続させないようにします。

Codex Hooksとは

Hook(特定イベントに応じて処理を呼び出す仕組み)は、Codexのagentic loop(調査、編集、検証を繰り返す処理)の途中でcommandまたはMCP toolを実行します。OpenAI公式のHooksドキュメントは、API keyの貼り付け検査、停止時のvalidation、ログ、memory作成などを用途として挙げています。

実用的な設定場所は次の4か所です。

適用範囲 JSON TOML
ユーザー ~/.codex/hooks.json ~/.codex/config.toml
プロジェクト <repo>/.codex/hooks.json <repo>/.codex/config.toml

この記事では、Gitで変更履歴を管理し、チームでレビューしやすい.codex/hooks.jsonを使います。Hookのcommandはセッションの現在directoryで実行されるため、project内scriptは相対パスに頼らずGit rootから解決します。

複数の設定元に一致するHookがあれば、上位設定だけが残るのではなく、すべて実行されます。また、同じイベントに一致する複数のcommand Hookは並行に起動されます。Hook Aの判定でHook Bの起動を止める、という直列の前提は置けません。

目的からイベントを選ぶ

よく使うイベントを、処理の時点と制御できる範囲で比較します。

目的 イベント 実行時点 できること 主な注意点
プロンプト中の秘密情報を検出 UserPromptSubmit ユーザー入力の送信前 入力をblock、追加contextを渡す matcherは無視される
危険なtool入力を止める PreToolUse 対応toolの実行前 deny、対応toolでは入力書き換え Hosted toolなど対象外がある
approvalをルールで判断 PermissionRequest 承認要求の直前 allow、deny、通常確認へ委ねる 承認が不要な処理では発火しない
実行結果へ指摘を返す PostToolUse tool実行後 結果を置き換えて再考させる 起きた副作用は取り消せない
完了条件を再確認 Stop Codexがターンを止めるとき 理由を継続プロンプトにする 無限継続を避ける安全弁が必要

入力内容を調べたいならUserPromptSubmit、shell commandやfile editそのものを制御したいならPreToolUseです。PostToolUseは実行後なので、破壊的操作を未然に止める用途には使えません。

プロジェクトHooksの最小構成

今回の構成は次の3fileです。

.codex/
├── hooks.json
└── hooks/
    ├── scan_prompt.py
    └── validate_stop.py

.codex/hooks.jsonへ二つのHookを登録します。

{
  "description": "Input and completion checks for this repository.",
  "hooks": {
    "UserPromptSubmit": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "/usr/bin/python3 \"$(git rev-parse --show-toplevel)/.codex/hooks/scan_prompt.py\"",
            "timeout": 5,
            "statusMessage": "Checking prompt for secret-like values"
          }
        ]
      }
    ],
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "/usr/bin/python3 \"$(git rev-parse --show-toplevel)/.codex/hooks/validate_stop.py\"",
            "timeout": 30,
            "statusMessage": "Checking completion conditions"
          }
        ]
      }
    ]
  }
}

UserPromptSubmitStopではmatcherが現在使われないため、書いても絞り込みにはなりません。条件分岐はscript側で行います。

UserPromptSubmitで秘密情報らしい入力を止める

UserPromptSubmitのcommand Hookは、標準入力でJSON objectを受け取ります。検査対象はprompt fieldです。次の例では、よくあるtoken形式とprivate key headerだけを検出します。

from __future__ import annotations

import json
import logging
import re
import sys
from collections.abc import Mapping
from typing import Any

LOGGER = logging.getLogger("scan_prompt")
PATTERNS: tuple[re.Pattern[str], ...] = (
    re.compile(r"\bsk-[A-Za-z0-9_-]{20,}\b"),
    re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----"),
)


def contains_secret_like_value(prompt: str) -> bool:
    """Return whether a prompt contains a secret-like value.

    Args:
        prompt: User prompt supplied by the UserPromptSubmit event.

    Returns:
        True when any configured pattern matches; otherwise False.

    Raises:
        No exceptions are raised intentionally.

    Example:
        >>> contains_secret_like_value("token is sk-abcdefghijklmnopqrstuvwxyz")
        True
    """
    return any(pattern.search(prompt) is not None for pattern in PATTERNS)


def main() -> int:
    """Read a hook event and emit a safe blocking decision when needed.

    Args:
        None.

    Returns:
        Process exit code. Zero means the hook completed normally.

    Raises:
        json.JSONDecodeError: If stdin does not contain valid JSON.

    Example:
        Run via Codex UserPromptSubmit hook with event JSON on stdin.
    """
    event: Mapping[str, Any] = json.load(sys.stdin)
    prompt = str(event.get("prompt", ""))

    if contains_secret_like_value(prompt):
        # 検出した文字列自体をログやreasonへ出さず、二次漏えいを避ける。
        LOGGER.warning("Blocked a prompt containing a secret-like value")
        decision = {
            "decision": "block",
            "reason": "秘密情報らしい文字列を検出しました。値を削除し、環境変数やsecret managerを使ってください。",
        }
        print(json.dumps(decision, ensure_ascii=False))
        return 0

    LOGGER.info("Prompt check passed")
    print(json.dumps({"continue": True}))
    return 0


if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO, stream=sys.stderr)
    try:
        raise SystemExit(main())
    except json.JSONDecodeError:
        LOGGER.error("Hook input was not valid JSON")
        raise

block理由へ一致した文字列を含めないことが重要です。Codexの画面、hook log、監視基盤へ秘密そのものを複製すると、検査が新しい漏えい経路になります。

この例は早期警告です。token形式はサービスごとに異なり、一般文字列と衝突する場合もあります。実運用では次を併用します。

  • 認証情報を環境変数またはsecret managerへ置く
  • repositoryとCIでsecret scanを行う
  • Codexのfilesystem、network、approval権限を必要最小限にする
  • pattern追加時は正常な入力を誤検知しないtestを用意する

Stopで終了時テストを促す

Stopは、Codexがターンを終えようとした時点で実行されます。block decisionを返すとエラー終了にするのではなく、reasonを新しい継続プロンプトとしてCodexへ渡します。

UserPromptSubmitの入力検査からCodexの作業、Stopの完了条件確認へ進むHooksフロー

次の例は、repository rootに.codex/required-checks-failed.txtが存在するときだけ再確認を促します。CI連携の完成品ではなく、Stopの入出力契約と無限継続防止を確認する最小例です。

from __future__ import annotations

import json
import logging
import subprocess
import sys
from collections.abc import Mapping
from pathlib import Path
from typing import Any

LOGGER = logging.getLogger("validate_stop")


def repository_root() -> Path:
    """Resolve the current Git repository root.

    Args:
        None.

    Returns:
        Absolute path to the Git repository root.

    Raises:
        subprocess.CalledProcessError: If the current directory is not in Git.

    Example:
        >>> repository_root().is_absolute()
        True
    """
    result = subprocess.run(
        ["git", "rev-parse", "--show-toplevel"],
        check=True,
        capture_output=True,
        text=True,
    )
    return Path(result.stdout.strip())


def main() -> int:
    """Continue Codex once when a completion marker reports failure.

    Args:
        None.

    Returns:
        Process exit code. Zero means a valid JSON response was emitted.

    Raises:
        json.JSONDecodeError: If stdin does not contain valid JSON.
        subprocess.CalledProcessError: If the Git root cannot be resolved.

    Example:
        Run via Codex Stop hook with event JSON on stdin.
    """
    event: Mapping[str, Any] = json.load(sys.stdin)
    already_continued = bool(event.get("stop_hook_active", False))
    failure_marker = repository_root() / ".codex" / "required-checks-failed.txt"

    if failure_marker.exists() and not already_continued:
        LOGGER.warning("Completion marker reports a failed required check")
        decision = {
            "decision": "block",
            "reason": "必須チェックの失敗記録があります。内容を確認し、必要な修正と再テストを1回行ってください。",
        }
        print(json.dumps(decision, ensure_ascii=False))
        return 0

    if failure_marker.exists():
        # すでにStopで一度継続済みなら終了を許可し、無限ループを避ける。
        LOGGER.error("Required check still fails after one continuation")

    print(json.dumps({"continue": True}))
    return 0


if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO, stream=sys.stderr)
    raise SystemExit(main())

本番では、Stopのたびに全test suiteを同期実行すると待ち時間が大きくなります。短いrequired checkだけを実行する、直前のCI結果を安全なmarkerへ反映する、重い検証はCIへ残す、といった分担が必要です。

さらに、終了コード0のStop HookはJSONを返す必要があります。debug用の平文を標準出力へ混ぜず、logは標準エラーへ出します。

信頼レビューと動作確認

プロジェクトHookは、repositoryの.codexレイヤーが信頼されている場合に読み込まれます。非管理Hookは定義のhashに対して信頼されるため、commandや設定を変更すると再レビューが必要です。

導入時は次の順で確認します。

  1. .codex/hooks.jsonと呼び出すscriptをcode reviewする
  2. scriptを単体testし、正常入力、検出入力、不正JSONを確認する
  3. Codex CLIの/hooksでsourceとcommandを確認する
  4. 内容を信頼し、正常なpromptが通ることを確認する
  5. dummy tokenでblockされ、値自体がlogへ出ないことを確認する
  6. Stop failure markerを作り、一度だけ継続されることを確認する
  7. Hookを無効化した場合の手動check手順を残す

Hookはrepository内のscriptを実行できます。見知らぬrepositoryで表示されたHookを、内容を読まずに信頼してはいけません。変更後に再レビューを求める仕組みは、設定が密かに差し替わるリスクを下げるための境界です。

Codexの書き込み範囲やapprovalの整理は、Codexがファイルを編集できないときの対処法でも扱っています。Hookで許可範囲自体が広がるわけではありません。

失敗しやすい設計

Hooksを完全なセキュリティ境界として扱う

公式資料では、shell、apply_patch、MCP、その他のlocal function toolはPreToolUsePostToolUseの対象になり得ます。一方、Hosted WebSearchのようにローカルfunction-tool経路を通らないtoolや、標準Hook経路を使わない専用処理もあります。

そのため、Hooksは事故を減らすguardrailであり、sandbox、approval、OS権限、network制御を置き換えません。

複数Hookが順番に動くと仮定する

同一イベントの一致command Hookは並行起動されます。先に動くHookがfileを作り、次のHookが読む、といった暗黙の順序は不安定です。依存する処理は一つのscriptへまとめるか、明示的な同期機構を設計します。

async Hookで操作を止めようとする

background Hookは、発火元の処理をblock、approve、rewriteできません。監査logの送信など、結果を待つ必要がない用途に使います。prompt拒否、tool policy、終了時継続には同期Hookを選びます。

Stopを条件なしでblockする

Stopが毎回blockを返すと、Codexは継続し続けます。stop_hook_active、再試行回数、明確なfailure状態を使い、終了を許可する条件を必ず入れます。

Hookのstdoutへdebug logを書く

HookのstdoutはCodexが読むprotocolです。JSONへ余分な文字列が混ざると無効な出力になります。構造化応答はstdout、INFO・WARN・ERRORのlogはstderrへ分けます。

導入チェックリスト

  • [ ] 目的に合うイベントを選び、実行前と実行後を混同していない
  • [ ] .codex/hooks.jsonとscriptをcode reviewした
  • [ ] /hooksでsource、command、信頼状態を確認した
  • [ ] 検出対象の値そのものをlogやreasonへ出していない
  • [ ] 正常系、block系、不正入力を単体testした
  • [ ] 複数Hook間に暗黙の実行順依存がない
  • [ ] Stopでstop_hook_activeまたは再試行上限を確認している
  • [ ] async Hookへblockやrewriteを期待していない
  • [ ] Hooks以外のsecret scan、CI、sandbox、approvalを維持している
  • [ ] Hookを無効化したときの手動確認手順がある

Hooksを導入する目的は、Codexへ無制限な自動実行を許すことではありません。人が毎回思い出していた小さな確認を、処理の適切な時点へ固定することです。入力時の早期警告と、終了時の完了条件確認から始めると、失敗したときの影響を見ながら改善できます。

複数担当で調査や検証を分ける場合は、Codexサブエージェントの使い方も参考にしてください。Hookによる自動検証と、担当境界による競合防止は別の問題です。

参考資料

コメント

タイトルとURLをコピーしました