From 41c56acd32f575297f5a9db10ea34887deee74e0 Mon Sep 17 00:00:00 2001 From: richblack Date: Sun, 23 Aug 2026 14:18:28 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A8=20main=20=E7=9A=84=E6=88=B3=E8=A8=98?= =?UTF-8?q?=E6=94=B9=E7=B6=81=E3=80=8Cpush=20=E7=9C=9F=E6=AD=A3=E7=9A=84?= =?UTF-8?q?=E7=9B=AE=E6=A8=99=20repo=E3=80=8D=EF=BC=8C=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E7=B6=81=20hook=20=E8=87=AA=E5=B7=B1=E7=9A=84=20cwd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 跨 repo 交辦時(總管站在 A repo,要推 B repo 的 main)main-and-prod-push-guard 的戳記機制永遠對不上:HERE 讀的是 hook 自己的 cwd(=session 的真身,不會變), WANT 是總管替目標 repo(B)寫進戳記的路徑——兩者結構性地不可能相等,不是 判斷錯,是這個情境在舊模型裡根本不存在(inkstone/ISEP#30 comment 3949, 脈絡 inkstone/InkStoneCo#57,2026-08-21 實撞)。 新增 hooks/lib/push_target_dir.py:純 tokenize(不執行任何指令)解析指令裡 `cd && git push` 或 `git -C push` 真正會落地的目錄,對多層 cd 鏈與子殼(`(cd A && ...); git push` 這種子殼 cd 不能外洩出去)都做了範圍化—— 這條範圍化是防穿透的關鍵,不是順手:沒有它,`(cd A && true); git push` 會被誤判成推向 A,讓替 A 開的舊戳記錯誤地放行推到殼外真正的目標。解不出來 一律退回舊行為(hook 自己的 cwd),維持 fail-closed 方向不變。 順手修掉補測時自己抓到的另一個洞:`(git push origin HEAD:main)`——單純加一層 括號——舊版目的地判斷完全偵測不到,整段直接放行,跟戳記無關。成因是截斷 refspec 尾巴的 sed 只認 `;`/`&`/`|` 三種字元,沒算到 `)`;補上即可,git 的 refspec 語法本來就不允許出現 `)`,這裡截斷永遠安全。 綁 repo+單次用完即丟兩條 2026-08-11/12 用血換來的性質完全沒有鬆動:只是把 「現在人在哪個 repo」問得更準,比對邏輯一個字沒動。 實測: - hooks/tests/main-and-prod-push-guard.test.sh 舊有 8 向:8/8 - scripts/test-main-and-prod-push-guard.sh 舊有 11 向:11/11 - 新增 hooks/tests/main-and-prod-push-guard-cross-repo.test.sh 17 向 (跨 repo 正向/反向不准鬆/git -C/子殼範圍化/括號洞/單次用完即丟/ 900 秒逾時/空戳記/既有行為零回歸):17/17 本輪只驗證,未拿去放行任何真實推送;plugin.json 隨慣例 bump 0.3.4 -> 0.3.5 並重跑 vendor-to-shell.py(.shell-payload 為 gitignore 產物,不入版控)。 Co-Authored-By: Claude Opus 5 --- .claude-plugin/plugin.json | 2 +- hooks/lib/push_target_dir.py | 174 ++++++++++++++++++ hooks/main-and-prod-push-guard.sh | 38 +++- ...ain-and-prod-push-guard-cross-repo.test.sh | 105 +++++++++++ 4 files changed, 316 insertions(+), 3 deletions(-) create mode 100644 hooks/lib/push_target_dir.py create mode 100755 hooks/tests/main-and-prod-push-guard-cross-repo.test.sh diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 8c6ad02..0e17304 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "isep", "description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:43 支機械閘(53 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、27 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。", - "version": "0.3.4", + "version": "0.3.5", "keywords": [ "inkstone", "guardrails", diff --git a/hooks/lib/push_target_dir.py b/hooks/lib/push_target_dir.py new file mode 100644 index 0000000..10effe2 --- /dev/null +++ b/hooks/lib/push_target_dir.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +"""hooks/lib/push_target_dir.py -- shared helper (inkstone/ISEP#40 S7-style +lib helper: source of truth for a small piece of logic, not a gate by +itself; nothing here decides allow/block on its own). + +What it answers: given a shell command string that somewhere invokes +`git ... push ...`, which directory will that push actually execute in? + +Why this exists (inkstone/ISEP#30 comment 3949, 脈絡見 InkStoneCo#57): +main-and-prod-push-guard.sh's stamp_ok() used to read `git rev-parse +--show-toplevel` from the *hook's own* cwd (= the session's real repo) as +"HERE", and compare it against the repo path the caller wrote into the +stamp ("WANT"). That works when the push target IS the repo the session is +standing in. It can never work otherwise: a `cd && git push +origin HEAD:main` changes the *push's* directory but not the hook's, so +HERE stays the real repo forever while WANT is (correctly) the other repo +-- the two can never match, no matter how carefully the caller follows the +gate's own instructions. This isn't a bad judgment call; that scenario +simply doesn't exist in the old model. + +This module extracts the directory the push *actually* runs in, purely by +tokenizing -- it never executes anything. Two sources, first one found +wins (in the order a real shell would apply them): + - a `cd ` chain preceding the push, scoped correctly across + subshells: a `(` inherits the current directory from its parent at the + moment it opens, but whatever a subshell `cd`s to does NOT leak back + out to sibling commands after the matching `)` closes (this mirrors + real bash: a subshell's cwd change is local to that subshell). This + scoping is load-bearing, not cosmetic: without it, `(cd /repo-A && + true); git push origin main` would misattribute the later push (which + really runs wherever the outer shell already was) to /repo-A, and a + stale/legitimate stamp for /repo-A could then wrongly wave through a + push into whatever the outer cwd actually is -- the exact "stamp + opened for repo A also opens the door for repo B" shape 2026-08-11 + already burned us on once. + - a `-C ` flag on the git invocation itself, which further wins + over any `cd` chain (matches git's own precedence: `-C` sets the + directory for that invocation regardless of the shell's cwd). + +Multiple relative `cd`/`-C` hops are combined by plain string join here +(no `..`/`~`/`$()` resolution) -- resolving the combined expression to a +real, canonical, absolute path is left to the caller, which does it with a +read-only `cd "" && pwd` in a throwaway subshell. That two-step split +matters: this module only ever *parses*, so it stays side-effect-free even +when fed a hostile or malformed command; only the caller's final `cd` +touches the filesystem, and `cd` cannot execute anything, it can only fail +to find a directory. + +If no `cd`/`-C` applies (the push runs wherever the hook itself is, i.e. +today's behaviour), or the command doesn't parse, prints nothing -- the +caller falls back to its existing cwd-based resolution. That fallback +direction is deliberately the *safe* one: on any parse ambiguity we hand +back "unknown" rather than guess, and an unresolved HERE can only make the +gate keep blocking (fail toward blocking), never open a door it wouldn't +have opened before. + +Usage: + printf '%s' "$CMD" | python3 push_target_dir.py +""" +import shlex +import sys + +_SEPARATORS = {";", "&&", "||", "|", "&", "\n"} + + +def _join(base, path): + """Combine a cwd-so-far (`base`, or None if unknown/hook-cwd) with a + `cd`/`-C` argument written in the command. Absolute paths and `~` + replace the base outright; anything else is appended textually -- + normalizing `..`/`.` is intentionally left to the caller's real `cd`.""" + if not path: + return base + if path == "-" or path.startswith("$"): + # `cd -` (previous dir) and `$VAR`/`$(...)` expansions can't be + # resolved by tokenizing alone -- treat as "unknown" rather than + # guess wrong, which keeps the caller on its safe fallback path. + return None + if path.startswith("/") or path.startswith("~"): + return path + if base is None: + return path + return base.rstrip("/") + "/" + path + + +def _classify(tokens): + if not tokens: + return ("other", None) + if tokens[0] == "cd" and len(tokens) > 1: + return ("cd", tokens[1]) + if tokens[0] == "git" and "push" in tokens[1:]: + return ("push", tokens) + return ("other", None) + + +def _events(cmd): + """Tokenize cmd into (kind, value) events in source order: 'enter'/ + 'exit' for parens (subshell boundaries), 'cd'/'push'/'other' for + statements split on the usual shell separators. Returns [] on any + quoting error -- caller then falls back to cwd-based resolution.""" + try: + lexer = shlex.shlex(cmd, posix=True, punctuation_chars=True) + lexer.whitespace_split = True + toks = list(lexer) + except ValueError: + return [] + + events = [] + seg = [] + + def flush(): + if seg: + # _classify's "push" branch returns `tokens` by reference; copy + # before clear() below, or the event's tuple would observe the + # list emptied out from under it (aliasing, not a value copy). + events.append(_classify(seg[:])) + seg.clear() + + for tok in toks: + if tok == "(": + flush() + events.append(("enter", None)) + elif tok == ")": + flush() + events.append(("exit", None)) + elif tok in _SEPARATORS: + flush() + else: + seg.append(tok) + flush() + return events + + +def find_push_target(cmd): + events = _events(cmd) + if not events: + return "" + + stack = [None] # cwd-so-far per paren depth; None = "same as hook cwd" + result = None + saw_push = False + + for kind, val in events: + if kind == "enter": + stack.append(stack[-1]) # child subshell inherits current dir + elif kind == "exit": + if len(stack) > 1: + stack.pop() # subshell's own cd's don't leak out + elif kind == "cd": + stack[-1] = _join(stack[-1], val) + elif kind == "push": + saw_push = True + c_path = None + toks = val + for j, t in enumerate(toks): + if t == "-C" and j + 1 < len(toks): + c_path = toks[j + 1] + break + if t.startswith("-C") and len(t) > 2: + c_path = t[2:] + break + result = _join(stack[-1], c_path) if c_path else stack[-1] + + if not saw_push: + return "" + return result or "" + + +def main(): + cmd = sys.stdin.read() + sys.stdout.write(find_push_target(cmd)) + + +if __name__ == "__main__": + main() diff --git a/hooks/main-and-prod-push-guard.sh b/hooks/main-and-prod-push-guard.sh index ed75395..87f9d3e 100755 --- a/hooks/main-and-prod-push-guard.sh +++ b/hooks/main-and-prod-push-guard.sh @@ -98,7 +98,32 @@ stamp_ok() { # 等於一把萬用鑰匙——正是 08-11 那次穿透的形狀(替 A repo 開的門 B repo 也走得過)。 # 而且 `.claude/settings.local.json` 裡真的放行過 `touch /tmp/.main-push-ok`。 # ⇒ 現在**空內容一律不算數**:要嘛寫得出 repo 路徑且對得上,要嘛不放行。 - HERE=$(git rev-parse --show-toplevel 2>/dev/null || printf '') + # + # 🔴 2026-08-23(inkstone/ISEP#30 comment 3949,脈絡 InkStoneCo#57): + # `HERE` 原本一律讀 hook 自己的 cwd(=session 站著的那個 repo)。 + # 只要要推的 repo **不是**「session 站著的那個 repo」——例如指令自己 + # `cd <別的 repo> && git push` 或 `git -C <別的 repo> push`——HERE 永遠是 + # 總管的真身,而總管替目標 repo 開的 WANT 永遠對不上,這道閘就**永遠沒辦法 + # 合法通過**。不是判斷錯,是這個情境在舊模型裡根本不存在(照閘的指示做 + # 戳記,戳記內容天生就贏不了)。 + # 改法:先看指令本身有沒有把 push 的執行目錄改掉 + # (lib/push_target_dir.py——純 tokenize,不執行任何指令, + # 對 `cd A && cd B && git push` 這種多層鏈與 `(cd A && …); git push` 這種 + # 子殼會不會外洩都做了範圍化,理由見該檔檔頭);解得出來就 `cd` 進那個 + # 目錄(唯讀操作,`cd` 本身不會執行任何東西)問 git 那裡的 toplevel 是誰; + # 解不出來(沒有 cd/-C,或指令太怪解析失敗)才退回舊行為=hook 自己的 cwd。 + # 🔴 綁 repo+單次用完即丟兩條性質完全沒有鬆動:這裡只是把「現在人在哪個 + # repo」問得更準,比對邏輯(下面兩行)一個字沒動。 + _push_target_dir="$(dirname "$0")/lib/push_target_dir.py" + _target_expr="" + if [ -f "$_push_target_dir" ]; then + _target_expr=$(printf '%s' "$CMD" | python3 "$_push_target_dir" 2>/dev/null || printf '') + fi + if [ -n "$_target_expr" ]; then + HERE=$(cd "$_target_expr" 2>/dev/null && git rev-parse --show-toplevel 2>/dev/null || printf '') + else + HERE=$(git rev-parse --show-toplevel 2>/dev/null || printf '') + fi WANT=$(head -1 "$STAMP" 2>/dev/null || printf '') [ -n "$WANT" ] || return 1 [ -n "$HERE" ] || return 1 @@ -132,7 +157,16 @@ if printf '%s' "$CMD" | grep -qE '(^|[;&|(`]|&&|\|\|)[[:space:]]*git([[:space:]] # git checkout origin/main --detach; git push origin refs/tags/v0.3.3 # ⇒ **紅線寫得越細,命中關鍵字的機率越高**(leo 2026-08-17 的觀察, # 文字層封路必敗)。這裡改成判動作的目標,不是判字面。 - _push_seg=$(printf '%s' "$CMD" | sed -E 's/.*git[[:space:]]+(-[^[:space:]]+[[:space:]]+)*push//' | sed -E 's/[;&|].*//') + # 2026-08-23(順著 inkstone/ISEP#30 comment 3949 補測時自己抓到的洞,不在原票範圍 + # 但屬於同一支閘、同一段邏輯,且直接讓下面「反向不准鬆」的驗證跑不過,所以一併修): + # `(git push origin HEAD:main)`——單純用括號包住整條指令——舊版會整段放行, + # 跟 HERE/戳記完全無關,**連目的地判斷本身都沒觸發**。 + # 成因:截斷 refspec 尾巴只切 `;`/`&`/`|` 三種字元,沒算到 `)`—— + # 於是「HEAD:main)」被當成一個 token,`${_tok##*:}` 剝完冒號還剩「main)」, + # 跟 `^main$` 對不上 ⇒ 判定成「看不出目標」⇒ 整段放行。加 `)` 進截斷字元。 + # git 的 refspec/分支名語法本來就不允許出現 `)`,所以在這裡截斷永遠安全, + # 不會誤傷任何合法的推送目標。 + _push_seg=$(printf '%s' "$CMD" | sed -E 's/.*git[[:space:]]+(-[^[:space:]]+[[:space:]]+)*push//' | sed -E 's/[;&|)].*//') _dest="" _seen_remote=0 _saw_refspec=0 diff --git a/hooks/tests/main-and-prod-push-guard-cross-repo.test.sh b/hooks/tests/main-and-prod-push-guard-cross-repo.test.sh new file mode 100755 index 0000000..fcbec79 --- /dev/null +++ b/hooks/tests/main-and-prod-push-guard-cross-repo.test.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# 跨 repo 戳記實測(inkstone/ISEP#30 comment 3949,脈絡 inkstone/InkStoneCo#57) +# +# 補的是什麼:hooks/tests/main-and-prod-push-guard.test.sh 那八向都只在單一 repo +# (測試腳本自己所在的 repo)裡驗證,從沒測過「站在 A、要推 B 的 main」這個形狀 +# ——而這正是 2026-08-21 真的撞到、讓戳記永遠對不上的那個情境。這支專門補這塊。 +# +# 用法:main-and-prod-push-guard-cross-repo.test.sh <要測的 hook 絕對路徑> +# 路徑務必給絕對路徑——測試會 cd 進臨時建立的 A/B repo 再呼叫它,相對路徑 +# 到那時就對不上了(自己撞過一次:exit=127 command not found)。 +set -u +G="$1" +STAMP=/tmp/.main-push-ok +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"; rm -f "$STAMP"' EXIT + +for d in A B; do + git init -q -b main "$WORK/$d" + git -C "$WORK/$d" config user.email t@t.com + git -C "$WORK/$d" config user.name t + echo x > "$WORK/$d/f.txt" + git -C "$WORK/$d" add f.txt + git -C "$WORK/$d" commit -q -m init +done +A="$WORK/A"; B="$WORK/B" + +pass=0; fail=0 +t() { # t <說明> <指令> <期望 exit> + local desc="$1" cwd="$2" cmd="$3" want="$4" + local rc + rc=$(cd "$cwd" && CLAUDE_CODE_CHILD_SESSION=1 python3 -c ' +import json, subprocess, sys +p = subprocess.run(["bash", sys.argv[2]], + input=json.dumps({"tool_name": "Bash", + "tool_input": {"command": sys.argv[1]}}), + capture_output=True, text=True) +print(p.returncode) +' "$cmd" "$G") + if [ "$rc" = "$want" ]; then printf ' ✅ %-58s exit=%s\n' "$desc" "$rc"; pass=$((pass+1)) + else printf ' ❌ %-58s exit=%s(期望 %s)\n' "$desc" "$rc" "$want"; fail=$((fail+1)); fi +} + +echo "── 2026-08-21 實撞的原形狀:站在 A,要推 B 的 main ──" +rm -f "$STAMP" +t "沒戳記 → 擋" "$A" "cd $B && git push origin HEAD:main" 2 +git -C "$B" rev-parse --show-toplevel > "$STAMP" +t "替 B 開的戳記 → 推 B 的 main 該放行(舊版在此情境永遠擋,這是本票要修的洞)" \ + "$A" "cd $B && git push origin HEAD:main" 0 + +echo "── 反向不准鬆:替 A 開的戳記,不能拿去放行推 B(08-11 那次穿透的形狀)──" +git -C "$A" rev-parse --show-toplevel > "$STAMP" +t "替 A 開的戳記 → 拿去推 B 的 main 必須仍被擋" \ + "$A" "cd $B && git push origin HEAD:main" 2 +rm -f "$STAMP" + +echo "── git -C 語法要吃到同一套判斷 ──" +git -C "$B" rev-parse --show-toplevel > "$STAMP" +t "替 B 開戳記,用 git -C B push" "$A" "git -C $B push origin main" 0 +rm -f "$STAMP" + +echo "── 08-11 原始穿透的形狀:子殼裡的 cd 不能外洩到殼外 ──" +git -C "$A" rev-parse --show-toplevel > "$STAMP" +t "子殼裡 cd 去 B 但沒在殼內推;殼外站著 A 真的推 → 符合 A 的戳記,放行" \ + "$A" "(cd $B && true); git push origin HEAD:main" 0 +rm -f "$STAMP" +git -C "$A" rev-parse --show-toplevel > "$STAMP" +t "子殼裡 cd 去 B 且在殼內真的推 → 目標是 B,戳記是 A,必須擋" \ + "$A" "(cd $B && git push origin HEAD:main)" 2 +rm -f "$STAMP" + +echo "── 順手抓到、一併修的洞:純括號包住整條指令,不准繞過目的地判斷 ──" +t "(git push origin HEAD:main) 沒有任何戳記 → 必須擋(舊版在此整段放行)" \ + "$A" "(git push origin HEAD:main)" 2 + +echo "── 同 repo(session 站著的那個)舊行為原封不動 ──" +rm -f "$STAMP" +t "站在 A 推 A 自己的 main,沒戳記 → 擋" "$A" "git push origin HEAD:main" 2 +git -C "$A" rev-parse --show-toplevel > "$STAMP" +t "站在 A 推 A 自己的 main,替 A 開戳記 → 放行" "$A" "git push origin HEAD:main" 0 +rm -f "$STAMP" + +echo "── 舊有行為一條都不能壞 ──" +t "推 feature branch 放行" "$A" "git push origin feat/xyz" 0 +t "推 tag 放行" "$A" "git push origin refs/tags/v1.0.0" 0 +t "只是提到 main 的 gh pr create,放行" "$A" "gh pr create --base main --title t" 0 + +echo "── subagent 沒戳記,即使 cd 去別的 repo 也照擋 ──" +rm -f "$STAMP" +t "subagent 站在 A、cd 去 B 推 main,沒戳記仍擋" "$A" "cd $B && git push origin HEAD:main" 2 + +echo "── 單次用完即丟、900 秒逾時:換到跨 repo 場景一樣要成立 ──" +git -C "$B" rev-parse --show-toplevel > "$STAMP" +t "第一次:替 B 開戳記推 B → 放行" "$A" "cd $B && git push origin HEAD:main" 0 +t "第二次:同一枚戳記(已用掉)再推一次 → 應該擋" "$A" "cd $B && git push origin HEAD:main" 2 +rm -f "$STAMP"; touch "$STAMP" +t "touch 出的空戳記 → 推 B 的 main 仍應擋(08-12 補的洞不能被本次改動重開)" \ + "$A" "cd $B && git push origin HEAD:main" 2 +rm -f "$STAMP" +git -C "$B" rev-parse --show-toplevel > "$STAMP" +touch -t "$(date -v-16M +%Y%m%d%H%M.%S 2>/dev/null || date -d '-16 minutes' +%Y%m%d%H%M.%S)" "$STAMP" 2>/dev/null +t "16 分鐘前開的戳記 → 已過期,推 B 應擋" "$A" "cd $B && git push origin HEAD:main" 2 +rm -f "$STAMP" + +echo "────── 通過 $pass / 失敗 $fail" +[ "$fail" = 0 ] -- 2.52.0