23e4472715
leo 2026-08-27:「開票不查是否有現成的,這是什麼問題?」「不只開票前, 所有新增 gitea 的東西,都要搜尋」。 v1 的 ticket-api-bypass-guard 只認裸字 `grep -qw POST`(大小寫敏感), 只管 issues 端點。2026-08-27 總管一天開 13 張票,v1 一次都沒攔到—— 用的是 `urllib.request.Request(url, data=...)`(靠傳 data= 隱式變 POST, 指令裡從頭到尾沒有 "POST" 三個字)和 `requests.post(...)`(小寫)。 本次改動: - 寫入訊號從單一裸字換成一組結構性訊號(curl 資料類旗標/`.post(`/ `method=post`/urllib 隱式 POST),且大小寫不敏感,同時保留 v1 的裸字比對 - 端點擴大到 milestones/labels/pulls(含 org 層級的 labels),不再只管 issues - 用「resource/數字」特徵先放行帶 ID 的既有資源子路徑(貼留言、改標籤、 合併 PR 等),避免貪婪比對把 `/issues/5/labels` 誤判成頂層 `/labels` 端點 - 擋下 issues 新增時,順手用標題猜關鍵字查一次跨 repo 搜尋,把可能撞到的 舊票(例如 Arcrun#100)直接列進擋下的訊息裡 測試:scripts/test-ticket-api-bypass-guard.sh 24/24 通過(v1 的 13 條 + 本次 新增 11 條,含一條打真實 Gitea 網路重演 Arcrun#100 重複主題偵測)。 plugin.json 0.5.0 → 0.5.1;version-consistency 會在合併打 tag 那一刻才對齊 (release-tag-guard.sh 的既定分工:打 tag 是總管驗過整個 milestone 之後的事)。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
218 lines
12 KiB
Bash
Executable File
218 lines
12 KiB
Bash
Executable File
#!/bin/bash
|
||
# 管什麼: 用 Gitea API 直接「新增」東西時(開新票/開新 milestone/開新 label/開新 PR),
|
||
# 要求這一輪有跑過搜尋(/tmp/.ticket-where-ok,30 分鐘內)。
|
||
# 為什麼: scripts/ticket 早就強制先搜,但那道閘只擋走正門的人;而擋側門的第一版只認
|
||
# 大寫裸字 POST,2026-08-27 總管用 `requests.post(...)`/隱式 POST 的 urllib
|
||
# 繞了過去,同一天又用 API 直接開了 milestone/label/PR(這版都沒管)。
|
||
# 誤觸時怎麼關: 跑 `scripts/ticket where <關鍵字>` 先搜(之後 30 分鐘所有寫入端點都放行),
|
||
# 或在指令裡加 `ticket-api-ok` 留痕放行。
|
||
# ticket-api-bypass-guard.sh — 新增 Gitea 東西的每一道側門,都要經過同一道搜尋閘
|
||
#
|
||
# 來由(leo 2026-08-20 當場問「如何防止」;2026-08-27 leo 把範圍講寬:
|
||
# 「不只開票前⋯⋯所有新增 gitea 的東西,都要搜尋」):
|
||
# `scripts/ticket new` 早就強制「開票前先搜」(/tmp/.ticket-where-ok 戳記,30 分鐘失效)。
|
||
# 但 2026-08-20 總管開了 12 張與舊票重疊的新票——沒用那支工具,直接打 API。
|
||
# 2026-08-27 總管又開了 13 張,這次連當時的 v1 guard 都繞過了:
|
||
# v1 判準只認一個裸字 `grep -qw POST`(leo 2026-08-17 定的哲學:封動作不封文字,
|
||
# 一個裸字夠了),但那天實際指令是 `req = urllib.request.Request(url, data=...)`
|
||
# (靠傳 data= 隱式變成 POST,指令裡從頭到尾沒有 "POST" 三個字母),或
|
||
# `requests.post(...)`(小寫 post,`grep -qw POST` 大小寫敏感照樣漏)。
|
||
# ⇒ 這不是「該封文字改封動作」的問題(v1 本來就在封動作:打了寫入端點),
|
||
# 是「認動作的方式」本身漏了 HTTP 語意裡的隱式 POST。本版把「寫入訊號」從
|
||
# 單一裸字,換成「curl 的資料類旗標/`.post(`/`method=post`/urllib 隱式 POST」
|
||
# 這組結構性訊號——訊號種類有限、可枚舉,跟關鍵字黑名單不是同一件事。
|
||
#
|
||
# 這支封的是**動作**:任何 Bash 指令只要在對 Gitea 的「集合端點」
|
||
# (issues/milestones/labels/pulls,不帶 ID=新增;帶 ID=改既有的,放行)做寫入,
|
||
# 就要有一個新鮮的搜尋戳記。它不強迫你用 scripts/ticket,只強迫你搜過。
|
||
# (同 InkStoneCo#36:「守 prod 的閘,包一層腳本就繞過去了——它看的是指令長相」。
|
||
# 本支同樣只看得到指令文字,這是 PreToolUse 這層的天花板。)
|
||
#
|
||
# 放行(刻意,這些都不是「新增」):
|
||
# - 只讀不寫(GET):撈清單、看票、對帳
|
||
# - 對既有票號的子路徑(/issues/<N>/... //milestones/<N> //labels/<N> //pulls/<N>...):
|
||
# 留言、改標籤、關票、改狀態、合併 PR——leo 要的是「不要亂開新的」,不是「不准動 Gitea」
|
||
# - scripts/ticket 自己(它有自己的三道閘,重複擋只會互相打架)
|
||
# - 指令裡出現 ticket-api-ok(逃生口,會留在指令歷史上)
|
||
#
|
||
# 只對「issues」做主動搜尋+在擋下的訊息裡直接點名可能撞到的舊票(milestones/labels/pulls
|
||
# 沒有對應的跨 repo 全文搜尋端點可用,一律只要求「搜尋戳記新鮮」,不生產額外訊息——
|
||
# 這是刻意的簡化,不是漏做;真要幫 milestone/label/PR 做語意重複檢查是另一張票的事)。
|
||
# 搜尋本身連不上網路 → 不影響擋/放行的判斷,只影響訊息裡有沒有列出候選舊票(fail-open
|
||
# 在「錦上添花」的部分,不 fail-open 在「該不該擋」這個核心判斷上)。
|
||
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"*|*"ticket close"*|*"ticket decide"*) exit 0 ;; esac
|
||
|
||
# ① 先放行「帶了 ID 的子路徑」——issues/<N>、milestones/<N>、labels/<N>、
|
||
# pulls/<N>(後面不管接留言/labels/合併都算),這些是在動既有的東西,
|
||
# 不是新增。獨立判斷、排在最前面,不要留給下面那條「集合端點」regex 用
|
||
# 貪婪比對去猜——第一版把 owner/repo 寫成「允許斜線的萬用字元」時,
|
||
# `/repos/{REPO}/issues/5/labels`(既有票 #5 底下的 labels,不該擋)會被
|
||
# 貪婪比對成「owner/repo 吃掉 {REPO}/issues/5,資源名是 labels」,跟真正
|
||
# 的頂層 /labels 端點混在一起——先用「resource/數字」這個特徵擋在前面,
|
||
# 後面那條就不必再猜。
|
||
printf '%s' "$CMD" | grep -qE '(issues|milestones|labels|pulls)/[0-9]+' && exit 0
|
||
|
||
# ② 有沒有打到 Gitea 的「集合端點」(不帶 ID 的那個=新增的路徑)
|
||
# repos/<owner>/<repo>/<resource> 或 orgs/<org>/<resource>,資源名後面立刻是
|
||
# 「不是路徑字元」的東西(問號/引號/逗號/右括號/空白/結尾)。
|
||
# 中間那段刻意只排除空白(不必排引號):①已經先擋掉「帶 ID」的形狀,這裡
|
||
# 只要认出「打到了這個資源的集合端點」就好,多抓一點字元進中間那段不影響
|
||
# 判斷——這也是為什麼 f-string 占位符 `f"{API}/repos/{REPO}/issues"`
|
||
# (owner/repo 被壓成一個不含斜線的 token)跟真實網址 `repos/inkstone/ISEP/issues`
|
||
# (owner/repo 是兩段)都吃得下,不必為了兩種形狀各寫一條 pattern。
|
||
ENDPOINT_RE='(repos|orgs)/[^[:space:]]+/(issues|milestones|labels|pulls)([^A-Za-z0-9_/]|$)'
|
||
printf '%s' "$CMD" | grep -qE "$ENDPOINT_RE" || exit 0
|
||
|
||
RESOURCE=$(printf '%s' "$CMD" | grep -oE '/(issues|milestones|labels|pulls)([^A-Za-z0-9_/]|$)' | head -1 | grep -oE 'issues|milestones|labels|pulls')
|
||
|
||
# ③ 有沒有寫入訊號(純 GET 一律放行)。刻意取「結構性訊號」而非單一裸字:
|
||
# - curl:顯式 -X/--request POST,或帶了資料類旗標(curl 帶 -d/--data* 時,
|
||
# 沒寫 -X 也會自動變 POST——這正是舊版漏掉的其中一種隱式 POST)
|
||
# - requests 系函式庫:`.post(`(大小寫都收)
|
||
# - 任何函式庫的 `method=` 參數值是 post(大小寫都收)
|
||
# - urllib.request:`Request(`/`urlopen(` 與 `data=` 同時出現(不要求同一行——
|
||
# 多行 heredoc 裡兩者常常不在同一行)=隱式 POST,這是 2026-08-27 實際撞到的那種寫法
|
||
WRITE_SIGNAL=no
|
||
if printf '%s' "$CMD" | grep -qiw 'POST'; then
|
||
# 保留 v1 的裸字比對,但**大小寫不敏感**——v1 只認大寫 POST,
|
||
# 2026-08-27 `requests.post(...)` 小寫照樣繞過,這裡補上不分大小寫的字。
|
||
WRITE_SIGNAL=yes
|
||
elif printf '%s' "$CMD" | grep -qiE -- '-X[[:space:]]*POST|--request[[:space:]=]*POST|--data(-raw|-binary|-urlencode)?([[:space:]=]|$)|--json([[:space:]=]|$)|\.post\(|method[[:space:]]*=[[:space:]]*.{0,2}post'; then
|
||
WRITE_SIGNAL=yes
|
||
elif printf '%s' "$CMD" | grep -qE 'urllib\.request\.(Request|urlopen)\(' \
|
||
&& printf '%s' "$CMD" | grep -qE '\bdata[[:space:]]*='; then
|
||
# 隱式 POST:urllib.request 傳了 data= 卻沒寫明 method——這是 2026-08-27
|
||
# 實際撞到的寫法,指令裡從頭到尾沒有 "POST" 三個字母,上面兩層都攔不到。
|
||
WRITE_SIGNAL=yes
|
||
fi
|
||
[ "$WRITE_SIGNAL" = yes ] || 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
|
||
|
||
# 只有 issues 才做「主動幫你搜一次」——從指令裡的 title 欄位猜關鍵字,
|
||
# 真的搜得到就把候選舊票直接列進擋下的訊息裡(重演 2026-08-27 的 Arcrun#100)。
|
||
# 任何一步失敗(拿不到 token、連不上網、payload 裡沒有 title)都安靜放棄,
|
||
# 不影響「擋下來」這個判斷本身。
|
||
HITS=""
|
||
if [ "$RESOURCE" = "issues" ]; then
|
||
REMOTES=$(git -C "${CLAUDE_PROJECT_DIR:-.}" remote -v 2>/dev/null || true)
|
||
CRED_LINE=$(printf '%s' "$REMOTES" | grep -m1 'git\.uncle6\.me' | grep -m1 '@' || true)
|
||
if [ -n "$CRED_LINE" ]; then
|
||
TOKEN=$(printf '%s' "$CRED_LINE" | sed -E 's|.*//[^:]+:([^@]+)@.*|\1|')
|
||
# 只認雙引號的 "title": "..."(curl -d/json.dumps 輸出的實際格式,也是
|
||
# 2026-08-27 那 13 張票真實用的寫法,見 scripts/test-ticket-api-bypass-guard.sh
|
||
# 既有的模擬指令)。刻意不支援單引號版本——這段 python 要塞進 bash 單引號
|
||
# 參數字串(`python3 -c '...'`),bash 的單引號字串裡放不進一個字面單引號
|
||
# (沒有跳脫機制),另外分岔一段處理單引號只會逼自己走回「兩層語言的引號
|
||
# 疊在一起」那個坑。找不到 title 就安靜放棄——不影響「擋下來」這個判斷本身,
|
||
# 只是訊息裡少列候選舊票。
|
||
HITS=$(printf '%s' "$CMD" | _TICKET_TOKEN="$TOKEN" python3 -c '
|
||
import os, re, sys, json, urllib.request, urllib.parse
|
||
|
||
cmd = sys.stdin.read()
|
||
pat = "\"title\"\\s*:\\s*\"((?:\\\\.|[^\"\\\\])*)\""
|
||
m = re.search(pat, cmd)
|
||
if not m:
|
||
sys.exit(0)
|
||
title = m.group(1)
|
||
title = title.replace("\\n", " ").replace("\\\"", "\"")
|
||
|
||
toks = []
|
||
for t in re.findall(r"[A-Za-z0-9]+|[一-鿿]{2,}", title):
|
||
if t not in toks:
|
||
toks.append(t)
|
||
toks = toks[:3]
|
||
if not toks:
|
||
sys.exit(0)
|
||
|
||
token = os.environ.get("_TICKET_TOKEN", "")
|
||
seen = {}
|
||
for kw in toks:
|
||
q = urllib.parse.urlencode({"q": kw, "state": "open", "type": "issues", "limit": 20})
|
||
url = "https://git.uncle6.me/api/v1/repos/issues/search?" + q
|
||
try:
|
||
req = urllib.request.Request(url, headers={"Authorization": "token " + token})
|
||
data = json.load(urllib.request.urlopen(req, timeout=6))
|
||
except Exception:
|
||
continue
|
||
for it in (data or []):
|
||
ref = it["repository"]["full_name"] + "#" + str(it["number"])
|
||
row = seen.setdefault(ref, {"score": 0, "title": it["title"]})
|
||
row["score"] = row["score"] + 1
|
||
|
||
hits = sorted(seen.items(), key=lambda kv: -kv[1]["score"])[:6]
|
||
if not hits:
|
||
sys.exit(0)
|
||
joined = " ".join(toks)
|
||
print("(用猜出來的關鍵字「" + joined + "」順手搜了一次,命中:)")
|
||
for ref, info in hits:
|
||
score = info["score"]
|
||
t2 = info["title"][:56]
|
||
print(" [" + str(score) + "] " + ref + " " + t2)
|
||
' 2>/dev/null || true)
|
||
fi
|
||
fi
|
||
|
||
case "$RESOURCE" in
|
||
issues) VERB="開新票" ;;
|
||
milestones) VERB="開新 milestone" ;;
|
||
labels) VERB="開新 label" ;;
|
||
pulls) VERB="開新 PR" ;;
|
||
*) VERB="新增東西" ;;
|
||
esac
|
||
|
||
{
|
||
cat <<MSG
|
||
🚫 你正在用 Gitea API 直接${VERB},而這一輪沒有搜尋紀錄
|
||
|
||
leo 2026-08-16:「寫開票前先去搜尋要開在哪裡,不然你永遠會亂開新票」
|
||
leo 2026-08-27:「開票不查是否有現成的,這是什麼問題?」「不只開票前,
|
||
所有新增 gitea 的東西,都要搜尋」
|
||
|
||
規範有,閘也有——但上一版只認裸字 POST,你這次用的寫法(隱式 POST/小寫
|
||
requests.post/milestone·label·PR 端點)沒被管到。**本閘把這些側門也封上。**
|
||
MSG
|
||
if [ -n "$HITS" ]; then
|
||
printf '\n%s\n' "$HITS"
|
||
fi
|
||
cat <<'MSG'
|
||
|
||
── 怎麼過(擇一)───────────────────────────────
|
||
1. 先搜(預設,戳記 30 分鐘有效,之後所有寫入端點都放行):
|
||
scripts/ticket where <關鍵字...>
|
||
🔴 搜到同一條線就貼進那張票,不要開新的:
|
||
scripts/ticket say <owner/repo#N> -F <內文檔>
|
||
|
||
2. 開票真的是新的一條線 → 直接用那支工具開,它會幫你把該檢查的檢查完:
|
||
scripts/ticket new <repo> -F <內文檔> --title <標題>
|
||
|
||
3. 這次確實不是在新增(例如批次改標籤/關票/留言/改 milestone 狀態)
|
||
→ 指令裡加 `ticket-api-ok` 說明理由,留痕放行。
|
||
|
||
放行的情況(本閘不管):純讀取(GET)、對既有票號的留言與標籤與關票、
|
||
scripts/ticket 自己。
|
||
MSG
|
||
} >&2
|
||
exit 2
|