#!/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//... //milestones/ //labels/ //pulls/...): # 留言、改標籤、關票、改狀態、合併 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/、milestones/、labels/、 # pulls/(後面不管接留言/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/// 或 orgs//,資源名後面立刻是 # 「不是路徑字元」的東西(問號/引號/逗號/右括號/空白/結尾)。 # 中間那段刻意只排除空白(不必排引號):①已經先擋掉「帶 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 < 🔴 搜到同一條線就貼進那張票,不要開新的: scripts/ticket say -F <內文檔> 2. 開票真的是新的一條線 → 直接用那支工具開,它會幫你把該檢查的檢查完: scripts/ticket new -F <內文檔> --title <標題> 3. 這次確實不是在新增(例如批次改標籤/關票/留言/改 milestone 狀態) → 指令裡加 `ticket-api-ok` 說明理由,留痕放行。 放行的情況(本閘不管):純讀取(GET)、對既有票號的留言與標籤與關票、 scripts/ticket 自己。 MSG } >&2 exit 2