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 接線修正。
254 lines
16 KiB
Bash
Executable File
254 lines
16 KiB
Bash
Executable File
#!/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 $?
|