gate-log: 透明記錄層——每一支閘的擋/放行/繞過都留得下(InkStoneCo#48)

在此之前 36 支閘裡只有 2 支會記錄自己擋了什麼,六種逃生口
(NOT_MY_BRANCH_OK=1/no-ticket-needed/wiki-secret-ok/credential-ok/
solo-ok/kbdb-down)用了完全不留痕 ⇒ 擋對 100 次跟擋錯 100 次在資料上
一模一樣 ⇒ 規則只會累積不會被淘汰。

機制:hooks/lib/gate-log-wrap.sh 包在每一支閘外面(hooks.json 的 82 條
gate command 全部前綴這支 wrapper;2 條 scripts/ refresher 不是閘、不包)。
它從不 exit 2,只把真正那支閘的離開碼原樣傳出去 ⇒ 記錄壞掉最多漏記一筆,
不可能誤攔(票紅線:寧可漏記,不可擋錯)。每次留一筆中繼資料:
哪支閘/何時/對什麼工具/判成什麼/(若繞過)用了哪個逃生口。

- 只記中繼資料,完全不寫入指令原文或 payload ⇒ 金鑰在結構上進不了帳本(D36)
- 純 POSIX shell 記錄,不另起 python ⇒ 不讓閘變慢變脆
- 紀錄長在既有閘動作上,零輪詢/零 cron(D20)
- 帳本沿用 milestone-account 慣例(不寫進會被 plugin update 換掉的目錄)

報告:scripts/gate-report all 回答票上三題(誤擋嫌疑排序/最近沒擋過的規則/
同一 session 反覆撞同一支閘)。基線與反面驗證見票留言。

測試:hooks/tests/gate-log.test.sh 22 條(透明/留痕/反面驗證/不變脆/不外洩)
+ scripts/test-gate-report.sh 8 條(三題數字對不對)。
hooks 檔數 61、hooks.json 註冊 84 均不變(wrapper 住 hooks/lib,非 gate .sh)。

版本:待總管定版(改了會被載入的東西需升版才傳得到,但 tag 由總管打)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TNfNypeDaCVEZQW1Wqwfxq
This commit is contained in:
Claude
2026-09-06 21:01:42 +00:00
parent 605f1fe5a6
commit dda4852938
8 changed files with 668 additions and 82 deletions
+116
View File
@@ -0,0 +1,116 @@
#!/bin/sh
# gate-log-wrap.sh — 透明記錄層:包在每一支閘外面,把「這支閘這一次判成什麼」留一筆。
# inkstone/InkStoneCo#4836 支閘對著虛空開火,沒有任何資料
# 說得出任一支擋對幾次、擋錯幾次、被逃生口繞過幾次。)
#
# ── 它解什麼 ────────────────────────────────────────────────────────────────
# 在此之前只有 2 支閘會記錄自己擋了什麼,六種逃生口(NOT_MY_BRANCH_OK=1
# no-ticket-neededwiki-secret-okcredential-oksolo-okkbdb-down)用了**完全不留痕**。
# ⇒ 一條閘擋對 100 次跟擋錯 100 次,在資料上長得一模一樣 ⇒ 規則只會累積不會被淘汰。
# 這一層讓「規則有沒有在運作」變成查得出來的事實:每一次閘動作(擋/放行/繞過)留一筆。
#
# ── 為什麼是「包在外面」而不是改 61 支閘的內部 ──────────────────────────────
# ① 一份機制,一處測——不是散進 61 個檔各留一手(那正是「疊床架屋」)。
# ② 閘的離開碼是唯一權威:包在外面就能對每一支統一抓到「擋(2)/放行(0)」,
# 完全不必動任何一支閘的邏輯。
# hooks.json 把每一支閘的 command 從
# ${CLAUDE_PLUGIN_ROOT}/hooks/<gate>.sh
# 改成
# ${CLAUDE_PLUGIN_ROOT}/hooks/lib/gate-log-wrap.sh ${CLAUDE_PLUGIN_ROOT}/hooks/<gate>.sh
# 閘路徑當第一個參數傳進來;`bash "$GATE"` 是子行程,所以閘內部的 $0BASH_SOURCE[0]
# 仍是閘自己的路徑,`source .../lib/...` 那套定位照常成立。
#
# ── 紅線(InkStoneCo#48,逐條落在這支上)────────────────────────────────────
# 🔴 寧可漏記一筆,不可擋錯一次:這支**永遠只把真正那支閘的離開碼原樣傳出去**,
# 它自己的邏輯裡沒有任何一條 `exit 2`。記錄整段包在一個永遠成功的子殼裡
# `( … ) 2>/dev/null || true`),記錄壞掉最多漏記一筆,不可能造成誤攔。
# 🔴 不得讓閘變慢或變脆:記錄全程純 POSIX shell,不另外起 python;閘本來就要跑,
# 這裡只多一次輕量 shell fork。
# 🔴 D36 金鑰不落地:**只記中繼資料(閘名/事件/工具名/判成什麼/session/逃生口代號)**,
# **完全不寫入 payload 或指令原文** ⇒ 金鑰在結構上就進不了紀錄,不必事後遮罩。
# 🔴 零輪詢(D20):紀錄長在既有的閘動作上,不新增任何排程/cron/輪詢/事件 fan-out。
# 🔴 不新增第二套落地方式:沿用 milestone-account 的 jsonl 帳本慣例
# (不寫進會被 `plugin update` 整個換掉的 plugin 目錄;env 可覆寫路徑供測試)。
#
# 記錄檔位置(沿用 scripts/milestone-account 的 _resolve_dir 順序):
# ISEP_GATE_LOGenv,測試/停用用) → $CLAUDE_PROJECT_DIR/system-dev/gate-log/ →
# plugin 根的 system-dev/gate-log/ → ~/.claude/isep-gate-log/
# 檔名 gate-log.jsonl。用 `scripts/gate-report` 讀它回答三題。
GATE="$1"
[ -n "$GATE" ] || exit 0 # 沒給閘路徑:什麼都不做,放行(絕不擋、絕不報錯)
shift 2>/dev/null || true
# 讀一次 stdin(閘要吃的 payload
INPUT=$(cat 2>/dev/null || true)
# ── 先跑真正那支閘 ──────────────────────────────────────────────────────────
# stdin 原樣餵進去;stdout/stderr 原樣流出去(閘的紅字說明、注入的 context 都不動)。
# pipeline 最後一段是閘 ⇒ $? 就是閘的離開碼。
printf '%s' "$INPUT" | "$GATE" "$@"
rc=$?
# ── 記錄:全程 best-effort,包在永遠成功的子殼裡,絕不影響 rc、絕不外洩任何字 ──
(
GATE_NAME=${GATE##*/}; GATE_NAME=${GATE_NAME%.sh}
# 只從 payload 抽固定幾個「天生安全」的欄位;全部再過一層白名單字元清洗,
# 保證組出來的 JSON 一定合法、且不可能夾帶金鑰或引號破壞結構。
_field() { # _field <json-key> —— 印出第一個同名字串欄位的值(清洗後)
printf '%s' "$INPUT" \
| sed -n "s/.*\"$1\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" \
| head -n1 \
| tr -cd 'A-Za-z0-9_.:@/#+-' \
| cut -c1-100
}
EVENT=$(_field hook_event_name)
TOOL=$(_field tool_name)
SID=$(_field session_id)
# 判成什麼
if [ "$rc" = "2" ]; then VERDICT=block
elif [ "$rc" = "0" ]; then VERDICT=pass
else VERDICT=error; fi
# 逃生口偵測(owner-scoped:只有**擁有**那個逃生口的閘才檢查它自己那一個,
# 避免「指令裡剛好出現 credential-ok」被誤記到別支閘頭上)。
BYPASS=
case "$GATE_NAME" in
not-my-branch-guard) [ "${NOT_MY_BRANCH_OK:-}" = "1" ] && BYPASS=NOT_MY_BRANCH_OK ;;
subagent-first-guard) [ -n "$SID" ] && [ -f "/tmp/.solo-ok-$SID" ] && BYPASS=solo-ok ;;
history-first-guard) [ -f "${KBDB_STAMP_DIR:-/tmp}/.kbdb-down" ] && BYPASS=kbdb-down ;;
credential-only-guard) case "$INPUT" in *credential-ok*) BYPASS=credential-ok ;; esac ;;
wiki-secret-scan) case "$INPUT" in *wiki-secret-ok*) BYPASS=wiki-secret-ok ;; esac ;;
mistake-needs-ticket-guard) case "$INPUT" in *no-ticket-needed*) BYPASS=no-ticket-needed ;; esac ;;
esac
# 逃生口只有在「閘放行了」時才算真的被繞過;閘照樣擋下 ⇒ 逃生口沒生效,維持 block。
if [ -n "$BYPASS" ] && [ "$VERDICT" = "pass" ]; then VERDICT=bypass; fi
# 是不是子 session(給「同一支 agent 反覆撞同一支閘」那一題用)
if [ "${CLAUDE_CODE_CHILD_SESSION:-}" = "1" ]; then CHILD=1; else CHILD=0; fi
# 記錄檔位置(沿用 milestone-account 慣例;不寫進 plugin 目錄)
LEDGER=${ISEP_GATE_LOG:-}
if [ -z "$LEDGER" ]; then
_root=$(CDPATH= cd -- "$(dirname -- "$0")/../.." 2>/dev/null && pwd) || _root=
_dir=
for _b in "${CLAUDE_PROJECT_DIR:-}" "$_root"; do
[ -n "$_b" ] && [ -d "$_b/system-dev" ] && { _dir="$_b/system-dev/gate-log"; break; }
done
[ -n "$_dir" ] || _dir="$HOME/.claude/isep-gate-log"
LEDGER="$_dir/gate-log.jsonl"
fi
[ "$LEDGER" = /dev/null ] && exit 0 # 明示停用
TS=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || printf '')
GATE_NAME=$(printf '%s' "$GATE_NAME" | tr -cd 'A-Za-z0-9_.-' | cut -c1-80)
LINE=$(printf '{"ts":"%s","gate":"%s","event":"%s","tool":"%s","verdict":"%s","bypass":"%s","sid":"%s","child":%s}' \
"$TS" "$GATE_NAME" "$EVENT" "$TOOL" "$VERDICT" "$BYPASS" "$SID" "$CHILD")
mkdir -p -- "$(dirname -- "$LEDGER")" 2>/dev/null || true
# 單一 printf 的短行 append<4KB)在 O_APPEND 下是原子的 ⇒ 併發的多支閘不會互相截斷。
printf '%s\n' "$LINE" >> "$LEDGER" 2>/dev/null || true
) >/dev/null 2>&1 || true
exit $rc