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:
Executable
+206
@@ -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(" (還沒有任何 block/bypass 紀錄。)")
|
||||
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()
|
||||
Executable
+66
@@ -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(只放行)與 gateD(30天前才擋)─"
|
||||
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 "── Q3:sA 反覆撞 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 ]
|
||||
Reference in New Issue
Block a user