diff --git a/hooks/hooks.json b/hooks/hooks.json index 37e845c..432cbda 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -36,6 +36,10 @@ { "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/release-tag-guard.sh" + }, + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/ticket-api-bypass-guard.sh" } ] }, diff --git a/hooks/ticket-api-bypass-guard.sh b/hooks/ticket-api-bypass-guard.sh new file mode 100755 index 0000000..f9d00a8 --- /dev/null +++ b/hooks/ticket-api-bypass-guard.sh @@ -0,0 +1,81 @@ +#!/bin/bash +# ticket-api-bypass-guard.sh — 開票的「側門」也要經過同一道搜尋閘 +# +# 來由(leo 2026-08-20 當場問「如何防止」): +# `scripts/ticket new` 早就強制「開票前先搜」(/tmp/.ticket-where-ok 戳記,30 分鐘失效)。 +# 但總管當天開了 12 張與舊票重疊的新票——因為他**沒用那支工具,直接打 Gitea API**。 +# ⇒ 規範有、閘也有,但閘長在「工具」上,而那個動作有兩條路,只封了一條。 +# leo:「你在讓事情複雜化」/「每張票開以前都要搜尋現有票,你為什麼會開了不搜?」 +# +# 這支封的是**動作**:任何 Bash 指令只要在對 Gitea 的 issues 端點做寫入, +# 就要有一個新鮮的搜尋戳記。它不強迫你用 scripts/ticket,只強迫你搜過。 +# (同 InkStoneCo#36:「守 prod 的閘,包一層腳本就繞過去了——它看的是指令長相」。 +# 本支同樣只看得到指令文字,這是 PreToolUse 這層的天花板; +# 所以判準取「端點 + 寫入動詞」兩個都命中才擋,讓純讀取一律放行。) +# +# 放行(刻意,這些都不是「開票」): +# - 只讀不寫(GET):撈清單、看票、對帳 +# - 對既有票的留言/改標籤/關票(/issues//... 這種帶票號的子路徑) +# - scripts/ticket 自己(它有自己的閘,重複擋只會互相打架) +# - 指令裡出現 ticket-api-ok(逃生口,會留在指令歷史上) +set -uo pipefail + +INPUT=$(cat) +CMD=$(printf '%s' "$INPUT" | python3 -c " +import sys,json +try: print(json.load(sys.stdin).get('tool_input',{}).get('command','')) +except Exception: print('') +" 2>/dev/null) +[ -n "$CMD" ] || exit 0 + +# 逃生口(留痕) +case "$CMD" in *ticket-api-ok*) exit 0 ;; esac +# scripts/ticket 有自己的閘 +case "$CMD" in *scripts/ticket*|*"ticket where"*|*"ticket new"*|*"ticket say"*) exit 0 ;; esac + +# ① 有沒有打到 Gitea 的 issues 端點(不帶票號的那個=建立新票的路徑) +printf '%s' "$CMD" | grep -qE 'repos/[^ "'"'"']*/issues([?"'"'"'`,)\\[:space:]]|$)' || exit 0 + +# ② 指令裡有沒有 POST 這個詞(純 GET 一律放行) +# 刻意只認一個裸字:跳脫引號、heredoc、python、curl、各種包裝的寫法無限多, +# 逐個補 pattern 追不完(leo 2026-08-17:「自然語言的變體是無限的,blacklist 永遠追不完」)。 +# ①已經確定這是「開票那條端點」,純讀取的指令不會出現 POST ⇒ 一個字就夠,而且沒有跳脫的破口。 +printf '%s' "$CMD" | grep -qw 'POST' || exit 0 + +# ③ 要有新鮮的搜尋戳記(與 scripts/ticket 共用同一個,30 分鐘) +STAMP=/tmp/.ticket-where-ok +NOW=$(date +%s) +FRESH=no +if [ -f "$STAMP" ]; then + AT=$(python3 -c "import json;print(int(json.load(open('$STAMP'))['at']))" 2>/dev/null || echo 0) + [ $((NOW - AT)) -le 1800 ] && FRESH=yes +fi +[ "$FRESH" = "yes" ] && exit 0 + +cat >&2 <<'MSG' +🚫 你正在用 Gitea API 直接開新票,而這一輪沒有搜尋紀錄 + +leo 2026-08-16:「**寫開票前先去搜尋要開在哪裡,不然你永遠會亂開新票**」 +leo 2026-08-20:「**每張票開以前都要搜尋現有票,你為什麼會開了不搜?這個規範不是早就有 hook 了?**」 + +規範有,閘也有——但那道閘長在 `scripts/ticket` 這支工具裡, +而你走的是 API 這條側門。**本閘就是把那道門也封上。** + +實錯(2026-08-20 同日):總管用 API 開了 12 張票,事後盤點**每一張都跟舊票重疊**, +全部只能關掉指回舊票。leo:「**你在讓事情複雜化**」。 + +── 怎麼過(擇一)─────────────────────────────── + 1. 先搜(預設,戳記 30 分鐘有效,之後 API 也放行): + scripts/ticket where <關鍵字...> + 🔴 搜到了就**貼進那張票**,不要開新的: + scripts/ticket say -F <內文檔> + + 2. 真的是新的一條線 → 直接用那支工具開,它會幫你把該檢查的檢查完: + scripts/ticket new -F <內文檔> --title <標題> + + 3. 這次確實不是在開新票(例如批次改標籤/關票/留言) + → 指令裡加 `ticket-api-ok` 說明理由,留痕放行。 + +放行的情況(本閘不管):純讀取(GET)、對既有票 /issues// 的留言與標籤、scripts/ticket 自己。 +MSG +exit 2