1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

git push origin main | tee log で保護が素通りしていたので、コマンド分割で塞ぎ直した

1
Posted at

この記事はシリーズ「自律運用の土台を 1 本まるごと読む: claude-code-repository-base 全解剖」の第 2 回(全 10 回)です。

Claude Code に毎回同じ指示をしなくて済むように、ルール・フック・スキル・ツールを一式にまとめた公開リポジトリ kai-kou/claude-code-repository-base(MIT)を、実ファイルを引用し実際にコマンドを動かしながら読み解く連載です。掲載する実行結果と数値はすべて各回の執筆時点で採取し直し、検証したベースのコミット SHA を各回の冒頭に記します。

シリーズ全体の目次(クリックで開く)
  • 第 1 回 Claude Code に毎回同じ指示をしなくて済むように、運用の土台をリポジトリ 1 本にまとめた
  • 第 2 回 git push origin main | tee log で保護が素通りしていたので、コマンド分割で塞ぎ直した(この記事)
  • 第 3 回 ベースを別リポジトリへ配る 2 経路を dry-run で動かす(apply-to-repo.sh と bootstrap.sh)(公開予定)
  • 第 4 回 Stop フックを 5 本並べたら最初の 1 本しか読まれなかったので、ルーター 1 本に集約した(公開予定)
  • 第 5 回 コンテキスト圧縮で作業が消えるのを、圧縮前後の二段 WIP コミットで防いだ(公開予定)
  • 第 6 回 sandbox.enabled を true にしてもクラウドでは bwrap が無く、許可リスト外へ素通りだった(公開予定)
  • 第 7 回 「確認してよいですか」を 6 種類に限定したら、それ以外は全部自律実行になった(公開予定)
  • 第 8 回 ルールと教訓を増やし続けないために、常駐バイト予算と「昇格=物理削除」を機械強制した(公開予定)
  • 第 9 回 複数エージェントが同時に書くホワイトボードを、個別ファイル+単一集約者で壊れなくした(公開予定)
  • 第 10 回 10 回分を読み終えたら、自分のリポジトリに最初に持ち込む 3 つはどれか(公開予定)

検証時点: base kai-kou/claude-code-repository-base(MIT) HEAD dda1eb6(2026-09-07 JST)

対象読者は、AI エージェントに git 操作を任せていて、main への直接 push 事故を心配しているチームリードです。

第 1 回では、このリポジトリが提供するものを「🔒 機械的に強制されるもの」と「📋 運用ルールとして定義されるもの」の 2 段階に分けて眺めました。前者の代表例が .claude/hooks/pre-git-push-check.sh です。CLAUDE.md に「main には push しないで」と書くだけなら📋どまりですが、このフックは PreToolUse で実際にコマンドを止めます。

ただ、止めると言っても実装は簡単ではありません。git push origin main という文字列を探すだけの素朴な実装は、驚くほどあっさり抜けられます。実際にこのリポジトリの自己テストには、かつて素通りしていたパターンがコメント付きでそのまま残されています。

  # --- critical 1: 実測で素通りしていた 6 パターン(全て block 期待)---
  check "bare main"            "git push origin main"                       "block"
  check "-u main"               "git push -u origin main"                    "block"
  check "pipe"                  "git push origin main | tee /tmp/l"          "block"
  check "and-and"               "git push origin main && echo done"          "block"
  check "semicolon"             "git push origin main; echo x"               "block"
  check "trailing comment"      "git push origin main # comment"             "block"

今回はこの自己テストを手元で 50 ケース回して、どこまで塞げていて、どこに穴が残っているのかを確かめました。

TL;DR

  • 保護の実体は scan_and_decide() で、コマンド文字列を || && ; |do/done/then などで セグメントに分割 してから、セグメントごとに push 先を判定します。文字列末尾の一致だけを見ると git push origin main | tee /tmp/log で抜けられます。
  • eval / bash -c / 変数展開($BR)・コマンド置換を含むセグメントは 静的解析できないので block 扱い(fail-closed)です。見逃しコストが不可逆であることを優先した設計です。
  • --self-test を実行すると 50 ケース中 49 件通過・1 件失敗(git push --tagsexpected=allow に対して got=block)で、exit code は 1 でした。
  • この 1 件は環境依存でした。チェックアウト中のブランチが main のときだけ失敗し、作業ブランチに切り替えると 50 件全通過(exit 0)になります。clone 直後の HEAD は main なので、そのまま回すと必ずこの 1 件が落ちます。

素朴な実装がどう壊れるか

保護したいのは「このリポジトリの main / master へ push させない」という 1 点です。素朴に書くなら、フックに渡ってきたコマンド文字列が main で終わるかどうかを見ることになります。

# 素朴な実装(このリポジトリでは採用されていない)
if [[ "$COMMAND" =~ git[[:space:]]+push.*(main|master)$ ]]; then block; fi

この書き方は、次のどれでも抜けられます。上 3 行が先ほどの 6 ケースに含まれるもの、下 3 行は自己テストの別ブロック(「その他 main/master 判定」「lead 追加検証ですり抜けが判明した 10 件」)が拾っているものです。

抜け方 コマンド例 素朴な実装の判定
パイプで後続コマンドを繋ぐ git push origin main | tee /tmp/l 末尾が main でないので通過
&&; で繋ぐ git push origin main && echo done 同上
行末コメントを付ける git push origin main # comment 同上
変数展開でブランチ名を隠す B=main; git push origin $B 文字列に main が出るが末尾ではない
引数なし push(現在ブランチが main) git push そもそも main という語が現れない
refspec の宛先で指定する git push origin HEAD:refs/heads/main 末尾が main でも HEAD: 側の解釈が要る

とくに 5 番目が厄介です。git push は引数を省略すると現在のブランチを追跡先へ送るので、main にいれば main 直 push になります。コマンドの引数だけを見る実装では原理的に捕捉できません。

セグメントに割ってから判定する

pre-git-push-check.shscan_and_decide() は、コマンド文字列を 1 本の塊として扱うのをやめています。まず行末コメントを落とし、そのうえで連結演算子と構文境界で改行に置き換えて分割します。

  # 2. 連結演算子(|| / && / ; / |)・構文境界(( ) { } do done then else fi)・改行で
  #    セグメントに分割する(|| を先に処理し | と混同しない。do/done 等は単語境界 \<...\> で
  #    誤爆(docs/update の "do" 等)を避ける)。
  local segments
  segments=$(printf '%s\n' "$stripped" | sed -E \
    -e 's/\|\|/\n/g' -e 's/&&/\n/g' -e 's/;/\n/g' -e 's/\|/\n/g' \
    -e 's/\(/\n/g' -e 's/\)/\n/g' -e 's/\{/\n/g' -e 's/\}/\n/g' \
    -e 's/\<do\>/\n/g' -e 's/\<done\>/\n/g' -e 's/\<then\>/\n/g' \
    -e 's/\<else\>/\n/g' -e 's/\<fi\>/\n/g')

分割してしまえば、git push origin main | tee /tmp/loggit push origin maintee /tmp/log の 2 セグメントになり、前者が単独で判定にかかります。for i in 1; do git push origin main; done のようなループも do / done / ; で割れるため、中身の push が露出します。

セグメントごとの判定は 3 分類です。

  • implicit: 引数がフラグ列と高々 1 トークン(remote 名のみ、または引数なし)
  • main: 末尾が main / master+main の force prefix・refs/heads/main の完全形・HEAD:main の refspec 宛先を含む)
  • other-explicit: それ以外(明示的に main / master 以外を指定)

implicit に落ちたときだけ、リポジトリの現在ブランチを見に行きます。

    implicit)
      if [[ "$repo_ctx" == "unknown" ]]; then
        # push 先リポジトリすら判定できない implicit push は fail-closed でブロック
        echo "block"
        return
      fi
      local cur
      cur=$(current_branch_of_repo_root)
      if [[ "$cur" == "main" || "$cur" == "master" ]]; then
        echo "block"
      else
        echo "allow"
      fi
      ;;

これで「main にいるときの引数なし git push」が塞がります。

静的解析できないものは block に倒す

分割しても読めないものがあります。eval "git push origin main"bash -c "..." の引数文字列、git push origin $B の変数展開です。ここでフックは、解釈を諦めたうえで通すのではなく、ブロック側に倒します

    # 変数展開・コマンド置換($VAR / ${VAR} / $(...) / `...`)を含むなら静的に解決不能。
    # A-1 は不可逆なので見逃しコストを重く見て fail-closed(main 扱い)でブロックする。
    if [[ "$rest_norm" == *'$'* || "$rest_norm" == *'`'* ]]; then
      branch_target="main"
    fi

2 つの倒し方は少し性質が違います。変数展開・コマンド置換は無条件に main 扱いへ倒されるのに対し、eval / bash -c は中身に gitpush の両方の語が現れたときだけ block になり、現れなければそのセグメントは判定対象から外れます。cd の引数が解決できない場合も unknown として扱われ、その文脈での implicit push は block になります。コメントにある「A-1」は、このベースが定める「ユーザー確認が必要な既約境界」の 1 番目、つまり main への直接 push を指します。取り消せない操作なので、誤ブロックの不便より見逃しの損害を重く見る、という判断がコードにそのまま書かれています。

判定の流れをまとめると次のようになります。

自己テストを 50 ケース回す

このフックは自分でテストを持っています。--self-test を付けて起動すると、scan_and_decide() に代表的なコマンドを流し込んで期待値と突き合わせます。ケース数はソース内の check "..." 呼び出しの数と一致します。

$ grep -c '^\s*check "' .claude/hooks/pre-git-push-check.sh
50

実際に走らせた結果です。

$ bash .claude/hooks/pre-git-push-check.sh --self-test
[pre-git-push-check] self-test 開始(REPO_ROOT=.../scratchpad/base-02)
  FAIL: tags                                     cmd=[git push --tags] expected=allow got=block

[pre-git-push-check] self-test: 49 passed / 1 failed (total 50)
$ echo "exit=$?"
exit=1

同じコマンドをもう一度実行しても、失敗するケースと件数は変わりませんでした。偶発的なものではありません。

$ bash .claude/hooks/pre-git-push-check.sh --self-test
[pre-git-push-check] self-test 開始(REPO_ROOT=.../scratchpad/base-02)
  FAIL: tags                                     cmd=[git push --tags] expected=allow got=block

[pre-git-push-check] self-test: 49 passed / 1 failed (total 50)

失敗しているのはこの 1 行です。

  check "tags"                     "git push --tags"                                 "allow"

著者視点の発見ポイント: 落ちる条件を切り分ける

第 1 回を書いた時点では、この失敗を「疑陽性が 1 件ある」とだけ捉えていました。今回自分で回し直して分かったのは、落ちるかどうかが実行環境に依存する ことです。

git push --tags は引数がフラグ 1 つだけなので implicit に分類され、そこから現在ブランチの判定へ進みます。clone 直後の HEAD は main なので、この経路は block を返します。逆に作業ブランチにいれば allow になるはずです。同じ clone で、チェックアウト中のブランチだけを変えて 2 条件を比べました。

$ git rev-parse --abbrev-ref HEAD
main
$ bash .claude/hooks/pre-git-push-check.sh --self-test
  FAIL: tags                                     cmd=[git push --tags] expected=allow got=block
[pre-git-push-check] self-test: 49 passed / 1 failed (total 50)

$ git checkout -q -b feat/demo && git rev-parse --abbrev-ref HEAD
feat/demo
$ bash .claude/hooks/pre-git-push-check.sh --self-test
[pre-git-push-check] self-test: 50 passed / 0 failed (total 50)
$ echo "exit=$?"
exit=0

条件が 1 つ変わっただけで結果が反転しました。つまりこの 1 件はフック側の破綻ではなく、テストケースの前提がテストコードに書かれていない という問題です。

自己テストの中には、ブランチ依存のケースを正しく隔離している箇所もあります。引数なし push の 3 ケースは、一時ディレクトリに main ブランチと feat/work ブランチのリポジトリをそれぞれ作り、REPO_ROOT を差し替えてから判定しています。

  main_repo=$(mktemp -d)
  git -C "$main_repo" init -q -b main 2>/dev/null || git -C "$main_repo" init -q 2>/dev/null
  REPO_ROOT="$main_repo"
  check "implicit push while on main"        "git push"                     "block"
  check "implicit push while on main (flag)" "git push --force"             "block"

隔離のしくみは用意されているのに、tags のケースだけがその外側、実リポジトリを REPO_ROOT としたまま置かれています。git push --force が「main にいるので block」を期待し、git push --tags が「allow」を期待していて、両者が同じ実装経路を通る以上、同一ブランチ上で両方を満たすことはできません。

もう 1 つ、git push --tags は、ほかに refspec を書かなければタグだけを送るコマンドで、ブランチは push しません。それを implicit なブランチ push と同じ経路で判定している点は、保護としては広めに倒しすぎています。もっとも、これは fail-closed という設計方針とは矛盾しません。ブロックされた側は git push origin refs/tags/v1.0.0 のように送るものを明示すれば、other-explicit に分類されて通せます。

いま言えるのはここまでです。筆者はこの挙動を再現して切り分けましたが、本家へ修正 PR を出したわけではないので「直した」とは書けません。見つけた、という段階です。README が「機械的に強制される」と謳っていても、その保証を確かめる自己テストの側に前提の書き漏れが残ることはあります。clone してきたフックをそのまま信じず、自分の環境で 1 回回してみる価値はここにあります。

PreToolUse を 1 本のルーターに束ねる

.claude/settings.jsonPreToolUse は、Bash 系の入口を 1 本しか持っていません。

    "PreToolUse": [
      { "matcher": "Bash|mcp__github__create_pull_request", "hooks": [ { "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/pre-tool-use-router.sh" } ] },
      { "matcher": "mcp__github__merge_pull_request", "hooks": [ { "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/pre-merge-layer1-check.sh" } ] }
    ],

Bash ツールの実行前チェックが増えるたびに settings.json へフックを足していくと、1 コマンドごとに複数プロセスが起動し、それぞれが JSON を読み込みます。pre-tool-use-router.sh はその入口を 1 本にまとめ、コマンド内容を見て委譲先を選ぶ役に徹しています。

# git push チェック(main/master 直接 push のブロック)
# 【注意】"git" と "push" が隣接する 'git\s+push' だけだと `git -C <path> push ...` を
# 取りこぼす(critical 1 の再発防止・pre-git-push-check.sh 側の再設計と対)。
# "git" と "push" が単語としてどちらもコマンド中に現れれば委譲し、精密な判定は
# pre-git-push-check.sh 側のセグメント解析に任せる(push でないなら向こうが allow で返す)。
if echo "$COMMAND" | grep -qE '\bgit\b' && echo "$COMMAND" | grep -qE '\bpush\b'; then
  echo "$INPUT" | "$HOOK_DIR/pre-git-push-check.sh"
  exit $?
fi

ルーター側は gitpush が単語として現れるかだけを見て、精密な判定は委譲先に任せます。役割を粗い振り分けと厳密な判定に分けたことで、git -C /path push origin main のような書き方の取りこぼしを防ぎつつ、ルーター自体は短いまま保たれています。

まとめ

main 直 push の物理ブロックは、コマンド文字列をセグメントに割り、解釈できないものは block へ倒す、という 2 つの判断でできていました。パイプや &&、変数展開、eval、引数なし push といった抜け道は、この 2 つでおおむね塞がれています。

一方で、その保護を保証しているはずの自己テストは、50 ケース中 1 ケースが実行環境のブランチに依存していて、clone 直後にそのまま回すと失敗します。フック本体が堅牢であることと、その堅牢さを検証する仕組みが堅牢であることは、別の話です。導入するときは、自己テストを自分の環境で一度回して、通るのか落ちるのか、落ちるならなぜかまで見ておくと安心できます。

このフックも含めて、リポジトリ全体を別のリポジトリへ配る仕組みを次回見ます。

関連記事

参考リンク

シリーズの前後の記事

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?