Files
ISEP/hooks/lib/gate-log-wrap.sh
T
Claude dda4852938 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
2026-09-06 21:01:42 +00:00

117 lines
7.1 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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