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
+206
View File
@@ -0,0 +1,206 @@
#!/usr/bin/env python3
"""gate-report — 把 gate-log.jsonl 變成「規則有沒有在運作」的答案(inkstone/InkStoneCo#48)。
這支不是文件、不是感覺,是**查資料**。紀錄由 hooks/lib/gate-log-wrap.sh 在每一次
閘動作當下留下(擋/放行/被逃生口繞過各一種 verdict)。本工具回答票上那三題:
q1 哪支閘擋最多?其中有多少最後被繞過? (=誤擋嫌疑最高的)
q2 哪條規則最近 N 天一次都沒擋過? (=可能已經沒有存在意義)
q3 同一個 session/agent 反覆撞同一支閘幾次? (=規則沒被理解,或閘寫錯)
用法:
scripts/gate-report [--log PATH] [--days N] [all|q1|q2|q3|summary|ledger-path]
--log 紀錄檔路徑(預設沿用 gate-log-wrap.sh 的解析順序)
--days N q2 的視窗,最近 N 天(預設 7)
(不給子命令 = all:先印基線數字,再依序印三題)
離線、唯讀:只讀那個 jsonl,不打任何網路,不寫任何東西。
"""
import argparse
import collections
import datetime as dt
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE) # scripts/ 的上一層 plugin 根
def resolve_log():
"""跟 gate-log-wrap.sh 同一套順序找紀錄檔。"""
v = os.environ.get("ISEP_GATE_LOG")
if v:
return v
for base in (os.environ.get("CLAUDE_PROJECT_DIR") or "", ROOT):
if base and os.path.isdir(os.path.join(base, "system-dev")):
return os.path.join(base, "system-dev", "gate-log", "gate-log.jsonl")
return os.path.join(os.path.expanduser("~"), ".claude", "isep-gate-log", "gate-log.jsonl")
def registered_gates():
"""從 hooks.json 讀出「目前真的被記錄層包著的閘」清單——q2 要拿它跟紀錄比對,
才分得出『掛著但從沒擋過』與『根本沒掛』。
判準是「這條 command 有沒有被 gate-log-wrap.sh 包住」:包住的才是我們在計數的閘,
scripts/ 底下的 refresher(不是閘、刻意沒包)就不會被誤算進來。
讀不到 hooks.json 就回空集合(q2 會退回只用紀錄裡看過的閘,不會因此爆掉)。"""
path = os.path.join(ROOT, "hooks", "hooks.json")
names = set()
try:
d = json.load(open(path))
for ev in d.get("hooks", {}).values():
for group in ev:
for h in group.get("hooks", []):
cmd = h.get("command", "")
if "gate-log-wrap" not in cmd:
continue # 沒被記錄層包住 = 不是我們在計數的閘
last = cmd.split()[-1] if cmd.split() else "" # 閘路徑一律是最後一段
base = last.rsplit("/", 1)[-1]
if base.endswith(".sh"):
names.add(base[:-3])
except Exception:
pass
return names
def load(path):
rows = []
try:
with open(path) as f:
for ln in f:
ln = ln.strip()
if not ln:
continue
try:
rows.append(json.loads(ln))
except Exception:
continue # 壞行跳過,不讓一行毀掉整份報告
except FileNotFoundError:
pass
return rows
def parse_ts(s):
try:
return dt.datetime.strptime(s, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=dt.timezone.utc)
except Exception:
return None
def summary(rows):
v = collections.Counter(r.get("verdict") for r in rows)
gates = {r.get("gate") for r in rows if r.get("gate")}
print("── 基線數字(拿今天當基線)───────────────────────────")
print(" 紀錄總筆數 : %d" % len(rows))
print(" 出現過的閘 : %d 支" % len(gates))
print(" 擋下 (block) : %d" % v.get("block", 0))
print(" 被繞過 (bypass) : %d" % v.get("bypass", 0))
print(" 放行 (pass) : %d" % v.get("pass", 0))
print(" 其他/錯 (error) : %d" % v.get("error", 0))
if not rows:
print(" (帳本還是空的——這一層剛裝上,還沒有任何閘動作被記錄。)")
def q1(rows):
print("── Q1 哪支閘擋最多?其中多少被繞過?(誤擋嫌疑排序)──────")
block = collections.Counter()
byp = collections.Counter()
for r in rows:
g = r.get("gate")
if not g:
continue
if r.get("verdict") == "block":
block[g] += 1
elif r.get("verdict") == "bypass":
byp[g] += 1
gates = set(block) | set(byp)
if not gates:
print(" (還沒有任何 blockbypass 紀錄。)")
return
# 「擋」= block + bypass(兩者都是「這支閘本來要擋」的動作);被繞過率高 = 誤擋嫌疑高
print(" %-30s %6s %7s %7s %s" % ("閘", "擋下", "被繞過", "本要擋", "繞過率"))
for g in sorted(gates, key=lambda x: -(block[x] + byp[x])):
would = block[g] + byp[g]
rate = (byp[g] / would * 100) if would else 0.0
print(" %-30s %6d %7d %7d %5.1f%%" % (g, block[g], byp[g], would, rate))
def q2(rows, days, reg):
print("── Q2 最近 %d 天一次都沒擋過的規則(可能已無存在意義)──" % days)
now = dt.datetime.now(dt.timezone.utc)
cutoff = now - dt.timedelta(days=days)
blocked_recent = set()
seen = set()
for r in rows:
g = r.get("gate")
if not g:
continue
seen.add(g)
ts = parse_ts(r.get("ts", ""))
if r.get("verdict") in ("block", "bypass") and ts and ts >= cutoff:
blocked_recent.add(g)
universe = (reg | seen) if reg else seen
idle = sorted(g for g in universe if g not in blocked_recent)
if not universe:
print(" (沒有可比對的閘清單。)")
return
for g in idle:
tag = "(掛著,但這視窗內沒有任何紀錄)" if g not in seen else "(有動作,但都是放行,沒擋過)"
print(" · %-30s %s" % (g, tag))
if not idle:
print(" (這視窗內每一支掛著的閘都至少擋過一次。)")
def q3(rows):
print("── Q3 同一個 session/agent 反覆撞同一支閘(≥2 次)─────")
hit = collections.Counter()
child = {}
for r in rows:
if r.get("verdict") not in ("block", "bypass"):
continue
g, sid = r.get("gate"), r.get("sid") or "(no-sid)"
if not g:
continue
hit[(sid, g)] += 1
child[(sid, g)] = r.get("child", 0)
repeat = [(k, n) for k, n in hit.items() if n >= 2]
if not repeat:
print(" (還沒有『同一 session 撞同一支閘 2 次以上』的紀錄。)")
return
print(" %6s %-4s %-30s %s" % ("撞幾次", "子?", "閘", "session"))
for (sid, g), n in sorted(repeat, key=lambda x: -x[1]):
print(" %6d %-4s %-30s %s" % (n, "子" if child[(sid, g)] else "主", g, sid))
def main():
ap = argparse.ArgumentParser(add_help=True)
ap.add_argument("cmd", nargs="?", default="all",
choices=["all", "q1", "q2", "q3", "summary", "ledger-path"])
ap.add_argument("--log", default=None)
ap.add_argument("--days", type=int, default=7)
a = ap.parse_args()
path = a.log or resolve_log()
if a.cmd == "ledger-path":
print(path)
return
rows = load(path)
reg = registered_gates()
if a.cmd in ("all", "summary"):
summary(rows)
if a.cmd == "summary":
return
print()
if a.cmd in ("all", "q1"):
q1(rows); print() if a.cmd == "all" else None
if a.cmd in ("all", "q2"):
q2(rows, a.days, reg); print() if a.cmd == "all" else None
if a.cmd in ("all", "q3"):
q3(rows)
if __name__ == "__main__":
main()
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
# scripts/gate-report 的迴歸測試(inkstone/InkStoneCo#48 驗收 #2
#
# 用一份寫死的 fixture 帳本,驗這三題「查得出來、答得對」,不是憑印象:
# Q1 哪支閘擋最多?其中多少被繞過?
# Q2 哪條規則最近 N 天一次都沒擋過?
# Q3 同一個 session 反覆撞同一支閘幾次?
# 🔴 出路要真的走得通(mistakes.md #158):這裡不驗「有沒有印字」,
# 驗「印出來的數字對不對」。
set -u
REPORT="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/gate-report}"
TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT
LED="$TMP/gate-log.jsonl"
PASS=0; FAIL=0; N=0
ok() { N=$((N+1)); printf ' ✅ %s\n' "$1"; PASS=$((PASS+1)); }
bad() { N=$((N+1)); printf ' ❌ %s\n' "$1"; FAIL=$((FAIL+1)); shift; for l in "$@"; do printf ' %s\n' "$l"; done; }
NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ)
OLD=$(python3 -c "import datetime as d;print((d.datetime.now(d.timezone.utc)-d.timedelta(days=30)).strftime('%Y-%m-%dT%H:%M:%SZ'))")
row() { printf '{"ts":"%s","gate":"%s","event":"PreToolUse","tool":"Bash","verdict":"%s","bypass":"%s","sid":"%s","child":%s}\n' \
"$1" "$2" "$3" "$4" "$5" "${6:-0}" >> "$LED"; }
# gateA:擋 3 次、繞過 2 次(誤擋嫌疑最高);session sA 撞它 3 次
row "$NOW" gateA block "" sA 0
row "$NOW" gateA block "" sA 0
row "$NOW" gateA block "" sA 0 # sA 撞 gateA 共 3 次(Q3
row "$NOW" gateA bypass NOT_MY_BRANCH_OK sB 1
row "$NOW" gateA bypass NOT_MY_BRANCH_OK sB 1
# gateB:擋 1 次
row "$NOW" gateB block "" sC 0
# gateC:最近只有放行,從沒擋過(Q2 該點名)
row "$NOW" gateC pass "" sC 0
row "$NOW" gateC pass "" sD 0
# gateD:只有 30 天前擋過,最近 7 天沒擋(Q2 該點名)
row "$OLD" gateD block "" sE 0
echo "── Q1:擋最多的是 gateA,且看得出被繞過 2 次 ────────────────"
Q1=$(ISEP_GATE_LOG="$LED" "$REPORT" q1 2>&1)
FIRST=$(printf '%s' "$Q1" | grep -E '^\s+gate[A-Z]' | head -n1)
case "$FIRST" in *gateA*) ok "① 誤擋嫌疑排序第一名是 gateA" ;; *) bad "① 排序第一名不是 gateA" "$Q1" ;; esac
# gateA 那行:擋下 3、被繞過 2、本要擋 5
if printf '%s' "$Q1" | grep -E 'gateA' | grep -qE '\b3\b.*\b2\b.*\b5\b'; then
ok "② gateA 的數字對:擋下3 / 被繞過2 / 本要擋5"
else bad "② gateA 數字不對" "$(printf '%s' "$Q1" | grep gateA)"; fi
echo "── Q2:最近 7 天沒擋過的是 gateC(只放行)與 gateD30天前才擋)─"
Q2=$(ISEP_GATE_LOG="$LED" "$REPORT" q2 --days 7 2>&1)
printf '%s' "$Q2" | grep -q 'gateC' && ok "③ gateC(活著但只放行)被點名" || bad "③ 沒點名 gateC" "$Q2"
printf '%s' "$Q2" | grep -q 'gateD' && ok "④ gateD(最近7天沒擋)被點名" || bad "④ 沒點名 gateD" "$Q2"
printf '%s' "$Q2" | grep -qE '^\s+·\s+gateA' && bad "⑤ 誤點名 gateA(它最近有擋)" "$Q2" || ok "⑤ 有擋過的 gateA 不被點名"
echo "── Q3sA 反覆撞 gateA 3 次 ─────────────────────────────────"
Q3=$(ISEP_GATE_LOG="$LED" "$REPORT" q3 2>&1)
if printf '%s' "$Q3" | grep -E 'gateA' | grep -q 'sA'; then
if printf '%s' "$Q3" | grep -E 'sA' | grep -qE '\b3\b'; then ok "⑥ sA 撞 gateA 記成 3 次"; else bad "⑥ 次數不是 3" "$Q3"; fi
else bad "⑥ 沒抓到 sA×gateA" "$Q3"; fi
# sC 撞 gateB 只 1 次,不該出現在 Q3
printf '%s' "$Q3" | grep -E 'gateB' | grep -q 'sC' && bad "⑦ 撞1次的 sC×gateB 不該出現" "$Q3" || ok "⑦ 只撞1次的不入 Q3"
echo "── 基線:空帳本也要能跑(不爆炸)───────────────────────────"
EMPTY="$TMP/empty.jsonl"; : > "$EMPTY"
if ISEP_GATE_LOG="$EMPTY" "$REPORT" all >/dev/null 2>&1; then ok "⑧ 空帳本跑 all 不報錯"; else bad "⑧ 空帳本跑 all 爆了"; fi
echo ""
echo "通過 $PASS 條,失敗 $FAIL 條(共 $N"
[ "$FAIL" = 0 ]