#!/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()