現行紀錄只記「被叫用」,欄位是 ts、cli、session、skill,沒有成敗也沒有 結束碼。跑完整輪的技能與開場就中止的技能,在紀錄裡長得一模一樣。hook 成功時更是完全不留紀錄,只有錯誤路徑會寫 wiki,而那條路徑刻意不自動觸發。 事件流走本機檔案,不直接寫 wiki。hook 每次提示都跑,網路寫入會拖垮宿主 CLI;失敗的 hook 自我回報還會疊出迴圈,既有的錯誤回報因此不接在失敗的 hook 上,這裡沿用同一條線。助理巡檢時排空、彙整、寫頁。 hook 端用 EXIT trap 接,一支只加一行。這幾支的 exit 點很多,階段閘門一支 就有五十幾個;逐點改要動到每一條判定路徑,而那些路徑正是閘門的判準,為了 加一行紀錄去動閘門,風險遠大於收益。trap 涵蓋每一條離開路徑,含中途失敗。 狀態預設由結束碼推,推不出來的由 hook 自己覆寫。相依版本檢查與兩道閘門有 這種情形:antigravity 走 deny JSON、kiro 只印警告,兩者擋下時結束碼都是 0, 單看結束碼會把擋下記成放行。 技能的 start 由既有的技能用量 hook 順手發,不必改任何技能文件。end 只能由 技能自己在收尾步驟寫——hook 觸發時技能的實際工作還在後面的模型輪次,看不到 成敗。有 start 沒有配對的 end,就是那一輪中止了。
286 lines
14 KiB
Bash
Executable File
286 lines
14 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/{cli}-{sid}.stage,單行
|
||
# 「{階段}<TAB>{必要標籤}<TAB>{上鎖時的模型}」),這裡只讀不寫:判準與格式留在 sdlc-gate.sh,
|
||
# 兩邊各存一份就會漂移。路徑一律取自 lib.sh 的 stage_state_read_file,連「新路徑優先、舊路徑
|
||
# 往後相容」這條規則也不在這裡抄第二份。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"
|
||
hook_trace "write-guard ${1:-}"
|
||
# 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=$(stage_state_read_file)
|
||
[ -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
|
||
# 解除指令帶上實際讀到的狀態檔路徑:擋人的是 hook,解鎖的是人在殼層手動執行,兩邊算出來
|
||
# 的檔名未必相同,不點名就會解到別的地方去。
|
||
deny "目前鎖在 SDLC「$stage」階段,這個階段只產出計畫或分析頁,不寫檔案。本次寫入已擋下。" \
|
||
"改法:把結論寫進該階段的 wiki 頁;真的要動程式碼請先進入 implement 階段。
|
||
解除階段鎖:sh $HERE/sdlc-gate.sh unlock $state"
|
||
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。"
|