Files
hooks/hooks/restart-gate.sh
T
jiantw83 0e9308f60e feat(hooks): 技能名解析與阻擋輸出共用化
What:
- 新增 hooks/skill-name.sh。五支 CLI 各一個子命令,從各自的負載解析出這一次要用哪一支 jsc 技能,印一行「{domain}<TAB>{技能名}」。解不出來就印空字串並回 0。
- 新增 hooks/deny.sh。依當前 CLI 產出四種阻擋形態,訊息從參數或標準輸入進。
- version-guard.sh 與 restart-gate.sh 改用這兩支,各自那份工具名判定與技能名取值一併移除。

Why:
- 兩道閘門原本都拿 Claude 的工具名 Skill 當通用條件。另外四支 CLI 的工具名分別是 Bash、skill、view_file,一律被擋在判定之外,兩道閘門在那四支上長期完全失效,而且一聲都不吭。
- 阻擋形態每支 CLI 都不一樣,判定卻是同一件事。各自留一份輸出邏輯,改了一支忘了另一支,就會做出「判定擋下、CLI 照樣放行」的無聲失效。

How:
- 技能名取值規則只留 skill-name.sh 這一份,環境變數 JSC_SKILL、SKILL 優先。claude 讀 skill 欄位、codex 認 tool_input.command 裡那條 SKILL.md 路徑、copilot 先剝一層字串化 JSON 再讀 toolArgs、antigravity 讀 toolCall.args.AbsolutePath 並另收提示字串、kiro 取提示開頭那個斜線指令。
- 閘門端用 awk 判 NF == 2 才取值。少一欄就當成解析不出來,免得沒有定位字元時 cut -f2 把整行當成技能名,拼出一個不存在的技能名去比對豁免清單。
- deny.sh 定形態:claude、codex、copilot 走 stderr 加 exit 2;antigravity 印 stdout 的單行 deny JSON 並固定回 0,因為那支 CLI 的結束碼語意兩邊文件都沒寫、絕不可靠;kiro 擋不下技能叫用,改印警告後回 0;認不得的代號走 stderr 加 2 這個保守預設。
- 訊息整段走同一條管線送進 deny.sh。分次呼叫會做出好幾份 deny JSON,antigravity 只認第一份,後面幾段使用者永遠看不到。
- 豁免清單與 fail-open 原則不變。這兩支刻意以子行程呼叫、不用 source 載入:讀不到只會讓技能名解不出來而安靜放行,不會反過來擋掉每一次呼叫。

Who:codex、copilot、antigravity、kiro 四支 CLI 的 pre-tool hook 接線修正。
2026-08-31 19:05:48 +08:00

254 lines
16 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.
#!/usr/bin/env sh
# restart-gate.sh — 部署後強制重啟閘門(PreToolUse,matcher: Skill)。
#
# 技能組更新後,正在跑的 CLI 行程載入的還是舊版:SKILL.md、hook 腳本與 tools 都在啟動當下
# 讀進記憶體。所以部署收尾要求重新啟動,這道閘門負責讓「還沒重啟就繼續用技能」擋在門外。
#
# 結束碼(hook 模式):0=放行 2=擋下該次技能呼叫。
# 擋下時的輸出形態由 deny.sh 依當前 CLI 決定,本檔只負責判定與訊息內容:
# claude、codex、copilot 走 stderr 加 exit 2;antigravity 走 stdout 的 deny JSON,結束碼
# 固定 0(那支 CLI 的結束碼語意沒有文件,不可靠);kiro 擋不下來,改印警告後 exit 0。
# 所以「exit 0」在這支腳本有兩種意思:放行,或已經以不靠結束碼的形態擋下。
# 安靜放行(exit 0)的情況:逃生門 JSC_RESTART_GATE=off、負載裡解不出技能名、
# 解出來的不是 jsc 技能、命中下方豁免清單那 10 支、取不到 CLI 代號、
# 當前 CLI 那份狀態檔與舊格式狀態檔都不在。
# 只有「當前 CLI 那份狀態檔存在」或「退回讀到的舊格式狀態檔存在」會走 deny.sh。
# 結束碼(require):0=閘門已掛上 2=取不到 CLI 代號或寫不進狀態檔,兩種都等於沒掛上。
# 結束碼(clear、report):0=永遠成功。clear 檔案不存在也算成功,report 一份都沒有就不印。
# 結束碼(不認得的子命令):0=安靜放行,不中斷宿主 CLI。
# 註:本檔以 `. "$HERE/lib.sh"` 載入共用函式,沒有接 `|| true`。lib.sh 讀不到時 sh 會就地
# 結束並回 2,接在 PreToolUse 上就是無聲擋下每一次技能呼叫,上面那些放行路徑一條都跑不到。
# hooks/skill-name.sh 與 hooks/deny.sh 同理要一起裝上,但那兩支是以子行程呼叫,讀不到只會
# 讓技能名解不出來而安靜放行,不會反過來擋人——所以那兩支刻意不用 source 載入。
#
# 輸入:技能名一律由 skill-name.sh 從當前 CLI 的負載解析,環境變數 JSC_SKILL、SKILL 優先,
# 規則與 version-guard.sh 共用同一份。不再另外篩工具名:工具名每支 CLI 都不一樣
# (Skill、Bash、skill、view_file),拿 Claude 的那一個當通用條件會把另外四支整批擋在判定之外。
#
# 用法:
# restart-gate.sh hook 模式:當前 CLI 那份狀態檔存在就擋下該次技能
# 呼叫(exit 2)。別支 CLI 那幾份不看。
# restart-gate.sh require {模式} [{domain}...]
# 寫入當前 CLI 那份狀態檔,掛上這一支的閘門。由
# jsc-cli:deploy 在 install 或 update 收尾時呼叫;模式為
# install 或 update,之後接這次更新的 domain 清單。
# exit 0 = 已掛上;exit 2 = 取不到 CLI 代號或寫不進去
# (兩種都等於沒掛上)。
# restart-gate.sh clear 只清除當前 CLI 那份狀態檔,放下這一支的閘門。由
# session-timer.sh 在判定為新工作階段時呼叫(見下方
# 「清除時機」)。檔案不存在也算成功。
# restart-gate.sh report 印出每一份狀態檔的內容,一支 CLI 一行(格式見下方
# 「report 輸出格式」);一份都沒有就不印,一律 exit 0。
#
# require、clear、report 都不讀標準輸入,只有 hook 模式讀。理由與 sdlc-gate.sh 相同:
# read_stdin 在標準輸入是管線又沒人關閉時會一直等,工具端呼叫就整支卡死。新增子命令照這個
# 原則歸類,工具端呼叫一律再補 </dev/null。
#
# --- 狀態檔格式 ---
#
# $JSC_HOME/restart-required.d/{CLI 代號}(JSC_HOME 未設定時為 ~/.jsc),一支 CLI 一份,
# 檔名就是 CLI 代號(claude、codex、copilot、antigravity、kiro)。內容為純文字 key=value,
# 一行一欄位,順序不拘,不認得的鍵一律忽略。格式壓到最簡,jsc-hooks 與 jsc-cli 兩邊各自
# 實作也對得上。
# at={ISO 時間} 部署收尾時間,UTC
# mode={install|update} 這次部署的模式
# domains={domain 清單} 這次更新到的 domain,空白分隔
# cli={CLI 代號} 執行部署的 CLI,與檔名相同
# 欄位只用在擋人訊息上。判定看的是「當前 CLI 那份檔案在不在」——檔案存在就是這一支還沒重啟
# 過的證據,欄位缺了只讓訊息少幾個字,不影響判定。
#
# 為什麼一支 CLI 一份:一台機器上五支 CLI 各自是獨立行程,各自載入自己記憶體裡的那一版。
# 早先的單一檔案設計有兩個實測抓到的洞——並行部署互相覆寫(後寫的把 domains 與 cli 蓋掉,
# 欄位不再代表先寫的那一支),以及任一支 CLI 重啟就把五支的閘門一起解除(其餘四支沒重啟卻
# 不再被擋,閘門等於半失效)。拆成一支一份之後,寫入、判定、清除三件事都只碰自己那一份。
#
# --- report 輸出格式 ---
#
# 一行一份狀態檔,欄位以空白分隔,domains 可能含空白所以擺最後:
# {CLI 代號} at={ISO 時間} mode={install|update} domains={domain 清單}
# 有幾行就代表有幾支 CLI 還沒重啟。欄位缺值時只留鍵名(例如 domains=)。第一欄印 legacy 的
# 那一行代表舊格式的單一狀態檔(見下方「舊檔相容」),它不屬於任何一支 CLI。
#
# --- 舊檔相容(過渡用) ---
#
# 舊版把狀態寫進 $JSC_HOME/restart-required 單一檔案。改用狀態目錄的第一輪部署,機器上可能
# 還留著那份舊檔:完全不認它,那一輪的閘門會整輪漏掉,檔案本身也會永遠留著變垃圾。所以:
# 判定:舊檔存在就一律擋,視為「每一支 CLI 都有未重啟的部署」。舊檔沒有 per-CLI 資訊,
# 分不出是哪一支寫的,寧可擋多不擋少。擋人訊息會標明這是舊格式紀錄。
# 清除:clear 除了刪當前 CLI 那一份,也一併刪掉舊檔。取捨講白:clear 只在新工作階段被
# 呼叫,呼叫到就代表確實有一支 CLI 重新啟動過了;舊檔沒有 per-CLI 資訊,留著會讓五支
# CLI 一路被擋到有人手動刪,刪掉是唯一收斂的做法。代價是同一輪部署的其他 CLI 少擋
# 一次,只影響改用狀態目錄的那一輪。
# 這一段是過渡用的:所有機器都跑過一次寫狀態目錄的部署與重啟之後,舊檔不會再被寫出來,屆時
# 可以整段移除——LEGACY_STATE、hook 判定裡的舊檔分支、clear 裡的舊檔刪除、report 的
# legacy 行、以及本節。
#
# --- 清除時機 ---
#
# 清除由 session-timer.sh 在「這一次 SessionStart 是新的工作階段」那一刻呼叫,不由本檔自己判定:
# 新舊工作階段的判準(sessions/{sid}.start 在不在)只有那支腳本知道,兩邊各寫一份就會漂移。
# 新的工作階段代表 CLI 行程是新起的,新版一定已經載入,所以清除是對的。續接同一階段
# (SessionStart 再觸發、resume、compact)不會走到那一段,閘門就一路留到真的重新啟動。
# 清除的範圍就是呼叫端那一支 CLI:那一支重啟了,不代表別支也重啟了。
#
# --- 判定原則 ---
#
# 比照 version-guard.sh:只擋確定違規,查不到基礎資訊一律放行(exit 0)。狀態檔讀不到、
# CLI 代號取不到、技能名取不到、工具名不是 Skill,四種都放行——沒有證據時擋下等於停掉每一次
# 技能呼叫。
#
# 豁免(這些技能永遠放行,改動前想清楚後果):
# jsc-cli:deploy 部署入口本身,也是唯一能把技能組換成新版的路徑,擋了會死鎖
# jsc-hooks:hooks-install 部署後要重新接線,擋了會讓部署做一半卡住
# jsc-hooks:repair 接線或執行期出錯時唯一的修復路徑。修 hook 的技能被 hook 擋下,
# 就沒有任何方法把 hook 修回來,閘門等於把解除自己的路徑一起鎖掉
# jsc-gitea:wiki 寫技能組異動報告與工作日誌都要它落地,擋了報告寫不完
# jsc-log:worklog 部署後還要寫得完工作日誌(R1)
# jsc-log:learn 同上,教訓也要記得完
# jsc-meta:* 技能組異動報告(R13)由這一組技能產出,另外它們是修技能組的工具
# jsc-ask:ask 上面幾支都要問使用者,擋了 deploy 連 install 或 update 都問不出來
# jsc-git:pr 報告與異動收尾要開 PR,擋了收尾做不完
# jsc-git:commit 同上,pr 的第一步就是它
# 理由講白:部署後還有兩條規則要收尾——技能組異動報告(R13)與工作日誌(R1)。整批擋下去,
# 「先重啟」與「先寫完報告」會互相打死,使用者兩件事都做不完。
#
# 清單認的是技能名,不是呼叫鏈:豁免技能轉呼叫的下一層若不在清單上,那一層照樣會被擋。
# 後三支(ask、pr、commit)就是為了這件事補進來的——它們自己不是收尾規則的主體,但前七支
# 少了它們就走不完:deploy 問不出模式、報告寫完開不了 PR。version-guard.sh 當年把
# jsc-ask:ask 與 jsc-gitea:wiki 放進豁免,也是同一個原因。
# 還有巢狀呼叫走不下去時,先重新啟動;真的卡死才下 JSC_RESTART_GATE=off。
#
# 逃生門:JSC_RESTART_GATE=off 完全略過這道閘門。
HERE=$(dirname "$0"); . "$HERE/lib.sh"
STATE_DIR="$JSC_HOME/restart-required.d"
# 舊格式的單一狀態檔。只為過渡而讀,可移除的時機見檔頭「舊檔相容」。
LEGACY_STATE="$JSC_HOME/restart-required"
# 當前 CLI 代號;取不到就不輸出,由呼叫端決定怎麼降級。取法與其他 hook 一致(JSC_CLI 優先,
# 其次 lib.sh 的 cli_name)。不像代號的值一併當成取不到:這個值直接拿去當檔名,帶斜線或
# 點號開頭的值會把檔案寫到狀態目錄外面去。
cli_code() {
_c=$(cli_name)
case "$_c" in
""|unknown) return 0 ;;
.*|*[!A-Za-z0-9._-]*) return 0 ;;
esac
printf '%s' "$_c"
}
# 從指定狀態檔取一個欄位;檔案讀不到或欄位不存在就不輸出。
state_field() { # $1=狀態檔 $2=鍵名
[ -f "$1" ] && [ -r "$1" ] || return 0
sed -n "s/^$2=//p" "$1" 2>/dev/null | head -n1
}
# 一份狀態檔印一行,格式見檔頭「report 輸出格式」。$1=第一欄要印的名稱 $2=狀態檔
state_line() {
printf '%s at=%s mode=%s domains=%s\n' "$1" \
"$(state_field "$2" at)" "$(state_field "$2" mode)" "$(state_field "$2" domains)"
}
case "${1:-}" in
require)
_mode="${2:-update}"
_domains=""
if [ "$#" -gt 2 ]; then shift 2; _domains="$*"; fi
_cli=$(cli_code)
if [ -z "$_cli" ]; then
# 取不到代號就不知道該寫哪一份,寫成別的檔名也沒用:hook 模式同樣取不到代號,那一份
# 永遠不會被讀到。沒掛上就要講出來,不能讓部署以為掛上了。
printf '[jsc][重啟閘門][ERR]:取不到可用的 CLI 代號(JSC_CLI 未設定,或值不是代號),這次部署沒有掛上重啟閘門。\n' >&2
exit 2
fi
mkdir -p "$STATE_DIR" 2>/dev/null || true
printf 'at=%s\nmode=%s\ndomains=%s\ncli=%s\n' \
"$(now_iso)" "$_mode" "$_domains" "$_cli" > "$STATE_DIR/$_cli" 2>/dev/null || {
# 寫不進去要講出來:沒寫成就沒有閘門,部署卻以為掛上了。
printf '[jsc][重啟閘門][ERR]:寫不進 %s,這次部署沒有掛上重啟閘門。\n' "$STATE_DIR/$_cli" >&2
exit 2
}
exit 0 ;;
clear)
_cli=$(cli_code)
# 只刪自己那一份。別支 CLI 沒有跟著重啟,它們的閘門要留著。
[ -n "$_cli" ] && rm -f "$STATE_DIR/$_cli" 2>/dev/null
# 舊檔一併刪,取捨與可移除時機見檔頭「舊檔相容」。
rm -f "$LEGACY_STATE" 2>/dev/null || true
exit 0 ;;
report)
# 目錄裡一份都沒有時,未展開的樣式字串會由 -f 判斷擋掉。
for _f in "$STATE_DIR"/*; do
[ -f "$_f" ] && [ -r "$_f" ] || continue
state_line "$(basename "$_f")" "$_f"
done
if [ -f "$LEGACY_STATE" ] && [ -r "$LEGACY_STATE" ]; then
state_line legacy "$LEGACY_STATE"
fi
exit 0 ;;
"") ;; # 落到下面的 hook 模式
*) exit 0 ;; # 不認得的子命令一律安靜放行,不中斷宿主 CLI
esac
read_stdin
[ "${JSC_RESTART_GATE:-}" = "off" ] && exit 0
# 技能名解析:交給 skill-name.sh,規則與 version-guard.sh 共用同一份。輸出固定是
# 「{domain}<TAB>{技能名}」;用 awk 判 NF==2 才取值,少一欄就當成解析不出來,免得沒有定位字元時
# cut -f2 把整行當成技能名,拼出一個不存在的技能名去比對豁免清單。
sn=$(printf '%s' "$STDIN_JSON" | sh "$HERE/skill-name.sh" "$(cli_name)" 2>/dev/null)
sn_domain=$(printf '%s\n' "$sn" | awk -F'\t' 'NF == 2 { print $1; exit }')
sn_name=$(printf '%s\n' "$sn" | awk -F'\t' 'NF == 2 { print $2; exit }')
[ -n "$sn_domain" ] && [ -n "$sn_name" ] || exit 0
skill="jsc-$sn_domain:$sn_name"
# 豁免清單(理由見檔頭)
case "$skill" in
jsc-cli:deploy|jsc-hooks:hooks-install|jsc-hooks:repair|jsc-gitea:wiki|jsc-log:worklog|jsc-log:learn|jsc-meta:*|jsc-ask:ask|jsc-git:pr|jsc-git:commit)
exit 0 ;;
esac
# CLI 代號取不到就放行:不知道現在跑的是哪一支,就不知道該讀哪一份狀態檔,等同沒有證據。
cli=$(cli_code)
[ -n "$cli" ] || exit 0
# 只看自己那一份;沒有才退回看舊檔。兩份都沒有就放行——沒有「剛部署過」的證據,就沒有擋人的
# 理由。別支 CLI 那幾份一律不看:那些是別的行程,重啟與否跟這一支無關。
state="$STATE_DIR/$cli"
legacy=no
if [ -f "$state" ] && [ -r "$state" ]; then
:
elif [ -f "$LEGACY_STATE" ] && [ -r "$LEGACY_STATE" ]; then
state="$LEGACY_STATE"; legacy=yes
else
exit 0
fi
at=$(state_field "$state" at)
mode=$(state_field "$state" mode)
domains=$(state_field "$state" domains)
# 重啟方式依實際 CLI 給。印別的 CLI 的執行檔名等於沒給指示。
bin=$(cli_bin "$cli")
# 訊息裡的部署資訊逐段接起來,缺欄位就少一段,不會留下空括號或多餘的逗號。
info=""
[ -n "$at" ] && info="$at"
[ -n "$mode" ] && info="${info}${info:+,}模式 $mode"
[ -n "$domains" ] && info="${info}${info:+,}domain:$domains"
# 舊格式紀錄要標出來:它分不出是哪一支 CLI 部署的,所以每一支都擋,看到訊息的人才不會以為
# 系統認定就是這一支剛部署過。
[ "$legacy" = yes ] && info="${info}${info:+,}舊格式紀錄,分不出是哪一支 CLI 部署的"
# 擋人輸出交給 deny.sh:形態依 CLI 而定,本檔只組訊息。三段訊息整段走同一條管線送過去,
# antigravity 那一支才有辦法把它們壓成同一個 reason 字串;分次呼叫會做出好幾份 deny JSON,
# 那支 CLI 只認第一份,後面兩段使用者永遠看不到。
{ printf '[jsc][重啟閘門][ERR]:技能組已更新%s,%s 還在跑舊版,新版要重新啟動才會載入。本次技能呼叫已擋下。\n' \
"${info:+($info)}" "$bin"
printf '重新啟動:結束 %s 再重新開啟一次,狀態檔 %s 會在新工作階段開始時自動清除。\n' \
"$bin" "$state"
printf '仍可使用:/jsc-cli:deploy、/jsc-hooks:hooks-install、/jsc-hooks:repair、/jsc-gitea:wiki、/jsc-log:worklog、/jsc-log:learn、/jsc-meta:*、/jsc-ask:ask、/jsc-git:pr、/jsc-git:commit(部署後的異動報告與工作日誌要寫得完,hook 壞掉也要修得回來) | 確定要略過閘門:JSC_RESTART_GATE=off\n'
} | sh "$HERE/deny.sh" "$(cli_name)"
exit $?