#!/bin/bash # github-contact-guard.sh — GitHub 接觸保險(D20,2026-07-02) # 背景:兩個帳號因高頻動作被 flag 拿不回(幾十顆星+issues 全損)。 # 設計:戰鬥機武器保險模式——平時所有 github.com 接觸一律機械擋下(exit 2), # 只有 leo 親手跑 scripts/github-arm.sh 解鎖(限時),期間每次接觸自動留痕。 # 誠實限制(mindset §7):AI 技術上可自建 .github-armed 繞過;本 hook 的價值是 # 擋手滑+留審計軌跡,最後一段靠 D20 鐵律與自律。絕不聲稱「不可能繞過」。 # 2026-08-10 leo:以後一律匿名讀,寫才實名(詳見下方判定區塊的說明)。 # # 🔴 另一個誠實限制(2026-08-09,arcrun-rag#27 查出的根因,別再花時間重查): # 本 hook 只看得到「Claude Code 的 Bash 工具呼叫本身的指令字串」。 # 若某支程式(例如 products/arcrun-rag/installer/scripts/ship.mjs)自己用 # node:child_process 的 execFileSync/spawnSync 直接 spawn `git push`, # 那個 push 是那支程式的**子行程**,不會產生新的 Bash 工具呼叫——本 hook # 從頭到尾看不到它,不管下面的正則怎麼改都一樣(8/8 23:16 那次出貨的 push # 就是這樣:hook 看到的指令字串只有 `node ship.mjs --target prod --confirm`, # 裡面沒有 git/github.com 字面,於是判成「匿名讀」放行,連 D20 保險存不存在 # 都沒機會查,log 自然也不會有那一筆)。 # ⇒ 這種「碰 GitHub 的動作被包在別支程式裡」的情況,保險檢查與留痕**必須由 # 那支程式自己做**(見 ship.mjs 用的 installer/scripts/d20-guard.mjs), # 不能指望改這支 hook 就能補到——這支 hook 天生看不見子行程。 INPUT=$(cat) CMD=$(printf '%s' "$INPUT" | python3 -c " import json,sys try: d = json.load(sys.stdin) print(d.get('tool_input',{}).get('command','')) except Exception: print('') " 2>/dev/null) [ -z "$CMD" ] && exit 0 # 命中判定(D20 邊界,2026-07-05 leo 拍板——Facebook 比喻定調): # GitHub 不在乎你「讀」(clone/fetch/抓 release,不管實名匿名、自己的還別人的——那是它原本的功能, # 像 FB 不禁你讀貼文)。它 abuse-detect 的是「機器人一直改/寫」(高頻 push、Actions fan-out、API 寫) # ——那才是害 richblack/uncle6 被 flag 的模式。**真正的線=讀 vs 寫,不是匿名 vs 實名。** # 所以:所有「讀」一律放行(含帶認證 clone 自己的 repo);只擋「寫」與「gh 高頻 API」。 # 只擋以下兩型(寫入 / 會寫的 API): # ① gh CLI 網路子指令(高頻 API,多會寫,且走你 token)② git 寫入動詞指向 github(push / remote add) # 放行:git clone/fetch/pull/ls-remote(任何 repo,帶不帶認證都是讀)、curl/wget、go get/pip。 HIT="" # ① gh CLI —— 高頻 API,全擋(讀寫混雜且走你 token,保守全擋;真要唯讀查詢個案 arm) if printf '%s' "$CMD" | grep -qE '(^|[;&|(]|\s)gh\s+(api|repo|issue|pr|auth|search|release|run|workflow|gist|browse)\b'; then HIT="gh CLI(高頻 API,走你的 token)" # ② git 寫入動詞指向 github(push / remote add 為 push 鋪路)—— 寫入,擋 elif printf '%s' "$CMD" | grep -qiE 'git\s+(push|remote\s+add)([^|;&]*)(github\.com)'; then HIT="git 寫入 → github(push/remote add)" # ②b 🔴 2026-08-05 補漏:上面那條只認指令裡的 **github.com 字面** # ⇒ `git push origin main`(remote 名指向 GitHub)**完全不會被攔**。 # 實撞:08-05 出貨那次 `git push origin main` 是「沒被偵測到」才過的, # 不是靠保險放行——等於 D20 的武器閘有一個大洞,而它平常看起來很正常。 # ⇒ 用 remote 名去把 URL 解出來再判。 # ⚠️ 只有解出來**真的是 github** 才擋——Gitea 是真相源、要能自由推,不可誤傷。 # 解不出來(不在 git repo/remote 不存在)就放行:那種情況 git 自己也會失敗。 # ②c 🔴 2026-08-10 再補一個漏:`git -c http..extraheader=... push -u github main` # (publish-github.sh 的寫法)command 字面裡**本來就看得到** github.com—— # 不必等 remote 名稱解析成功才判斷得出來,而 remote 名稱解析在某些 cwd 下會失敗 # (見檔頭「2026-08-10 leo 簡化」那段的實撞紀錄)。字面查得到就直接判定, # 查不到才退回舊的「解 remote 名稱」那條路——兩條路都失手才會誤放行。 elif printf '%s' "$CMD" | grep -qiE '(^|[;&|(]|\s)git\s+([^|;&]*\s)?push(\s|$)'; then # 🔴 總管 2026-08-10 收窄:原版寫成「整串裡有 github.com 就擋」, # 於是**連 commit message 提到那個網址都會被擋**(我自己第一次要 commit 就撞到)。 # ⇒ 改成必須是「push 的目標」:github.com 要出現在 push 之後、且中間不跨命令分隔符。 # 誤擋的代價不是不方便——是它會逼人拆指令繞路,久了整道閘就沒人當真。 # 第二次收窄:只看「push 後面**第一個非旗標參數**」是不是 github 網址。 # 光要求「同一段命令內」還不夠——commit message 寫「push 到 https://github.com/…」 # 中間沒有 ;&| 一樣命中。真正的推送,網址必定緊接在 push 與旗標之後。 if printf '%s' "$CMD" | grep -qiE 'push(\s+-{1,2}[A-Za-z0-9=_-]+)*\s+(https?://|git@|ssh://)[^[:space:]]*github\.com'; then HIT="git push → github(push 的目標字面就是 github.com)" else CWD=$(printf '%s' "$INPUT" | python3 -c " import json,sys try: print(json.load(sys.stdin).get('cwd','') or '') except Exception: print('') " 2>/dev/null) [ -n "$CWD" ] || CWD=$(pwd) # 取 push 後面第一個非 flag 的字當 remote 名;沒有就是 origin RNAME=$(printf '%s' "$CMD" | sed -nE 's/.*git[[:space:]]+([^|;&]*[[:space:]])?push[[:space:]]+(-[^[:space:]]+[[:space:]]+)*([A-Za-z0-9._//-]+).*/\3/p' | head -1) [ -n "$RNAME" ] || RNAME="origin" RURL=$(git -C "$CWD" remote get-url "$RNAME" 2>/dev/null) if printf '%s' "$RURL" | grep -qi 'github\.com'; then HIT="git push → github(remote「$RNAME」指向 GitHub)" fi fi fi # 明確放行(全部是「讀」,零 flag 風險——GitHub 原本就允許): # git clone/fetch/pull/ls-remote 任何 github repo(自己的/別人的、帶認證與否都是讀)、 # curl/wget 抓 release/raw/codeload、go get/pip 抓公開 module。 # ——讀不是 abuse,不在 D20 射程。唯一風險是「機器人一直寫」,已由 ①② 擋住。 # # 2026-08-10 leo 簡化:讀不分匿名/實名,一律放行、不計數、無頻率閘(見檔頭說明)。 # 舊版這裡曾有一段「實名讀頻率閘」(URL 帶 token/讀自己 private repo → 每 4 小時放行 1 次), # 08-10 真的誤傷過一次出貨(見檔頭)。leo 拍板拿掉:判準只剩「有沒有在寫」,讀就是讀。 # # 🔴 總管 2026-08-10 補回一件(agent 原版把偵測整段刪了,我只拿掉「擋」,不拿掉「看得見」): # leo 的規則是「**一律匿名讀**」⇒ 帶著憑證去讀,本身就已經偏離規則。 # hook 沒辦法強迫一條指令變匿名,但**可以留下痕跡**—— # 否則哪天 GitHub 又來說我們高頻活動,我們手上沒有任何紀錄可以自證讀了什麼。 # ⇒ 實名讀:**放行**(照 leo 的簡化,不擋、不限速),但**寫一行進接觸紀錄**。 if [ -z "$HIT" ]; then if printf '%s' "$CMD" | grep -qiE '(https?://)[^[:space:]/@]+@[a-z0-9._-]*(github\.com|githubusercontent\.com|codeload\.github)'; then _LOG="${CLAUDE_PROJECT_DIR:-$(pwd)}/system-dev/docs/3-specs/autonomy-dispatch/github-contact-log.md" printf -- '- %s | 實名讀(URL 帶憑證,已放行)| %s\n' \ "$(date '+%Y-%m-%d %H:%M')" "$(printf '%s' "$CMD" | sed 's|//[^@]*@|//<憑證省略>@|g' | cut -c1-120)" \ >> "$_LOG" 2>/dev/null fi exit 0 fi PROJ="${CLAUDE_PROJECT_DIR:-$(pwd)}" ARM_FILE="$PROJ/.github-armed" LOG_FILE="$PROJ/system-dev/docs/3-specs/autonomy-dispatch/github-contact-log.md" if [ -f "$ARM_FILE" ]; then EXPIRY=$(sed -n '1p' "$ARM_FILE") MISSION=$(sed -n '2p' "$ARM_FILE") NOW=$(date +%s) if [ "$NOW" -le "${EXPIRY:-0}" ] 2>/dev/null; then # 已解鎖 → 放行 + 留痕(BDA 戰損記錄) SHORTCMD=$(printf '%s' "$CMD" | head -c 200 | tr '\n' ' ') printf '| %s | %s | `%s` |\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$MISSION" "$SHORTCMD" >> "$LOG_FILE" 2>/dev/null exit 0 else rm -f "$ARM_FILE" echo "❌ BLOCKED by D20 GitHub 接觸儀式" >&2 echo "違反項:保險已過期(武裝時效已過,自動回到保險狀態)" >&2 echo "正確做法:需要繼續接觸 GitHub → 請 leo 重新在終端機跑 scripts/github-arm.sh \"任務描述\"" >&2 exit 2 fi fi echo "❌ BLOCKED by D20 GitHub 接觸儀式(武器保險未解除)" >&2 echo "命中:${HIT}" >&2 echo "背景:兩個 GitHub 帳號因高頻動作被 flag 永久拿不回。平時一律只碰 Gitea。" >&2 echo "正確做法(發射流程):" >&2 echo " 1. 先自問:這件事 Gitea 做不到嗎?(issue/PR/push 內部協作 Gitea 全都能)" >&2 echo " 2. 真的非 GitHub 不可 → 向 leo(總部)請示:目標 repo、動作清單、預估請求次數、為何非它不可" >&2 echo " 3. leo 同意後由 leo 親手在終端機跑:scripts/github-arm.sh \"任務描述\" [分鐘,預設30]" >&2 echo " 4. 解鎖期間動作守 ROE:單 repo、≤5 次網路請求、禁批量/迴圈/Actions、間隔像人手" >&2 echo "參考:system-dev/docs/2-architecture/decisions/D20-github-contact-protocol.md" >&2 exit 2