feat(write-guard): 新增寫入與提交閘門,把三條只寫在內文的規則落到程式層

新增第九支 hook,共四種模式。stage 在計畫與分析階段鎖著時擋下寫檔。
review 在稽核類技能執行中擋下寫檔。commit 擋下「一次加入全部變更再提交」的
單一指令,也擋下含簡體字、亂碼或非 UTF-8 編碼的提交訊息。
release 不接 hook,由稽核技能收尾時自己呼叫,清掉認人用的那份紀錄,
讓呼叫端接手修改時不會被剛跑完的稽核擋住。

這三條規則原本只寫在技能內文,靠模型自律。稽核技能會順手改程式碼,
計畫階段會寫出不該寫的檔案,整包提交會把型別與功能分組壓成一次。
規則要真的生效,就得由程式擋。release 模式是必要的:紀錄記的是最近一次
載入的技能,不是還在跑的技能,沒有它,稽核跑完之後呼叫端每一次寫入都被擋,
閘門會把解除自己的路徑一起鎖掉。

接線設定把 stage 與 review 接在寫檔工具,把 commit 接在指令工具。
閘門只讀階段閘門的狀態鎖與技能用量的紀錄,不自己寫狀態檔;
簡繁與編碼判定整段轉呼叫語言守門,不留第二份字表。
接線腳本的接線、唯讀盤點與冒煙各補上這一支,冒煙自備暫時的狀態目錄,
逐條比對每種模式的結束碼。接線腳本另補唯讀模式,體檢類技能全程帶著它跑,
清除與接線一律拒絕並回非零,子命令打錯一個字也改不到環境。
冒煙改成自己數結果行並自我斷言,散文只引用那一行,不再各抄一份數字。

限制據實寫在檔頭:只有 claude 有寫檔前置鉤子,其餘四支 CLI 一條都接不上,
那四支上這三條規則仍只剩技能內文。
This commit is contained in:
2026-08-31 11:15:24 +08:00
parent 6b272974b5
commit b5219f6549
3 changed files with 489 additions and 24 deletions
+22
View File
@@ -73,6 +73,28 @@
"command": "sh -c 'root=\"${CLAUDE_PLUGIN_ROOT:-${JSC_HOME:-$HOME/.jsc}/current/jsc-hooks}\"; exec sh \"$root/hooks/sdlc-gate.sh\" wp-check skill'"
}
]
},
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "sh -c 'root=\"${CLAUDE_PLUGIN_ROOT:-${JSC_HOME:-$HOME/.jsc}/current/jsc-hooks}\"; exec sh \"$root/hooks/write-guard.sh\" stage'"
},
{
"type": "command",
"command": "sh -c 'root=\"${CLAUDE_PLUGIN_ROOT:-${JSC_HOME:-$HOME/.jsc}/current/jsc-hooks}\"; exec sh \"$root/hooks/write-guard.sh\" review'"
}
]
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "sh -c 'root=\"${CLAUDE_PLUGIN_ROOT:-${JSC_HOME:-$HOME/.jsc}/current/jsc-hooks}\"; exec sh \"$root/hooks/write-guard.sh\" commit'"
}
]
}
],
"PostToolUse": [
+280
View File
@@ -0,0 +1,280 @@
#!/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。"