新增第九支 hook,共四種模式。stage 在計畫與分析階段鎖著時擋下寫檔。 review 在稽核類技能執行中擋下寫檔。commit 擋下「一次加入全部變更再提交」的 單一指令,也擋下含簡體字、亂碼或非 UTF-8 編碼的提交訊息。 release 不接 hook,由稽核技能收尾時自己呼叫,清掉認人用的那份紀錄, 讓呼叫端接手修改時不會被剛跑完的稽核擋住。 這三條規則原本只寫在技能內文,靠模型自律。稽核技能會順手改程式碼, 計畫階段會寫出不該寫的檔案,整包提交會把型別與功能分組壓成一次。 規則要真的生效,就得由程式擋。release 模式是必要的:紀錄記的是最近一次 載入的技能,不是還在跑的技能,沒有它,稽核跑完之後呼叫端每一次寫入都被擋, 閘門會把解除自己的路徑一起鎖掉。 接線設定把 stage 與 review 接在寫檔工具,把 commit 接在指令工具。 閘門只讀階段閘門的狀態鎖與技能用量的紀錄,不自己寫狀態檔; 簡繁與編碼判定整段轉呼叫語言守門,不留第二份字表。 接線腳本的接線、唯讀盤點與冒煙各補上這一支,冒煙自備暫時的狀態目錄, 逐條比對每種模式的結束碼。接線腳本另補唯讀模式,體檢類技能全程帶著它跑, 清除與接線一律拒絕並回非零,子命令打錯一個字也改不到環境。 冒煙改成自己數結果行並自我斷言,散文只引用那一行,不再各抄一份數字。 限制據實寫在檔頭:只有 claude 有寫檔前置鉤子,其餘四支 CLI 一條都接不上, 那四支上這三條規則仍只剩技能內文。
281 lines
13 KiB
Bash
Executable File
281 lines
13 KiB
Bash
Executable File
#!/usr/bin/env sh
|
||
# write-guard.sh — 寫入與提交的前置閘門(PreToolUse)。三種擋人模式接在兩個 matcher 上,
|
||
# 另有一個給技能收尾呼叫的解除模式。
|
||
#
|
||
# 用法:
|
||
# write-guard.sh stage matcher Write|Edit|MultiEdit:plan 與 analyze 階段鎖存在時擋下寫檔
|
||
# write-guard.sh review matcher Write|Edit|MultiEdit:稽核類技能執行中擋下寫檔
|
||
# write-guard.sh commit matcher Bash:擋下 git add -A 後的單次提交,以及含簡體字或亂碼的提交訊息
|
||
# write-guard.sh release 不接 hook,由稽核技能收尾時自己呼叫:清掉 review 模式認人用的那份
|
||
# 紀錄,一律 exit 0,紀錄本來就不存在也算成功
|
||
#
|
||
# 輸入相容(比照其他 hook,stdin JSON 與環境變數都收,缺欄位一律安靜降級 exit 0):
|
||
# 工具名 JSC_TOOL_NAME、TOOL_NAME、stdin 的 tool_name
|
||
# 技能名 JSC_SKILL、SKILL、stdin 的 skill
|
||
# 指令 JSC_TOOL_COMMAND、stdin 的 command
|
||
#
|
||
# 結束碼:0=放行、解除完成、資料不足或不認得的模式;2=擋下,訊息走 stderr。
|
||
# 逃生門:JSC_WRITE_GUARD=off,三種擋人模式全部略過(release 不受影響,清紀錄擋不到任何人)。
|
||
# review 模式另有 JSC_WRITE_GUARD_TTL(預設 900 秒),見下方「稽核技能的時效」。
|
||
#
|
||
# 覆蓋範圍要據實看待:只有 claude 有 PreToolUse,這道閘門只在 claude 上擋得下來。
|
||
# codex、copilot、antigravity、kiro 都沒有 pre-tool 事件,三種模式在那四支上一次都擋不到,
|
||
# 規則只剩 SKILL.md 的散文,回報時不得暗示每支 CLI 都擋得住。
|
||
#
|
||
# --- stage 模式 ---
|
||
#
|
||
# 階段鎖狀態檔沿用 sdlc-gate.sh 那一份($JSC_HOME/sessions/{sid}.stage,單行
|
||
# 「{階段}<TAB>{必要標籤}<TAB>{上鎖時的模型}」),這裡只讀不寫:判準與格式留在 sdlc-gate.sh,
|
||
# 兩邊各存一份就會漂移。plan 與 analyze 是純邏輯階段,產出是 wiki 頁不是程式碼,所以那兩個
|
||
# 階段鎖著時 Write、Edit、MultiEdit 一律擋下;implement 與 maintain 本來就要寫檔,放行。
|
||
#
|
||
# --- review 模式 ---
|
||
#
|
||
# 目前技能取自 skill-usage.sh 已經記下的那一份($JSC_HOME/sessions/{sid}.lastskill),
|
||
# 環境變數餵得到技能名時優先用環境變數。code-review 與 api-doc 只回報發現、不改程式碼,
|
||
# 執行中出現寫入就是越權,擋下。
|
||
#
|
||
# comment-cleanup 不擋:它本來就要改檔,只是限定「僅註解行」。要精確判定得先解析工具參數裡
|
||
# 帶跳脫字元的整份新內容,再逐語言判斷哪幾行是註解——判錯就會擋掉合法的清理,代價比漏擋大。
|
||
# 這一支因此只放行,寫入範圍由 SKILL.md 的散文與後續審查把關,不在這裡硬做。
|
||
#
|
||
# 稽核技能的時效:沒有「技能結束」事件可讀,只有「最近一次呼叫的技能」這個事實。不設界線的話,
|
||
# 稽核技能跑完之後每一次寫檔都會被擋到下一支技能被呼叫為止。所以紀錄超過 JSC_WRITE_GUARD_TTL
|
||
# 秒就當那支技能早已跑完,放行;取不到紀錄時間也放行。
|
||
#
|
||
# --- release 模式 ---
|
||
#
|
||
# 介面:write-guard.sh release,不吃其他參數、不接任何 hook 事件,由呼叫端自己執行。
|
||
# 呼叫端是 jsc-review:code-review 與 jsc-review:api-doc,兩支在收尾(把發現清單交回呼叫端)
|
||
# 那一步各呼叫一次。做的事只有一件:刪掉 $JSC_HOME/sessions/{sid}.lastskill。
|
||
#
|
||
# 為什麼要有這個模式:review 模式靠那份紀錄認人,而 skill-usage.sh 記的是「最近一次載入的
|
||
# 技能」,不是「還在跑的技能」。code-review 的契約是只回報、修不修由呼叫端決定,稽核結束後
|
||
# 呼叫端本來就要動手改——那一刻紀錄仍寫著 code-review,JSC_WRITE_GUARD_TTL 內每一次寫入
|
||
# 都被擋,解除路徑只剩逃生門或空等。閘門不得把解除自己的路徑一起鎖掉,所以補一個由呼叫端
|
||
# 自己按的解除鍵。
|
||
# 只刪那一份紀錄,不碰階段鎖:階段鎖歸 sdlc-gate.sh unlock 管,兩件事混在一起會互相解除。
|
||
#
|
||
# --- commit 模式 ---
|
||
#
|
||
# 擋兩件事:
|
||
# 1. 同一道指令裡同時有「git add -A(或 --all、.)」與「git commit」。那等於把所有待提交
|
||
# 變更併成一次提交,型別與功能分組就消失了。跨兩次工具呼叫的同一組動作不擋——那要記
|
||
# 跨呼叫狀態,而被擋下的人沒有辦法讓那個狀態自己消失,閘門會把解除自己的路徑一起鎖掉。
|
||
# 2. 提交訊息含簡體字、亂碼或非 UTF-8 編碼。判定整段轉呼叫 lang-guard.sh(字表在
|
||
# hooks/simplified.txt),這裡不抄第二份樣式;連帶地 JSC_LANG_GUARD=off 也會關掉這一項,
|
||
# 因為那本來就是同一條規則。
|
||
set -u
|
||
|
||
HERE=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||
# lib.sh 讀不到就直接放行。這裡不能寫成「. lib.sh || true」:dash 的 `.` 找不到檔案時會結束
|
||
# 整支 shell,後面的 || true 一次都跑不到,2>/dev/null 還把原因蓋掉,三種模式全部變成無訊息
|
||
# 的 exit 2——而 PreToolUse 的 exit 2 正是「擋下」,等於每一次寫檔與提交都被無聲擋死。
|
||
[ -r "$HERE/lib.sh" ] || exit 0
|
||
. "$HERE/lib.sh"
|
||
# lib.sh 沒載到時這個變數就沒人設,下面兩個模式都要用它組狀態檔路徑,補一份同樣的預設值。
|
||
JSC_HOME="${JSC_HOME:-$HOME/.jsc}"
|
||
|
||
mode="${1:-}"
|
||
case "$mode" in
|
||
stage|review|commit|release) ;;
|
||
*) exit 0 ;; # 不認得的模式一律安靜放行,不中斷宿主 CLI
|
||
esac
|
||
|
||
read_stdin 2>/dev/null || STDIN_JSON=""
|
||
|
||
# ── release:稽核技能收尾時清掉「目前技能」紀錄,解除 review 模式的擋下
|
||
#
|
||
# 排在逃生門之前,也不受 JSC_WRITE_GUARD=off 影響:清一筆紀錄從來不會擋到任何人,
|
||
# 而收尾呼叫失敗才是真的麻煩——閘門關著的機器上跑過一輪,紀錄留著,下次開啟就自鎖。
|
||
if [ "$mode" = release ]; then
|
||
rm -f "$JSC_HOME/sessions/$(session_id).lastskill" 2>/dev/null || true
|
||
exit 0
|
||
fi
|
||
|
||
[ "${JSC_WRITE_GUARD:-on}" = "off" ] && exit 0
|
||
|
||
deny() { # $1=擋下的理由 $2=修法
|
||
printf '[jsc][寫入閘門][ERR]:%s\n' "$1" >&2
|
||
printf '%s\n' "$2" >&2
|
||
printf '確定要略過這道閘門:JSC_WRITE_GUARD=off\n' >&2
|
||
exit 2
|
||
}
|
||
|
||
# 檔案的最後修改時間(epoch 秒)。GNU 與 BSD 的取法不同,三種都試過才放棄;
|
||
# 取不到就不輸出,呼叫端當成無法判定並放行。
|
||
file_mtime() { # $1=檔案
|
||
_m=$(date -r "$1" +%s 2>/dev/null)
|
||
[ -n "$_m" ] || _m=$(stat -c %Y "$1" 2>/dev/null)
|
||
[ -n "$_m" ] || _m=$(stat -f %m "$1" 2>/dev/null)
|
||
printf '%s' "$_m"
|
||
}
|
||
|
||
# 只在寫檔類工具上判定。工具名取不到就當成沒有篩選條件,交給後面的狀態判定——
|
||
# 接線的 matcher 已經先篩過一輪,這裡再擋一次只會把用環境變數餵資料的 CLI 全部放掉。
|
||
write_tool_or_exit() {
|
||
_t="${JSC_TOOL_NAME:-${TOOL_NAME:-$(json_str tool_name)}}"
|
||
case "$_t" in
|
||
""|Write|Edit|MultiEdit) return 0 ;;
|
||
*) exit 0 ;;
|
||
esac
|
||
}
|
||
|
||
# ── stage:plan 與 analyze 階段鎖存在時擋下寫檔
|
||
if [ "$mode" = stage ]; then
|
||
write_tool_or_exit
|
||
state="$JSC_HOME/sessions/$(session_id).stage"
|
||
[ -f "$state" ] && [ -r "$state" ] || exit 0
|
||
stage=$(cut -f1 "$state" 2>/dev/null | head -n1)
|
||
case "$stage" in
|
||
plan|analyze) ;;
|
||
*) exit 0 ;; # implement 與 maintain 本來就要寫檔;讀不出階段也放行
|
||
esac
|
||
deny "目前鎖在 SDLC「$stage」階段,這個階段只產出計畫或分析頁,不寫檔案。本次寫入已擋下。" \
|
||
"改法:把結論寫進該階段的 wiki 頁;真的要動程式碼請先進入 implement 階段。
|
||
解除階段鎖:jsc-hooks/hooks/sdlc-gate.sh unlock"
|
||
fi
|
||
|
||
# ── review:稽核類技能執行中擋下寫檔
|
||
if [ "$mode" = review ]; then
|
||
write_tool_or_exit
|
||
skill="${JSC_SKILL:-${SKILL:-$(json_str skill)}}"
|
||
if [ -z "$skill" ]; then
|
||
last="$JSC_HOME/sessions/$(session_id).lastskill"
|
||
if [ -f "$last" ] && [ -r "$last" ]; then
|
||
mt=$(file_mtime "$last")
|
||
now=$(now_epoch)
|
||
if [ -n "$mt" ] && [ -n "$now" ]; then
|
||
age=$((now - mt))
|
||
[ "$age" -lt "${JSC_WRITE_GUARD_TTL:-900}" ] && skill=$(cat "$last" 2>/dev/null)
|
||
fi
|
||
fi
|
||
fi
|
||
[ -n "$skill" ] || exit 0
|
||
case "$skill" in
|
||
jsc-review:code-review|code-review)
|
||
deny "jsc-review:code-review 執行中。這支技能只回報發現,修不修由呼叫端決定,執行中不寫檔。本次寫入已擋下。" \
|
||
"改法:先讓稽核跑完並收下 file:line、嚴重度與重構手法,再由呼叫端決定要不要改。" ;;
|
||
jsc-review:api-doc|api-doc)
|
||
deny "jsc-review:api-doc 執行中。這支技能只稽核 Swagger 文件屬性,從不修改程式碼。本次寫入已擋下。" \
|
||
"改法:先讓稽核跑完並收下缺漏清單,再由呼叫端決定要不要補。" ;;
|
||
esac
|
||
exit 0
|
||
fi
|
||
|
||
# ── commit:擋下 git add -A 後的單次提交,與含簡體字或亂碼的提交訊息
|
||
|
||
# 從 stdin JSON 取帶跳脫字元的字串欄位。lib.sh 的 json_str 以 [^"]* 比對,遇到訊息裡的 \"
|
||
# 就在那裡截斷,提交訊息會少掉後半段——而訊息內容正是這裡要檢查的東西,所以自己解一次跳脫。
|
||
json_escaped_str() { # $1=欄位名
|
||
printf '%s' "$STDIN_JSON" | awk -v key="$1" '
|
||
{ s = s $0 "\n" }
|
||
END {
|
||
n = length(s); i = 1; found = 0
|
||
while (i <= n) {
|
||
if (substr(s, i, 1) != "\"") { i++; continue }
|
||
buf = ""; i++
|
||
while (i <= n) {
|
||
c = substr(s, i, 1)
|
||
if (c == "\\") {
|
||
e = substr(s, i + 1, 1)
|
||
if (e == "n") buf = buf "\n"
|
||
else if (e == "t") buf = buf "\t"
|
||
else if (e == "r") buf = buf "\r"
|
||
else if (e == "u") { i += 6; continue }
|
||
else buf = buf e
|
||
i += 2; continue
|
||
}
|
||
if (c == "\"") { i++; break }
|
||
buf = buf c; i++
|
||
}
|
||
if (found) { printf "%s", buf; exit }
|
||
j = i
|
||
while (j <= n && substr(s, j, 1) ~ /[ \t\r\n]/) j++
|
||
if (buf == key && substr(s, j, 1) == ":") {
|
||
k = j + 1
|
||
while (k <= n && substr(s, k, 1) ~ /[ \t\r\n]/) k++
|
||
if (substr(s, k, 1) != "\"") { i = k; continue }
|
||
found = 1; i = k
|
||
}
|
||
}
|
||
}'
|
||
}
|
||
|
||
cmd="${JSC_TOOL_COMMAND:-$(json_escaped_str command)}"
|
||
[ -n "$cmd" ] || exit 0
|
||
case "$cmd" in
|
||
*git*) ;;
|
||
*) exit 0 ;; # 不是 git 指令就不關這道閘門的事
|
||
esac
|
||
|
||
# 把所有待提交變更一次加進索引的三種寫法。-A 也認 -vA 這類併寫的短旗標。
|
||
has_add_all() { # $1=指令
|
||
printf '%s' "$1" \
|
||
| grep -qE 'git[[:space:]]+add[[:space:]]+(-[A-Za-z]*A([[:space:]]|$)|--all([[:space:]]|$)|\.([[:space:]]|$))'
|
||
}
|
||
|
||
has_commit() { # $1=指令
|
||
printf '%s' "$1" | grep -qE 'git[[:space:]]+commit([[:space:]]|$)'
|
||
}
|
||
|
||
if has_add_all "$cmd" && has_commit "$cmd"; then
|
||
deny "這道指令把全部變更一次加進索引再提交,型別與功能分組會全部消失。本次執行已擋下。" \
|
||
"改法:依 conventional type 與功能分組,逐組 git add {檔案} 再各自 git commit。
|
||
分組與訊息格式交給 /jsc-git:commit 處理。"
|
||
fi
|
||
|
||
# 提交訊息:取 -m 後面那一段。帶引號就讀到成對的引號為止,沒帶引號就讀到下一個空白。
|
||
_sq=$(printf '\047')
|
||
commit_message() { # $1=指令
|
||
printf '%s' "$1" | awk -v sq="$_sq" '
|
||
{
|
||
s = $0; n = length(s)
|
||
i = index(s, "-m")
|
||
if (i == 0) exit
|
||
i += 2
|
||
while (i <= n && substr(s, i, 1) ~ /[ \t=]/) i++
|
||
q = substr(s, i, 1)
|
||
if (q == "\"" || q == sq) {
|
||
i++
|
||
while (i <= n) {
|
||
c = substr(s, i, 1)
|
||
if (c == "\\") { out = out substr(s, i + 1, 1); i += 2; continue }
|
||
if (c == q) break
|
||
out = out c; i++
|
||
}
|
||
} else {
|
||
while (i <= n && substr(s, i, 1) !~ /[ \t]/) { out = out substr(s, i, 1); i++ }
|
||
}
|
||
printf "%s", out
|
||
}'
|
||
}
|
||
|
||
has_commit "$cmd" || exit 0
|
||
msg=$(commit_message "$cmd")
|
||
[ -n "$msg" ] || exit 0
|
||
|
||
# 簡體字、亂碼與編碼判定整段轉呼叫 lang-guard.sh,樣式與字表都不在這裡留第二份。
|
||
# 那支腳本吃的是檔案,所以訊息先落成暫存檔;建不出暫存檔就放行,回報不該再變成一次失敗。
|
||
lang_hits() { # $1=文字;有問題就把證據印到 stdout 並回傳 1
|
||
[ -f "$HERE/lang-guard.sh" ] || return 0
|
||
_t=$(mktemp 2>/dev/null) || return 0
|
||
printf '%s\n' "$1" > "$_t" 2>/dev/null || { rm -f "$_t"; return 0; }
|
||
_o=$(JSC_CHANGED_FILE="$_t" sh "$HERE/lang-guard.sh" </dev/null 2>&1)
|
||
_rc=$?
|
||
rm -f "$_t"
|
||
# 只有 exit 2 是「確定命中」。exit 0 是乾淨或資料不足,其餘結束碼代表那支腳本自己出狀況,
|
||
# 兩種都放行:拿判不出來的結果擋提交,等於把護欄變成故障點。
|
||
[ "$_rc" -eq 2 ] || return 0
|
||
# 只留命中證據:開頭那句與 lang-guard.sh 自己的修法兩行由本檔的訊息取代,重複印只是噪音。
|
||
printf '%s\n' "$_o" | grep -vF "$_t" | grep -v '^\[jsc\]' \
|
||
| grep -v '^ 修法:' | grep -v '^ 規則正文' | head -n 4
|
||
return 1
|
||
}
|
||
|
||
evidence=$(lang_hits "$msg") && exit 0
|
||
deny "提交訊息有簡體字、亂碼或編碼問題。本次執行已擋下。
|
||
$evidence" \
|
||
"改法:訊息改寫成繁體中文、UTF-8、無亂碼。字表在 jsc-hooks 的 hooks/simplified.txt,
|
||
規則正文見 jsc-meta 的 references/ste100.md。"
|