Files
ISEP/scripts/isep-notify
T
isep-hand 65120a1c65 雲端要拿得到票、主線與通知:白名單住 ISEP、主線檔隨 repo 走、leo21c 讀放寫擋(inkstone/ISEP#130)
- docs/permissions-allow.json + scripts/settings-allow-sync:四個 Gitea 正門工具的權限白名單一份,
  setup script 裝完 plugin 寫一次、SessionStart 每次再對一次(只加不減、冪等)
- hooks/lib/mainline.py/scripts/mainline:家目錄沒主線就讀 InkStoneCo/system-dev/mainline.json;
  set/adopt/clear 兩份一起寫,refresh 只寫家目錄
- hooks/leo21c-write-guard.sh:唯讀 -d(tr/cut/sort…)先剪掉再判、notify_leo trigger 放行
  (與 prod-write-guard 同一份白名單)、改法段改印 09-02 起的 youlin 子網域;補第一支測試(26 條)
- prod-write-guard/main-and-prod-push-guard/kbdb-live-exam:認得 youlin 新子網域 arcrun-yuga3bse
- scripts/ticket:收件 repo 寫 inkstone/ISEP 不再 404(org 寫錯當場講)
- scripts/isep-notify:有 TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID 先走 Bot API 直送(雲端唯一通的路)
- 測試:A31–A35 共 79 條;README/plugin.json/hooks-inventory 數字實數(61 支、85 條、53 支腳本)

假設(記在這裡等 review):權限規則的形狀沿用 leo 09-07 親手加、實測有效的那四條;
「分類器真的不擋」要雲端一趟 run 的 permission_denials 才驗得到,本 PR 驗不了。
版本:待總管定版(plugin.json 仍 0.22.0)。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DTZ9QtvjY7MNxjfQbexAm7
2026-09-07 01:17:15 +00:00

486 lines
22 KiB
Python
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.
#!/usr/bin/env python3
"""isep-notify — 發一則 Telegram 給 leo,而且**發不出去的時候不會安靜**
inkstone/ISEP#93
━━ 這支存在的理由(2026-08-28 實測出來的限制)━━━━━━━━━━━━━━━━━━━━━━━━
通道本身早就有(`notify_leo`,一條 curl、不需金鑰,見頂層 wiki `agent-memory.md`)。
問題不在通道,在**閘**
· `prod-write-guard.sh` 擋「打到線上實例的寫入型請求」,而發一則 Telegram
在它眼裡跟「部署一個工作流上線」長得一模一樣(同一個 named webhook 家族路徑)
· v0.10.0 已經修好——只放行 `notify_leo` 這**一個名字**
· **但 hook 的註冊路徑是 session 啟動當下寫死的** ⇒ 更新 plugin 之後,
要開一個**新** session 才會載到新那份
⇒ 所以「能不能發得出去」是**每個 session 各自不同的事實**,不是全域設定。
這支的工作就是:**先去問這個 session 的閘,再決定怎麼辦**,而不是先射再說。
🔴 **發不出去而靜默,等於沒做。** 三條出路,一條都不能省:
① 先問閘(`--gate`):把「這個 session 載到的是哪一版、它會不會擋」變成查得到的事實
② 閘說會擋 ⇒ **不送**(不繞路,見下),改走退路:貼回票上 + 大聲印在眼前
③ 送出去了也要驗**內層** `data.data.ok`——外層 200 不算送到(wiki 記過這個坑)
━━ 為什麼閘說會擋就不送,而不是「反正 python 打得出去」━━━━━━━━━━━━━━━
這支用 urllib 打 HTTP`prod-write-guard` 掛在 `Bash` 上——**它看不到這支**。
也就是說:不問閘就送,一定送得出去。**而那正是不能做的事。**
leo 2026-08-11:「hook 要不讓它對正式環境做任何推送,不是某種推送」
leo(同期):「人就會學會繞過它,那它就等於不存在」
一支「繞過閘也照送」的工具,會讓那道閘對整條 python 路徑失效。
⇒ 本支**自己去問那道閘**,閘說不行就不行。這比 shell 那條路更嚴,不是更鬆。
━━ 用法 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
isep-notify --gate 只印閘的判定(不送、不碰網路)
isep-notify --text "…" 送一則
isep-notify --text-file <檔> 內文放檔案(長訊息用)
isep-notify --text "…" --fallback-ticket inkstone/ISEP#93
送不出去時貼回那張票
isep-notify --text "…" --dry-run 全流程走一遍,只是不真的送
離開碼:0 leo 拿得到了(Telegram 送達,或退路留在票上)
1 = 兩條路都沒走通(這時 stderr 一定有一段紅字,不會安靜)
環境變數(都有預設,測試才需要動):
TELEGRAM_BOT_TOKENTELEGRAM_CHAT_ID 有就先走 Bot API 直送(雲端唯一通的路,ISEP#130)
ISEP_NOTIFY_BOT_API Bot API 的主機(預設 https://api.telegram.org;測試指到假的)
ISEP_NOTIFY_URL notify_leo 的 trigger 網址
ISEP_NOTIFY_IDENTITY 貼票時的【身份】欄
ISEP_NOTIFY_STATE_DIR 閘判定快取的位置(預設 ~/.claude/isep-nag
ISEP_NOTIFY_GUARD 指定要問哪一支 prod-write-guard(測試用)
ISEP_NOTIFY_OFFLINE=1 不打任何網路(測試用)
"""
import hashlib
import json
import os
import re
import subprocess
import sys
import time
import urllib.error
import urllib.request
NOTIFY_URL = os.environ.get(
"ISEP_NOTIFY_URL",
"https://arcrun-cypher-executor.leo21c.workers.dev"
"/webhooks/named/leo/notify_leo/trigger",
)
IDENTITY = os.environ.get("ISEP_NOTIFY_IDENTITY", "【身份】總管/inkstone/ISEP-")
STATE_DIR = os.environ.get("ISEP_NOTIFY_STATE_DIR") or os.path.join(
os.path.expanduser("~"), ".claude", "isep-nag")
GITEA_HOST = os.environ.get("TICKET_HOST") or "https://git.uncle6.me"
PROD_STAMP = "/tmp/.prod-write-ok"
GATE_FACT = "gate-verdict.json"
# ══ 一、這個 session 的閘是哪一份、哪一版 ═══════════════════════════════
#
# 🔴 這一段就是工單說的「**把『這個 session 的閘是哪一版』變成它自己查得到的事實**」。
# 三個來源,可信度由高到低——**而且一定把來源講出來**,
# 因為「我問到的那份」跟「這個 session 真的註冊的那份」可能不是同一個檔。
def find_guard():
"""回 (guard 路徑, plugin 根目錄, 來源說明, 可信度)。找不到回 (None, ...)。"""
cands = []
forced = os.environ.get("ISEP_NOTIFY_GUARD")
if forced:
# 測試用:直接指定要問哪一支(測「舊版會擋」時把歷史版本擺進來)
return forced, os.path.dirname(os.path.dirname(forced)), "ISEP_NOTIFY_GUARD 指定", "high"
root = os.environ.get("CLAUDE_PLUGIN_ROOT")
if root:
# hook 裡跑:這就是**這個 session 真的註冊的那一份**,沒有第二種可能
cands.append((root, "CLAUDE_PLUGIN_ROOT(這個 session 註冊的那一份)", "high"))
remembered = _read_fact()
if remembered and remembered.get("plugin_root"):
cands.append((remembered["plugin_root"],
"SessionStart 當時記下來的那一份", "high"))
here = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
cands.append((here, "本腳本自己所在的那一份(不一定是 session 註冊的)", "low"))
for r, why, conf in cands:
g = os.path.join(r, "hooks", "prod-write-guard.sh")
if os.path.isfile(g):
return g, r, why, conf
return None, None, "找不到任何 prod-write-guard.sh", "none"
def plugin_version(root):
try:
with open(os.path.join(root, ".claude-plugin", "plugin.json")) as f:
return json.load(f).get("version") or "未知"
except Exception:
return "未知"
def probe_gate(url=None):
"""拿**這一支真的要送的請求**去問閘:你會不會擋?
回一個 dict,永遠有 verdict
pass 閘看過了,放行
block 閘會擋(=這個 session 載到的是舊版,或白名單被改窄了)
unknown 問不出來(沒有閘、戳記在場、腳本壞了)——**不會謊稱 pass**
"""
url = url or NOTIFY_URL
guard, root, why, conf = find_guard()
fact = {
"at": int(time.time()),
"url": url,
"guard": guard,
"plugin_root": root,
"source": why,
"confidence": conf,
"version": plugin_version(root) if root else "未知",
}
if not guard:
fact.update(verdict="unknown", reason="這個環境找不到 ISEP 的 prod-write-guard"
"(沒有閘 ⇒ 沒有東西被繞過)")
return fact
# 🔴 戳記在場就**不探針**`prod-write-guard` 的戳記是**單次、用完即丟**的,
# 探一次就把總管剛按下去的那一次授權燒掉了。
# (而戳記在場本來就代表「現在允許寫」⇒ 送得出去,不必問。)
if os.path.exists(PROD_STAMP):
fact.update(verdict="unknown",
reason="/tmp/.prod-write-ok 在場:探針會把它燒掉(單次用完即丟),"
"所以不問。戳記在場=現在允許寫入")
return fact
payload = json.dumps({
"tool_name": "Bash",
"tool_input": {
"command": ("curl -s -X POST %s -H 'Content-Type: application/json' "
"-d '{\"text\":\"[總管] gate probe\"}'" % url),
},
})
try:
p = subprocess.run(["bash", guard], input=payload, capture_output=True,
text=True, timeout=30)
except Exception as e:
fact.update(verdict="unknown", reason="問不動那支閘:%s" % e)
return fact
if p.returncode == 0:
fact.update(verdict="pass", reason="閘看過這一則通知,放行")
elif p.returncode == 2:
fact.update(verdict="block",
reason="閘會擋。這個 session 載到的 ISEP 是 v%s——"
"它的 prod-write-guard 還認不出 notify_leo 不是部署"
% fact["version"],
gate_says=(p.stderr or "").strip()[:400])
else:
fact.update(verdict="unknown",
reason="那支閘回了離開碼 %d(既不是放行也不是擋)" % p.returncode)
return fact
def _fact_path():
return os.path.join(STATE_DIR, GATE_FACT)
def _read_fact():
try:
with open(_fact_path()) as f:
return json.load(f)
except Exception:
return None
def remember_fact(fact):
"""把判定寫成檔:之後任何一支腳本(含手動跑的)都查得到,不必自己再探一次。"""
try:
os.makedirs(STATE_DIR, exist_ok=True)
with open(_fact_path(), "w") as f:
json.dump(fact, f, ensure_ascii=False, indent=2)
except Exception:
pass
def gate_report(fact):
icon = {"pass": "🟢", "block": "🔴", "unknown": "🟡"}.get(fact["verdict"], "🟡")
lines = [
"%s 這個 session 的通知閘:**%s**" % (icon, fact["verdict"]),
" ISEP 版本:v%s" % fact.get("version", "未知"),
" 問的是哪一份:%s" % (fact.get("guard") or "(找不到)"),
" 來源可信度:%s%s" % (fact.get("confidence"), fact.get("source")),
" 理由:%s" % fact.get("reason", ""),
]
if fact["verdict"] == "block":
lines += [
"",
" ⇒ **這個 session 發不出 Telegram**,不是網路問題,是閘的版本問題。",
" ⇒ 解法(兩步,缺一不可):",
" claude plugin update isep@inkstone # 拿到 v0.10.0 以上",
" **開一個新的 session** # 註冊路徑是啟動當下寫死的",
" ⇒ 在那之前,本支一律走退路(貼回票上+印在眼前),不會安靜。",
]
return "\n".join(lines)
# ══ 二、真的送 ═══════════════════════════════════════════════════════════
def send_telegram(text):
"""回 (ok, 說明)。**內層 data.data.ok 才算送到**——外層 200 不算。"""
if os.environ.get("ISEP_NOTIFY_OFFLINE") == "1":
return False, "ISEP_NOTIFY_OFFLINE=1:刻意不打網路"
body = json.dumps({"text": text}).encode()
# 🔴 一定要帶 User-Agent2026-08-28 實撞):不帶的話 urllib 送出的是
# `Python-urllib/3.x`**Cloudflare 直接回 403 error code 1010**bad user agent)。
# 症狀非常會騙人——wiki 說「一條 curl、不需任何金鑰」是對的,
# curl 自己帶 UA,所以同一個網址用 curl 通、用 python 不通
# ⇒ 會被誤讀成「通道壞了」或「這個 session 沒權限」,而兩個都不是。
req = urllib.request.Request(
NOTIFY_URL, data=body, method="POST",
headers={"Content-Type": "application/json",
"User-Agent": "isep-notify/1.0 (+inkstone/ISEP#93)"})
try:
raw = urllib.request.urlopen(req, timeout=30).read().decode()
except urllib.error.HTTPError as e:
return False, "HTTP %s%s" % (e.code, e.read().decode()[:200])
except Exception as e:
return False, "連不上:%s" % e
try:
d = json.loads(raw)
except Exception:
return False, "回應不是 JSON%s" % raw[:200]
# 🔴 wiki 記過這個坑:外層 success=true、內層 404 也發生過
inner = ((d.get("data") or {}).get("data") or {})
if inner.get("ok") is True:
return True, "送到了(message_id=%s" % inner.get("message_id")
if "找不到 workflow" in raw or "not found" in raw.lower():
# 🔴 這一種要分開講:**閘沒擋、網路也通,是那台實例上根本沒有這支工作流**
# wiki `agent-memory.md` 記過一次:2026-08-10 KV 整批換新時定義消失)。
# 講成「發不出去」會讓人去修錯的地方——修閘、修 token,而斷點不在那裡。
return False, ("通道本身斷了:實例上找不到 `notify_leo` 工作流。"
"**這不是閘擋的、也不是網路問題**——要有人把它重新 push 上去"
"`mira` repo `workflows/notify-leo.yaml`)。原文:%s" % raw[:200])
return False, "外層通了但**內層沒 ok**%s" % raw[:300]
# ══ 二之一、直送 Bot API(雲端的路,inkstone/ISEP#130)═══════════════════════
#
# 2026-09-07 從雲端 session 實測三條路:
# notify_leo @ leo21c → HTTP 404「找不到 workflow」(08-28 起就斷,修它是 leo 的人閘)
# notify_leo @ youlin → 連不到:雲端 egress proxy 對 *.arcrun-yuga3bse.workers.dev 回 403
# policy denial,不是 DNS、不是 000;要 leo 在 Cloud environment 放行主機)
# api.telegram.org → 302**通的**
# ⇒ 雲端唯一走得通的是 Bot API 直送,而它只缺兩個環境變數:
# TELEGRAM_BOT_TOKENTELEGRAM_CHAT_IDRoutine 文件 cloud-worker.md 檔頭本來就列著它們)。
# 有 ⇒ 先走這條(它不碰任何實例,閘管不到也不必管);沒有 ⇒ 講清楚缺哪兩個,走原本那條。
BOT_API = os.environ.get("ISEP_NOTIFY_BOT_API", "https://api.telegram.org")
def bot_creds():
tok = (os.environ.get("TELEGRAM_BOT_TOKEN") or "").strip()
chat = (os.environ.get("TELEGRAM_CHAT_ID") or "").strip()
return (tok, chat) if tok and chat else (None, None)
def send_bot_api(text):
"""回 (ok, 說明)。Telegram 的回應要 `ok: true` 才算送到。"""
tok, chat = bot_creds()
if not tok:
return False, "沒有 TELEGRAM_BOT_TOKENTELEGRAM_CHAT_ID(雲端要 leo 在 Cloud environment 加這兩個變數)"
if os.environ.get("ISEP_NOTIFY_OFFLINE") == "1":
return False, "ISEP_NOTIFY_OFFLINE=1:刻意不打網路"
req = urllib.request.Request(
"%s/bot%s/sendMessage" % (BOT_API, tok),
data=json.dumps({"chat_id": chat, "text": text}).encode(), method="POST",
headers={"Content-Type": "application/json",
"User-Agent": "isep-notify/1.0 (+inkstone/ISEP#130)"})
try:
raw = urllib.request.urlopen(req, timeout=30).read().decode()
except urllib.error.HTTPError as e:
return False, "Bot API HTTP %s%s" % (e.code, e.read().decode()[:200])
except Exception as e:
return False, "Bot API 連不上:%s" % e
try:
d = json.loads(raw)
except Exception:
return False, "Bot API 回應不是 JSON%s" % raw[:200]
if d.get("ok") is True:
return True, "送到了(Bot API 直送,message_id=%s" % ((d.get("result") or {}).get("message_id"))
return False, "Bot API 回 ok=false%s" % raw[:200]
# ══ 三、退路:貼回票上 ═══════════════════════════════════════════════════
def gitea_token():
for env in ("GITEA_TOKEN_CLAUDE_CODE", "GITEA_TOKEN"):
v = os.environ.get(env)
if v:
return v
root = os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()
host = GITEA_HOST.split("//")[-1].rstrip("/")
try:
out = subprocess.run(["git", "-C", root, "remote", "-v"],
capture_output=True, text=True, timeout=20).stdout
except Exception:
out = ""
for line in out.splitlines():
if host in line:
m = re.search(r"//[^:/]+:([^@]+)@", line)
if m:
return m.group(1)
return None
def fallback_body(text, gate_fact, why=None):
"""退路留言的內文。**獨立成一支就是為了測得到**——這段的內容才是這條退路的價值。
🔴 **為什麼發不出去**一定要在裡面,不是只寫閘的判定。
2026-08-28 第一版漏了這一格:閘明明 pass,票上卻只看得到 pass,
而真正的斷點(實例上找不到 `notify_leo` 工作流)**一個字都沒進票**
⇒ 下一個人會去修閘,而斷點根本不在那裡。
"""
return "\n".join([
IDENTITY,
"",
"🔴 **這則本來要用 Telegram 發給 leo,但這個 session 發不出去**——所以貼在這裡。",
"",
"**送不出去的原因**%s" % (why or "(沒有記錄,這本身就是個 bug)"),
"",
gate_report(gate_fact),
"",
"---",
"",
text,
])
def comment_on_ticket(ref, text, gate_fact, why=None):
"""把那則發不出去的訊息貼回票上。回 (ok, 說明)。
🔴 **同一則不重複貼**:內容雜湊存在狀態目錄,24 小時內一樣就跳過。
票被洗版的下場跟發不出去一樣——leo 會學會不看它。
"""
m = re.match(r"^([\w.-]+)/([\w.-]+)#(\d+)$", (ref or "").strip())
if not m:
return False, "票的寫法是 owner/repo#N,你給的是:%s" % ref
owner, repo, num = m.group(1), m.group(2), m.group(3)
key = hashlib.sha256(("%s|%s|%s" % (ref, text, why or "")).encode()).hexdigest()[:16]
seen = os.path.join(STATE_DIR, "commented-%s" % key)
if os.path.exists(seen) and time.time() - os.path.getmtime(seen) < 24 * 3600:
return True, "24 小時內已經貼過一模一樣的內容,跳過(不洗版)"
if os.environ.get("ISEP_NOTIFY_OFFLINE") == "1":
return False, "ISEP_NOTIFY_OFFLINE=1:刻意不打網路"
tok = gitea_token()
if not tok:
return False, "拿不到 Gitea tokenGITEA_TOKEN_CLAUDE_CODE 沒設,remote 也沒帶憑證)"
body = fallback_body(text, gate_fact, why)
url = "%s/api/v1/repos/%s/%s/issues/%s/comments" % (GITEA_HOST, owner, repo, num)
req = urllib.request.Request(
url, data=json.dumps({"body": body}).encode(), method="POST",
headers={"Authorization": "token %s" % tok,
"Content-Type": "application/json"})
try:
r = json.load(urllib.request.urlopen(req, timeout=40))
except urllib.error.HTTPError as e:
return False, "Gitea %s%s" % (e.code, e.read().decode()[:200])
except Exception as e:
return False, "連不上 Gitea%s" % e
try:
os.makedirs(STATE_DIR, exist_ok=True)
open(seen, "w").close()
except Exception:
pass
return True, "已貼到 %s%s" % (ref, r.get("html_url") or "")
# ══ 四、CLI ══════════════════════════════════════════════════════════════
def notify(text, fallback_ticket=None, dry_run=False):
"""回 (離開碼, 給人看的報告)。**任何一條路都會留下一段話,不會安靜。**"""
fact = probe_gate()
remember_fact(fact)
out = [gate_report(fact), ""]
if dry_run:
out.append("🧪 --dry-run:到此為止,什麼都沒送。要送的內容是:")
out.append("")
out.append(text)
return 0, "\n".join(out)
# ① Bot API 直送:不碰任何實例,閘的判定管不到它(inkstone/ISEP#130
sent, why_bot = send_bot_api(text)
out.append(("✅ TelegramBot API 直送):%s" if sent else "⏭️ Bot API 直送:%s") % why_bot)
if sent:
return 0, "\n".join(out)
# ② 走實例上的 notify_leo 工作流(原本那條;閘說會擋就不送)
if fact["verdict"] == "block":
out.append("⛔ 閘說會擋 ⇒ **本支不繞路**,直接走退路。")
sent, why = False, "閘會擋,而繞過去等於拆了那道閘 ⇒ 沒有嘗試送出"
else:
sent, why = send_telegram(text)
out.append(("✅ Telegramnotify_leo):%s" if sent else "❌ Telegramnotify_leo):%s") % why)
why = "Bot API 直送:%snotify_leo%s" % (why_bot, why)
if sent:
return 0, "\n".join(out)
# ── 退路 ──────────────────────────────────────────────────────────
if fallback_ticket:
ok, detail = comment_on_ticket(fallback_ticket, text, fact, why)
out.append(("✅ 退路(貼回票上):%s" if ok else "❌ 退路(貼回票上):%s") % detail)
else:
ok, detail = False, "沒有給 --fallback-ticket,沒有票可以貼"
out.append("⚠️ 退路(貼回票上):%s" % detail)
out += [
"",
"🔴 **leo 的手機上沒有出現這則。** 原文照抄在下面,"
"看到這段的人有義務把它講出去:",
"",
text,
]
return (0 if ok else 1), "\n".join(out)
def main(argv):
text, ticket, dry, want_json = None, None, False, False
mode = "notify"
i = 0
while i < len(argv):
a = argv[i]
if a == "--gate":
mode = "gate"
elif a == "--text":
i += 1; text = argv[i]
elif a == "--text-file":
i += 1
with open(argv[i]) as f:
text = f.read()
elif a == "--fallback-ticket":
i += 1; ticket = argv[i]
elif a == "--dry-run":
dry = True
elif a == "--json":
want_json = True
elif a in ("-h", "--help"):
print(__doc__)
return 0
else:
print("不認得的參數:%s--help 看用法)" % a, file=sys.stderr)
return 2
i += 1
if mode == "gate":
fact = probe_gate()
remember_fact(fact)
print(json.dumps(fact, ensure_ascii=False, indent=2) if want_json
else gate_report(fact))
return 0
if not text or not text.strip():
print("🔴 沒有內容可以發(--text 或 --text-file", file=sys.stderr)
return 2
if not text.lstrip().startswith("["):
text = "[總管] " + text.lstrip() # 署名鐵律:text 開頭必署名
code, report = notify(text, ticket, dry)
print(report, file=(sys.stdout if code == 0 else sys.stderr))
return code
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))