41c56acd32
跨 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 <path> && git push` 或 `git -C <path> 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 <noreply@anthropic.com>
175 lines
6.7 KiB
Python
175 lines
6.7 KiB
Python
#!/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 <other-repo> && 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 <path>` 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 <path>` 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 "<expr>" && 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()
|