feat(assistant-gate): 助理運行閘門,這一版尚未接線
What: - 新增 hooks/assistant-gate.sh:心跳新鮮就放行,心跳不存在、過期或時間戳壞掉就擋下該次技能呼叫。 - 豁免清單十一支、逃生門一個。README 的 hooks 表與環境變數表跟著補。 - 這一版刻意不接線,接線檔一個字都沒動。 Why: - 助理沒在跑的時候,技能會以為背景有人收尾,實際上沒有。這道閘門把那個落差擋在門外。 - 不接線是因為這台機器的穩定路徑指向開發存放庫,寫進接線檔就立刻對五支 CLI 生效。而現在還沒有心跳,接線的那一秒整組技能會全部鎖死,連修的路徑都走不到。接線的前提是助理已經在跑、心跳穩定。 How: - 這是整組 hook 裡唯一一道 fail-closed 的閘門。其餘的原則都是資料不足就放行,這一道相反。代價是狀態檔寫不進去時全組停擺,所以逃生門與豁免清單不是選配,是能上線的前提。 - 豁免清單只收解鎖路徑,不收收尾規則。這一點與重啟閘門的方向相反:重啟閘門解鎖靠閘門外的動作,收尾規則要寫得完;這一道解鎖靠跑一支技能,清單收寬了閘門就等於沒有。 - 清單認技能名不認呼叫鏈,所以豁免技能轉呼叫的下一層也要收進來。最要緊的是 wiki 那一支:巡檢要先把結果寫上監控頁才寫心跳,只豁免助理自己會做出「沒心跳就擋 wiki、擋了巡檢跑不完、跑不完就還是沒心跳」的自咬環。 - 心跳判定回「檔案系統問不出來」時放行,不擋。那是判不出事實,不在這道閘門的職權裡;而且那一刻正是磁碟或權限壞掉的訊號,擋下去連豁免那幾支也修不動——它們同樣要寫狀態檔。磁碟壞掉要人去清磁碟,不是把技能組鎖起來。時間戳壞掉則照擋,那是確定沒有可信心跳的證據,而且修法就在豁免清單裡。 - 逃生門的判斷擺在載入共用函式庫之前。函式庫讀不到時 sh 會就地結束並回擋人的那個碼,逃生門也會跟著跑不到,人就繞不過去。 - 擋人一律經 deny.sh 輸出。三支走標準錯誤加結束碼,另外兩支靠標準輸出的內容擋,結束碼固定是零;自己印訊息會在那兩支上無聲失效。 - 心跳不存在、過期、時間戳壞掉三種情況講三種話。使用者要做的事不一樣:一個是從沒啟動過,一個是跑過停了,一個是檔案壞了要重建。 Who: 助理落地的最後一塊。接線與準則那一節另外處理。
This commit is contained in:
Executable
+204
@@ -0,0 +1,204 @@
|
||||
#!/usr/bin/env sh
|
||||
# assistant-gate.sh — 助理運行閘門(PreToolUse,matcher: Skill)。
|
||||
#
|
||||
# 助理在背景跑,前景會話看不到它。技能組有一批規則要靠它落地:巡檢、監控頁、待辦簿。助理停著
|
||||
# 的時候那些規則沒有人執行,可是技能照樣叫得起來,看起來一切正常。這道閘門負責讓「助理沒在跑
|
||||
# 就繼續用技能」擋在門外。
|
||||
#
|
||||
# --- 這一版尚未接線 ---
|
||||
#
|
||||
# 本檔沒有寫進 hooks/hooks.json、hooks/codex-hooks.json 與 tools/wire-cli.sh,五支 CLI 一支都不
|
||||
# 會叫到它。要驗證請直接跑 `sh hooks/assistant-gate.sh`,餵環境變數與標準輸入。
|
||||
#
|
||||
# 接線的前提有兩條,兩條都成立才可以接:
|
||||
# 1. 助理已經在跑——`jsc-assist:assistant` 的 start 跑過,排程項目確實裝上了。
|
||||
# 2. 心跳穩定——`heartbeat.sh check` 連續多輪都回 0。排程寫進 crontab 不等於 cron 在跑,
|
||||
# WSL 預設不啟動 cron,那種機器上心跳一拍都不會有。
|
||||
# 這兩條沒確認就接線,下一次技能呼叫就會被擋,而且擋的是整台機器的五支 CLI。
|
||||
#
|
||||
# 結束碼(hook 模式,本檔只有這一個模式):
|
||||
# 0 放行,或已經以不靠結束碼的形態擋下。所以「exit 0」在這支腳本有兩種意思。
|
||||
# 放行的情況:逃生門 JSC_ASSISTANT_GATE=off、負載裡解不出技能名、解出來的不是 jsc 技能、
|
||||
# 命中下方豁免清單、`heartbeat.sh check` 回 0(心跳新鮮)、`heartbeat.sh check` 回 2、5、6
|
||||
# (判不出來,理由見下方「心跳結束碼怎麼處置」)。
|
||||
# 已擋下但不靠結束碼的情況:antigravity 的 stdout deny JSON、kiro 的注入警告。
|
||||
# 2 擋下該次技能呼叫(claude、codex、copilot,以及認不得的 CLI 代號)。
|
||||
# 擋下時的輸出形態由 deny.sh 依當前 CLI 決定,本檔只負責判定與訊息內容:
|
||||
# claude、codex、copilot 走 stderr 加 exit 2;antigravity 走 stdout 的 deny JSON,結束碼
|
||||
# 固定 0(那支 CLI 的結束碼語意沒有文件,不可靠);kiro 擋不下來,改印警告後 exit 0。
|
||||
# 本檔沒有其他結束碼,也沒有子命令。帶進來的參數一律忽略。
|
||||
#
|
||||
# 輸入:技能名一律由 skill-name.sh 從當前 CLI 的負載解析,環境變數 JSC_SKILL、SKILL 優先,
|
||||
# 規則與 version-guard.sh、restart-gate.sh 共用同一份。不另外篩工具名:工具名每支 CLI 都不
|
||||
# 一樣(Skill、Bash、skill、view_file),拿 Claude 的那一個當通用條件會把另外四支整批擋在判定
|
||||
# 之外。
|
||||
#
|
||||
# --- 這是整組技能唯一一道 fail-closed 閘門 ---
|
||||
#
|
||||
# 其餘 hook 的原則都是「資料不足就放行」:version-guard.sh 查不到版本放行,restart-gate.sh 讀不
|
||||
# 到狀態檔放行。這一道相反——心跳不存在就是助理沒在跑,照要求要擋。心跳檔不存在本身就是證據,
|
||||
# 不是「資料不足」。
|
||||
#
|
||||
# 代價講白:$JSC_HOME 寫不進去的時候(磁碟滿、權限壞、掛載掉了)助理寫不出心跳,這道閘門就把
|
||||
# 全機器五支 CLI 的整組技能一起停掉。所以逃生門與豁免清單不是選配,是這道閘門能上線的前提:
|
||||
# 逃生門讓人在閘門判錯時當場繞過去,不必先修好環境才動得了技能。
|
||||
# 豁免清單讓「啟動助理」與「修環境」這兩條路徑永遠走得通,閘門才不會把解除自己的路徑鎖掉。
|
||||
# 這兩樣任何一樣被拿掉或改窄,這道閘門就不可以接線。
|
||||
#
|
||||
# --- 心跳結束碼怎麼處置 ---
|
||||
#
|
||||
# 判定一律交給 `heartbeat.sh check`,本檔不自己讀心跳檔——判定寫兩份就會漂移,狀態跟訊息對不上。
|
||||
# 那支腳本的六個結束碼逐碼處置如下:
|
||||
# 0 新鮮。放行。
|
||||
# 1 過期:心跳檔在、ts 也讀得到,但距現在已達門檻。助理跑過、現在停了。擋,訊息講「跑過但
|
||||
# 停了」,並講出超過門檻幾秒。
|
||||
# 2 腳本沒跑起來(`. lib.sh` 載入失敗時 sh 自己回這一碼)。放行——這是判定機制自己壞了,
|
||||
# 不是「助理沒在跑」的證據。
|
||||
# 3 心跳檔不存在。助理從沒啟動過。擋,訊息講「從沒啟動過」,要人去啟動。與 1 的處置不同:
|
||||
# 使用者要做的事不一樣,併成同一句話會叫錯人去做錯事。
|
||||
# 4 心跳檔在、ts 卻讀不出來(缺鍵、空值或不是數字)。**擋。** heartbeat.sh 檔頭寫明呼叫端
|
||||
# 一律當成不新鮮處置,絕不可以退回當成新鮮。訊息與 1、3 都不同:那是檔案壞了,不是助理
|
||||
# 停了,修法是先 stop 再 start 把心跳檔重建起來。
|
||||
# 5 檔案系統操作失敗。**放行。** 理由見下一段。
|
||||
# 6 用法錯誤(不認得的子命令,或一個都沒給)。放行——本檔固定送 check,收到 6 就代表
|
||||
# heartbeat.sh 換了介面、或這支閘門叫錯了。那是這一邊的缺陷,不是助理的狀態。
|
||||
#
|
||||
# 5 為什麼選放行,不選擋:
|
||||
# 一、`check` 這條路徑根本不產生 5。5 只由 `write` 與 `clear` 產出。從 check 收到 5,意思是
|
||||
# 判定機制本身壞了,跟 2 與 6 同一類,不是「助理沒在跑」。
|
||||
# 二、fail-closed 管的是「助理狀態」這一件事實:確定沒有新鮮心跳才擋。5 的意思是連事實都問
|
||||
# 不出來,那不在這道閘門的職權裡。
|
||||
# 三、最要緊的實務理由:5 正是磁碟滿或權限壞的訊號,而那一刻助理自己也寫不出心跳。擋下去的
|
||||
# 結果是全機器整組技能鎖死,出路只剩豁免清單那幾支——可是那幾支同樣要寫 $JSC_HOME
|
||||
# (接線狀態、用量、工作階段),環境壞著它們也修不動。磁碟壞掉要人去清磁碟,不是把技能
|
||||
# 組鎖起來。
|
||||
# 四、和 4 的差別在有沒有出路:4 是「檔案在、內容壞」,那是確定沒有可信心跳的證據,而且修法
|
||||
# 就在豁免清單裡(stop 再 start),擋得起;5 是「檔案系統問不出來」,擋了沒有出路。
|
||||
# 代價據實寫:磁碟壞掉時這道閘門會安靜放行,助理沒在跑也擋不到。那是刻意的取捨——這道閘門
|
||||
# 不是磁碟監控,環境壞掉由 /jsc-cli:doctor 抓。
|
||||
#
|
||||
# 豁免(這些技能永遠放行,改動前想清楚後果):
|
||||
# jsc-assist:* 啟動助理本身就是一次技能呼叫。少了這一條,助理永遠啟動不了,整組
|
||||
# 技能鎖死。這是雞生蛋,清單裡最要緊的一條
|
||||
# jsc-hooks:repair 修 hook 的唯一路徑。修 hook 的技能被 hook 擋下,就沒有任何方法把
|
||||
# hook 修回來,閘門等於把解除自己的路徑一起鎖掉
|
||||
# jsc-hooks:hooks-install 重新接線的唯一路徑。這道閘門接錯了要靠它拆掉
|
||||
# jsc-cli:doctor 環境健檢。心跳寫不出來多半是環境問題,查不了就修不了
|
||||
# jsc-cli:setup 修設定的唯一路徑,doctor 找到的東西要靠它落地
|
||||
# jsc-cli:deploy 部署技能組。助理主體本身也是技能,裝不上就啟動不了
|
||||
# jsc-gitea:wiki 助理巡檢一輪要先把結果寫進 MONITOR_{HASH},寫不成那一輪就不寫心跳
|
||||
# (no record, no heartbeat)。擋了它,巡檢永遠跑不完、心跳永遠不出現,
|
||||
# 助理再也啟動不了。jsc-hooks:repair 的第一步也是讀 ERROR_{HASH},讀
|
||||
# 不到就中止
|
||||
# jsc-ask:ask 上面幾支都要問使用者:assistant 要問做哪一個操作,setup 與 deploy
|
||||
# 要問模式。擋了它,start 連要不要跑都問不出來
|
||||
# jsc-git:commit jsc-hooks:repair 收尾要提交,擋了修好的東西進不了版本控制
|
||||
# jsc-git:pr 同上,repair 規定收尾要對 develop 開 PR,擋了修復做一半
|
||||
# jsc-cli:models jsc-cli:setup 遇到 model-tags.tsv 不見時要靠它補回來,擋了那一項修不完
|
||||
#
|
||||
# 清單認的是技能名,不是呼叫鏈:豁免技能轉呼叫的下一層若不在清單上,那一層照樣會被擋。後五支
|
||||
# (wiki、ask、commit、pr、models)就是為了這件事補進來的——它們自己不是啟動助理的主體,但前
|
||||
# 六支少了它們就走不完。jsc-gitea:wiki 是這裡面最容易漏的一支:只豁免 jsc-assist:* 看起來就夠
|
||||
# 了,可是巡檢那一輪會轉呼叫 wiki 去寫監控頁,寫不成就不寫心跳,於是「沒心跳 → 擋 wiki →
|
||||
# 巡檢不完 → 還是沒心跳」自己咬住自己,永遠解不開。
|
||||
#
|
||||
# 清單刻意不收 jsc-log:worklog 與 jsc-log:learn:那兩支是部署收尾的規則,跟「把助理啟動起來」
|
||||
# 這條路徑無關。fail-closed 閘門的豁免清單只收解鎖路徑,收寬了這道閘門就等於沒有。
|
||||
#
|
||||
# 逃生門:JSC_ASSISTANT_GATE=off 完全略過這道閘門。
|
||||
#
|
||||
# 註:逃生門的判斷擺在載入 lib.sh 之前,這一點與 restart-gate.sh 不同。lib.sh 讀不到時 sh 會就地
|
||||
# 結束並回 2,接在 PreToolUse 上就是無聲擋下每一次技能呼叫;這道閘門是 fail-closed 的,那個
|
||||
# 結果方向上不算錯,但逃生門也跟著跑不到,人就沒有辦法自己繞過去。所以先看逃生門,再載入。
|
||||
# hooks/skill-name.sh、hooks/deny.sh 與 hooks/heartbeat.sh 都以子行程呼叫,讀不到只會讓判定
|
||||
# 降級成放行,不會反過來擋人。
|
||||
|
||||
# 逃生門先看。結束前把標準輸入讀乾淨:不讀就結束,宿主 CLI 會寫進斷掉的管線。
|
||||
if [ "${JSC_ASSISTANT_GATE:-}" = "off" ]; then
|
||||
[ -t 0 ] || cat >/dev/null 2>&1
|
||||
exit 0
|
||||
fi
|
||||
|
||||
HERE=$(dirname "$0"); . "$HERE/lib.sh"
|
||||
|
||||
read_stdin
|
||||
|
||||
# 技能名解析:交給 skill-name.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-assist:*|jsc-hooks:repair|jsc-hooks:hooks-install|jsc-cli:doctor|jsc-cli:setup|jsc-cli:deploy|jsc-gitea:wiki|jsc-ask:ask|jsc-git:commit|jsc-git:pr|jsc-cli:models)
|
||||
exit 0 ;;
|
||||
esac
|
||||
|
||||
# 心跳判定。補 </dev/null:heartbeat.sh 不讀標準輸入,但這裡的標準輸入已經被 read_stdin 收乾,
|
||||
# 留著空管線給子行程沒有意義,明確關掉才不會有人往回接一條會等的路。
|
||||
sh "$HERE/heartbeat.sh" check </dev/null 2>/dev/null
|
||||
hb=$?
|
||||
case "$hb" in
|
||||
0) exit 0 ;; # 新鮮
|
||||
1|3|4) ;; # 過期、不存在、時間戳壞掉:落到下面組訊息並擋下
|
||||
*) exit 0 ;; # 2、5、6:判不出來就放行,逐碼理由見檔頭
|
||||
esac
|
||||
|
||||
# 訊息細節一律取自 `heartbeat.sh report`,本檔不自己解析心跳檔:判定與訊息共用同一份探測結果,
|
||||
# 兩邊各讀一次會出現「擋的理由」與「印的數字」對不上。
|
||||
REPORT=$(sh "$HERE/heartbeat.sh" report </dev/null 2>/dev/null)
|
||||
|
||||
# report 是一行、欄位以空白分隔。除了 file 以外每一欄都不含空白,換行切開再取最穩。
|
||||
rep_field() { # $1=鍵名(file 除外)
|
||||
printf '%s' "$REPORT" | tr ' ' '\n' | sed -n "s/^$1=//p" | head -n1
|
||||
}
|
||||
# file 擺在最後,路徑可能含空白,所以取「file= 之後的全部」。
|
||||
rep_file() {
|
||||
printf '%s' "$REPORT" | sed -n 's/.*[[:space:]]file=//p'
|
||||
}
|
||||
|
||||
hb_age=$(rep_field age)
|
||||
hb_ttl=$(rep_field ttl)
|
||||
hb_pid=$(rep_field pid)
|
||||
hb_file=$(rep_file)
|
||||
[ -n "$hb_file" ] || hb_file="$JSC_HOME/assistant/heartbeat"
|
||||
|
||||
# 超過門檻幾秒。兩個值都是純數字才算,算不出來就不印那一段——寧可少講一個數字,也不要印出
|
||||
# 算壞的值。
|
||||
hb_over=""
|
||||
case "$hb_age$hb_ttl" in
|
||||
''|*[!0-9]*) ;;
|
||||
*) hb_over=$(( hb_age - hb_ttl )) ;;
|
||||
esac
|
||||
|
||||
# 三種狀態的第一句話各寫一份。使用者要做的事不一樣:沒啟動過的要去啟動,跑過停了的要去查為
|
||||
# 什麼停,檔案壞了的要去重建。併成同一句就會叫錯人做錯事。
|
||||
case "$hb" in
|
||||
3) first=$(printf '[jsc][助理閘門][ERR]:助理沒有在跑。心跳檔 %s 不存在,助理從沒啟動過。技能 /%s 這一次呼叫已擋下。' \
|
||||
"$hb_file" "$skill") ;;
|
||||
1) first=$(printf '[jsc][助理閘門][ERR]:助理跑過,現在停了。上次心跳是 %s 秒前,門檻 %s 秒,已經超過門檻 %s 秒(寫入者 pid=%s,心跳檔 %s)。技能 /%s 這一次呼叫已擋下。' \
|
||||
"${hb_age:-不明}" "${hb_ttl:-不明}" "${hb_over:-不明}" "${hb_pid:-不明}" "$hb_file" "$skill") ;;
|
||||
*) first=$(printf '[jsc][助理閘門][ERR]:助理狀態判不出來。心跳檔 %s 在,但 ts 欄位缺了、是空的、或不是數字——檔案壞了,不是助理停了。一律當成沒有心跳處置。技能 /%s 這一次呼叫已擋下。' \
|
||||
"$hb_file" "$skill") ;;
|
||||
esac
|
||||
|
||||
# 第二句:怎麼把助理弄回來。三種狀態的做法也不同。
|
||||
case "$hb" in
|
||||
3) second='啟動助理:/jsc-assist:assistant,操作選 start。它會先跑一輪巡檢,把結果寫上監控頁,再把排程項目裝起來;心跳是那一輪跑完才寫的。' ;;
|
||||
1) second='重新啟動助理:/jsc-assist:assistant,操作選 start;先用 status 看排程項目還在不在。排程寫進 crontab 不等於 cron 在跑,WSL 預設不啟動 cron,那種機器要先 sudo service cron start,而且每次重開機都要再跑一次。' ;;
|
||||
*) second='重建心跳:/jsc-assist:assistant,操作先選 stop 再選 start。stop 會把壞掉的心跳檔刪掉,start 跑完一輪巡檢才寫出新的一份。' ;;
|
||||
esac
|
||||
|
||||
# 擋人輸出交給 deny.sh:形態依 CLI 而定,本檔只組訊息。四段訊息整段走同一條管線送過去,
|
||||
# antigravity 那一支才有辦法把它們壓成同一個 reason 字串;分次呼叫會做出好幾份 deny JSON,
|
||||
# 那支 CLI 只認第一份,後面三段使用者永遠看不到。
|
||||
{ printf '%s\n' "$first"
|
||||
printf '%s\n' "$second"
|
||||
printf '仍可使用:/jsc-assist:*、/jsc-hooks:repair、/jsc-hooks:hooks-install、/jsc-cli:doctor、/jsc-cli:setup、/jsc-cli:deploy、/jsc-cli:models、/jsc-gitea:wiki、/jsc-ask:ask、/jsc-git:commit、/jsc-git:pr(啟動助理與修環境這兩條路徑要永遠走得通,包括它們轉呼叫的下一層)\n'
|
||||
printf '確定要略過閘門:JSC_ASSISTANT_GATE=off\n'
|
||||
} | sh "$HERE/deny.sh" "$(cli_name)"
|
||||
exit $?
|
||||
Reference in New Issue
Block a user