CIマージゲートをシェル入力の書き換えで素通りさせない
今回やったこと
CIが成功していることを確認してからプルリクエストをマージするフックでは、gh pr merge の呼び出しを見つける必要がある。単純な文字列検索では、コメントや検索パターンまでマージ操作と誤認する一方、シェルに渡されたコマンド文字列を見落とすことがある。scripts/ci_merge_gate_hook.py と scripts/test_ci_gate.py の実装をもとに、どの入力をコードとして読み直し、どの入力をデータとして扱うかを整理する。
実装ステータス: 実装済み。対象はリポジトリ内のマージゲート用 Python フックと回帰テストである。この記事では外部環境のバージョンやCI実行結果を追加で推定せず、コードに記録された判定とテストケースを説明する。
部分文字列検索で起きる二つの失敗
gh pr merge という文字列があるかだけを見ると、たとえば grep -rn "gh pr merge" scripts/ は検索しているだけなのにマージ操作として扱われる。コメントや echo "gh pr mergeは禁止" も同じ問題を起こす。誤検知が続けば、ゲートが邪魔なものとして無効化されかねない。
逆に、コマンドとして実行される文字列を普通の引用文字列と同じように消してしまうと、検知漏れになる。実装のテストには、修正前は not-a-merge になっていたものとして、次の形が記録されている。
bash -c 'cd x && gh pr merge 12'
ssh host 'cd /somewhere && gh pr merge 12'
echo "gh pr merge 12" | bash
bash <<'EOF'
gh pr merge 12
EOF
これらはシェルが受け取った文字列や標準入力をコマンドとして実行する。表面上の引用符だけを見て内容を隠すと、ゲートの対象から外れる。
コマンドの位置と引数を別々に読む
フックは入力を一度走査し、同じ長さを保った clean と masked の二つの見え方を作る。clean は引数の解析に使い、masked はクォートされた文字列やコメントを隠してコマンドが始まる位置を探す。長さをそろえることで、片方で見つけた位置をもう片方の同じ場所から読める。
シェルを起動する bash、sh、ssh などに渡る引用文字列は例外だ。これは実行コードなので、フックは中身も再解析する。heredoc も同じく一律には扱わない。bash <<'EOF' の本文はbashが読むコードだが、cat > f.sh <<'EOF' の本文はファイルへ保存するデータである。前者を調べ、後者の本文に書かれた文字列だけでは止めない。
終端語が見つからないheredocを残り全部として飲み込まないことも大切だ。そうすると、その後ろにある本物のマージコマンドまで解析対象から消える。実装では終端行を確認してから本文範囲を扱う。
誤検知を防ぐ対照テストも置く
test_ci_gate.py は検出対象だけでなく、止めてはいけない入力も並べている。たとえば cat > f.sh のheredocや、JSONを bash ./hook.sh に渡すパイプはデータ経路として扱う。一方、| bash のように標準入力をシェルスクリプトとして実行する形はコードとして読み直す。
この対照群がないと、検知漏れを減らす変更が検索・保存まで妨げる変更になっても気づきにくい。ゲートのテストでは「対象を拾った」だけで合格にせず、似た見た目の非対象を通すことも確かめる必要がある。
この方式の境界
このフックはシェル全体の完全な構文解析器ではない。コードには引用符、コメント、heredoc、コマンド置換、環境変数の前置、シェルへのパイプなど、実際に問題になった構文を扱う処理がある。読み解けない引用状態を検知した場合は安全側に倒す設計も記されている。それでも、あらゆるシェルや任意のラッパーを完全に再現できるとは言えない。
したがって、構文を追加・変更したときは、実行コードとして拾う例と、データとして通す例を対で回帰テストへ加える。ゲートの信頼性は条件式の見た目ではなく、想定する入力と対照入力の双方がテストに残っているかで保つ。
更新履歴
- 2026-10-03: 初稿作成。リポジトリ内のフック実装と回帰テストをもとに構成。
訂正履歴
- なし。