From 5c2727bf56c5a5341e050fde4b078c502c35bfb0 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Mon, 31 Aug 2026 13:41:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(version-guard):=20=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E5=89=8D=E7=BD=AE=E6=AA=A2=E6=9F=A5=E6=96=B0=E5=A2=9E=E7=9B=B8?= =?UTF-8?q?=E4=BE=9D=E7=89=88=E6=9C=AC=E8=90=BD=E5=BE=8C=E7=9A=84=E6=93=8B?= =?UTF-8?q?=E4=BA=BA=E6=83=85=E6=B3=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 版本前置檢查多一種擋人情況。技能所屬 plugin 的 manifest 在 jsc.requires 宣告的相依 plugin,只要有一項的本機實際載入版本落後宣告的最低版本, 就以 exit 2 擋下這一次技能呼叫。訊息逐項講明哪一個 plugin、需要哪一版、 目前哪一版、怎麼補。 相依宣告原本只有部署那一端會回報,沒有任何一道閘門擋。機器上因此會出現 版本互相搭不起來的 plugin 組合,技能跑到一半才失敗,使用者也看不出要更新 哪一個 plugin。 判定邏輯自己實作,不呼叫 jsc-cli 的 check-requires.sh。hook 一律專屬存放在 jsc-hooks,不可散落到別的 domain;jsc-cli 也已經宣告相依 jsc-hooks, 反向呼叫會做出循環相依。安裝路徑解析抽成 install_path(),與本機載入版本 共用同一條規則,各寫一份就會漂移。相依檢查排在豁免清單與逃生門之後、 遠端比對之前,全部讀本機檔案,離線的機器也判得動。豁免沿用同一份 7 項清單, 相依落後不另立短清單:這幾支同樣是更新與修復的唯一路徑,用哪一個理由擋 都是死鎖。四種情況一律安靜放行:解不出安裝路徑、讀不到 manifest、 manifest 沒有 jsc.requires、讀不到相依 plugin 的本機載入版本。五支 CLI 只有 claude 讀得到本機載入版本,fail-closed 會把另外四支整批鎖死。 所屬功能:版本前置檢查的相依版本閘門。 Co-Authored-By: Claude Opus 5 (1M context) --- hooks/version-guard.sh | 104 ++++++++++++++++++++++++++++++++++------- 1 file changed, 87 insertions(+), 17 deletions(-) diff --git a/hooks/version-guard.sh b/hooks/version-guard.sh index 9530ce5..17105e1 100755 --- a/hooks/version-guard.sh +++ b/hooks/version-guard.sh @@ -1,14 +1,19 @@ #!/usr/bin/env sh # version-guard.sh — 技能使用前的版本前置檢查(PreToolUse,matcher: Skill)。 # -# 本機版本落後遠端發佈版本時擋下該次技能呼叫,並提示更新指令。 +# 這道閘門擋兩種情況,兩種都會 exit 2: +# 一、本機版本落後遠端發佈版本。 +# 二、技能所屬 plugin 宣告的相依 plugin 版本落後(manifest 的 jsc.requires)。 +# 兩種都會提示更新指令。 # # 結束碼(hook 模式):0=放行 2=擋下該次技能呼叫,訊息走 stderr。 # 安靜放行(exit 0)的情況要記清楚,這道閘門絕大多數時候走的是這幾條:逃生門 # JSC_VERSION_GUARD=off、工具名取得到但不是 Skill、取不到技能名、技能名不是 -# jsc-{domain}:{name}、拆不出 domain、命中下方豁免清單那 7 支、讀不到本機實際載入版本、 -# 推導不出遠端站台、查不到遠端版本、本機版本等於或超前遠端。 -# 只有「本機落後遠端」這一條會 exit 2。 +# jsc-{domain}:{name}、拆不出 domain、命中下方豁免清單那 7 支、解不出安裝路徑、 +# 讀不到 manifest、manifest 沒有 jsc.requires、讀不到相依 plugin 的本機載入版本、 +# 讀不到自己的本機實際載入版本、推導不出遠端站台、查不到遠端版本、 +# 本機版本等於或超前遠端。 +# 只有「相依確定落後」與「本機落後遠端」這兩條會 exit 2。 # 結束碼(report、recommend):0=永遠成功,只讀不擋。結論看 stdout,不看結束碼。 # 註:本檔以 `. "$HERE/lib.sh"` 載入共用函式,沒有接 `|| true`。lib.sh 讀不到時 sh 會就地 # 結束並回 2,接在 PreToolUse 上就是無聲擋下每一次技能呼叫,上面那些放行路徑一條都跑不到 @@ -26,12 +31,28 @@ # 會放過真正被載入的舊版。讀不到那份檔案就當查不到,安靜放行。 # - 只擋落後。本機版本等於或超前遠端一律放行:開發技能組時本機本來就會 # 超前預設分支,擋下去會讓維護者自己動不了。 -# - **只有「本機落後遠端」會擋**。查不到資料一律放行(exit 0):本機版本、 -# Gitea 站台、遠端版本全部來自 Claude 的 plugin 檔案與 Gitea API,沒裝 -# Claude 或離線的機器一筆都讀不到。那種情況擋下去,等於在沒有任何版本 -# 證據時停掉每一次技能呼叫,護欄變成故障點。 +# - 查不到資料一律放行(exit 0):本機版本、Gitea 站台、遠端版本全部來自 +# Claude 的 plugin 檔案與 Gitea API,沒裝 Claude 或離線的機器一筆都讀不到。 +# 那種情況擋下去,等於在沒有任何版本證據時停掉每一次技能呼叫,護欄變成故障點。 # -# 豁免(這些技能永遠放行): +# 相依版本檢查(第二種擋人情況): +# - 取值來源是技能所屬 plugin 的 manifest,也就是 installPath 底下那份 plugin.json, +# 讀它的 jsc.requires,一項是一個「相依 plugin: 最低版本」。 +# - 相依 plugin 的現況一律取「本機實際載入版本」,規則與上面同一條:只認 +# installed_plugins.json 的 installPath 底下那份 plugin.json,不拿註冊欄位當備援。 +# - 只要有一項落後宣告的最低版本就 exit 2,訊息逐項講明哪一個 plugin、需要哪一版、 +# 目前哪一版、怎麼補。 +# - 判定同樣 fail-open:解不出安裝路徑、讀不到 manifest、manifest 沒有 jsc.requires、 +# 讀不到某一項相依的本機載入版本,四種都安靜放行。五支 CLI 只有 claude 讀得到 +# 本機載入版本,fail-closed 會把另外四支整批鎖死。 +# - 這段邏輯自己實作,不去呼叫 jsc-cli 的 check-requires.sh。兩個理由:技能準則要求 +# 所有 hook 專屬存放於 jsc-hooks,不可散落到別的 domain;而且 jsc-cli 已經宣告相依 +# jsc-hooks,反向呼叫會做出循環相依。部署那端照樣更新、只回報,阻擋落在這支 hook。 +# - 檢查順序刻意排在遠端比對之前:相依檢查全部讀本機檔案,不必連網,離線機器也判得動。 +# +# 豁免(這些技能永遠放行,兩種擋人情況一起豁免): +# 共 7 項,jsc-meta:* 算一項。相依版本落後不另立一份短清單:這幾支同樣是修復與更新的 +# 唯一路徑,用哪一個理由擋都是死鎖。這張表的唯一真實來源就是這段檔頭與下方豁免清單。 # jsc-cli:deploy 更新整組技能的入口,擋了就沒有任何方法更新,會死鎖 # jsc-hooks:hooks-install 更新後要重新接線,擋了會讓更新做一半卡住 # jsc-cli:models SDLC 閘門依賴它產生 model-tags.tsv @@ -83,19 +104,42 @@ file_json_str() { # $1=檔案 $2=欄位名 | sed -n "s/.*\"$2\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" | head -n1 } +# 該 plugin 的安裝路徑:從註冊檔取 installPath。版本與 manifest 都從這個目錄取, +# 抽成一支函式是為了讓兩邊共用同一條解析規則,各寫一份就會漂移。 +# 取不到就回傳空字串,由呼叫端安靜放行。 +install_path() { # $1=domain + [ -f "$REG" ] && [ -r "$REG" ] || return 0 + _seg=$(tr -d '\n' < "$REG" \ + | sed -n "s/.*\"jsc-$1@jsc\"[[:space:]]*:[[:space:]]*\[\([^]]*\)\].*/\1/p") + [ -n "$_seg" ] || return 0 + printf '%s' "$_seg" | tr ',' '\n' \ + | sed -n 's/.*"installPath"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1 +} + # 本機實際載入版本:先取該 plugin 的 installPath,再讀那個目錄下的 plugin.json。 # 只認 installPath 底下那份檔案。註冊在 installed_plugins.json 的 version 欄位不當備援: # 註冊值可能比實際載入的版本新,拿它來比對會放過真正被載入的舊版,護欄形同虛設。 # 讀不到那份檔案就當「查不到本機載入版本」,由呼叫端安靜放行。 local_version() { # $1=domain - [ -f "$REG" ] && [ -r "$REG" ] || return 0 - _seg=$(tr -d '\n' < "$REG" \ - | sed -n "s/.*\"jsc-$1@jsc\"[[:space:]]*:[[:space:]]*\[\([^]]*\)\].*/\1/p") - [ -n "$_seg" ] || return 0 - _path=$(printf '%s' "$_seg" | tr ',' '\n' \ - | sed -n 's/.*"installPath"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1) - [ -n "$_path" ] || return 0 - file_json_str "$_path/plugin.json" version + _lp=$(install_path "$1") + [ -n "$_lp" ] || return 0 + file_json_str "$_lp/plugin.json" version +} + +# manifest 宣告的相依版本:印出每行一項「{相依 plugin} {版本條件}」,例如「jsc-cli >=0.2.1」。 +# 解析方式沿用本檔的 naive JSON 取值:先取 "requires" 後面那一對大括號裡的內容, +# 再以逗號斷行,這樣每行最多一組鍵值。jsc.requires 底下只有一層字串對字串,夠用。 +# 只認 jsc- 開頭的鍵:這道閘門管的是本技能組自己的 plugin,別的來源查不到本機載入版本, +# 收進來也只會走到 fail-open 那條路。沒有宣告、讀不到檔案都印空字串,由呼叫端安靜放行。 +requires_pairs() { # $1=manifest 路徑 + [ -f "$1" ] && [ -r "$1" ] || return 0 + # 補一個換行再往下送:tr -d '\n' 之後整份 JSON 只剩一行,而且結尾沒有換行, + # 這種缺行尾的串流走到最後一筆時,read 會把值讀進去卻回非零,while 迴圈的本體 + # 一次都跑不到,落後的相依就靜靜被漏掉。補在源頭,後面每一段都拿得到完整的行。 + { tr -d '\n' < "$1"; printf '\n'; } \ + | sed -n 's/.*"requires"[[:space:]]*:[[:space:]]*{\([^}]*\)}.*/\1/p' \ + | tr ',' '\n' \ + | sed -n 's/.*"\(jsc-[A-Za-z0-9_-]*\)"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1 \2/p' } # 遠端站台與 owner:從已註冊的 jsc marketplace 來源推導,其次 GITEA_HOST。 @@ -311,6 +355,32 @@ deny() { # $1=訊息 exit 2 } +# ── 相依版本檢查:讀技能所屬 plugin 的 manifest,逐項比對相依 plugin 的本機載入版本。 +# 排在遠端比對之前,因為這一段全部讀本機檔案,離線機器也判得動。 +# 每一層取不到值都直接跳過,理由見檔頭「相依版本檢查」那一段的 fail-open 說明。 +plugin_dir=$(install_path "$domain") +if [ -n "$plugin_dir" ] && [ -f "$plugin_dir/plugin.json" ]; then + # 迴圈放在命令替換裡收結果。POSIX sh 的管線各跑在自己的子行程, + # 在迴圈裡累加變數帶不回來,只有印出來的內容帶得回來。 + behind_list=$(requires_pairs "$plugin_dir/plugin.json" | while IFS=' ' read -r dep cond; do + [ -n "$dep" ] && [ -n "$cond" ] || continue + # 只認 >= 這一種寫法,與 manifest 現行宣告一致;其餘寫法就把整串當最低版本。 + min=${cond#>=} + dep_domain=${dep#jsc-} + cur=$(local_version "$dep_domain") + # 讀不到相依的本機載入版本就跳過這一項:那是沒有證據,不是落後。 + [ -n "$cur" ] || continue + [ "$(ver_cmp "$cur" "$min")" = "-1" ] || continue + printf ' - %s 需要 %s,目前 %s,更新指令:%s\n' "$dep" "$cond" "$cur" "$(update_cmd "$dep_domain")" + done) + if [ -n "$behind_list" ]; then + printf '[jsc][版本檢查][ERR]:%s 宣告的相依 plugin 版本落後,本次技能呼叫已擋下\n' "$skill" >&2 + printf '%s\n' "$behind_list" >&2 + printf '更新整組:/jsc-cli:deploy | 確定要略過檢查:JSC_VERSION_GUARD=off\n' >&2 + exit 2 + fi +fi + # 讀不到本機實際載入版本就放行:沒有版本證據時擋下等於停掉每一次技能呼叫 local_ver=$(local_version "$domain") [ -n "$local_ver" ] || exit 0