Files
ISEP/hooks/github-contact-guard.sh
T
isep-hand 49a7145e70 兩支閘改看「指令要動的那個 repo」,不看 hook 自己的環境/cwd(inkstone/ISEP#109 → comment 6629)
① line-needs-own-worktree.sh:出路 `WORKTREE_OK=1 git -C … checkout …` 改認指令字串裡的字面前綴
   (舊版讀 hook 自己的環境變數,PreToolUse hook 跟指令不是同一個行程,那行永遠走不通)。
   判準是位置不是字:前綴必須掛在會移動 HEAD 的那條 git 指令上。
   lib/checkout_target_dir.py 加 `--escape NAME=1`;push_target_dir._classify 在 strip_env 時
   把前綴留在 verb 事件裡(find_push_target 行為不變,主線閘 10+19 條照綠)。
② github-contact-guard.sh:remote 名在「這條指令實際會推的那個 repo」裡解(沿用 lib/push_target_dir.py
   解 cd 鏈/-C/子殼),解不出來才退回 cwd。判準仍是 remote URL 主機,不是「有 -C 就放行」。

測試:A24 59→69(E 群把閘印的那一行原樣餵回去;舊閘 3 條紅)、
      A39 14→26(payload 帶 cwd、cwd≠目標 repo;舊閘 7 條紅=4 誤攔+3 漏擋)。
盤點表:61 支/85 條,改判準不加閘,當場數的。
版本:待總管定版。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0178ef1fGw3XeZtpN7LaZrm4
2026-09-07 06:31:45 +00:00

172 lines
12 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/bin/bash
# 管什麼: 對外接觸 GitHub 的寫入動作(gh CLI 高頻 API、git push/remote add 指向 github.com)——一律先擋,要 leo 手動解保險才放行。
# 為什麼: D202026-07-02)——兩個帳號因高頻寫入被 GitHub flag 永久拿不回;讀不設限(讀不是 abuse),只擋寫。
# 誤觸時怎麼關: 讀取(clone/fetch/pull/curl 抓檔)一律放行;真要寫 GitHubleo 跑 scripts/github-arm.sh 解保險;純粹提到 github.com 的文字(heredoc/commit message/留言)不算數,見下方判準。
# github-contact-guard.sh — GitHub 接觸保險(D202026-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-09arcrun-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
# 2026-08-20inkstone/InkStoneCo#23 comment, #56):a heredoc body is data, not an
# instruction. Writing docs/TESTING.md (one example line of a real push command,
# meant for a human to try later) got blocked because the whole heredoc body was
# scanned for keywords. Strip heredoc bodies before matching; on failure fall back
# to the original string (fail-open on "did this helper run", not on the verdict).
CMD=$(printf '%s' "$CMD" | python3 "$(dirname "$0")/lib/strip_heredoc.py" 2>/dev/null || printf '%s' "$CMD")
# 命中判定(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 寫入動詞指向 githubpush / 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 '(^|[;&|(`]|&&|\|\|)[[:space:]]*gh[[:space:]]+(api|repo|issue|pr|auth|search|release|run|workflow|gist|browse)\b'; then
HIT="gh CLI(高頻 API,走你的 token"
# ② git 寫入動詞指向 githubpush / remote add 為 push 鋪路)—— 寫入,擋
elif printf '%s' "$CMD" | grep -qiE '(^|[;&|(`]|&&|\|\|)[[:space:]]*git[[:space:]]+(push|remote[[:space:]]+add)([^|;&]*)(github\.com)'; then
HIT="git 寫入 → githubpush/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.<url>.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 '(^|[;&|(`]|&&|\|\|)[[:space:]]*git[[:space:]]+([^|;&]*[[:space:]])?push([[:space:]]|$)'; 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 → githubpush 的目標字面就是 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)
# 🔴 2026-09-07inkstone/ISEP#109 → comment 6629):remote 名要在**這條指令實際會推的
# 那個 repo** 裡解,不是在 hook 收到的 cwd 裡解。雲端薄殼的 cwd 是 GitHub 那份
# origingithub.com),總管站在那裡 `git -C <InkStoneCo> push origin x`
# InkStoneCo 的 originGitea)——舊版拿 cwd 解 origin ⇒ 判成「指向 GitHub」擋下,
# `-C` 看都沒看;反過來,站在 Gitea 那份 `git -C <薄殼> push origin x` 則**漏擋**。
# ⇒ 沿用 lib/push_target_dir.py(推 main 那道閘 08-23 就是為同一個病接的它):
# 純 tokenize 解出 cd 鏈/-C,解得出來就 cd 進去(唯讀)問 git;解不出來
# (`cd $VAR`、目錄不存在)才退回 cwd——退回的方向是「照舊」,不是放寬。
# 判準沒變:仍是 remote 解出來的 URL 主機(唯一識別碼),不是「有沒有 -C」。
PUSHDIR="$CWD"
_ptd="$(dirname "$0")/lib/push_target_dir.py"
if [ -f "$_ptd" ]; then
_expr=$(printf '%s' "$CMD" | python3 "$_ptd" 2>/dev/null || printf '')
if [ -n "$_expr" ]; then
PUSHDIR=$(cd "$CWD" 2>/dev/null && cd "$_expr" 2>/dev/null && pwd) || PUSHDIR="$CWD"
fi
fi
# 取 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 "$PUSHDIR" remote get-url "$RNAME" 2>/dev/null)
if printf '%s' "$RURL" | grep -qi 'github\.com'; then
HIT="git push → github$PUSHDIR 的 remote「$RNAME」指向 GitHub$(printf '%s' "$RURL" | sed 's|//[^@]*@|//<憑證省略>@|g')"
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