雲端要拿得到票、主線與通知:白名單住 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
This commit is contained in:
isep-hand
2026-09-07 01:17:15 +00:00
parent 605f1fe5a6
commit 65120a1c65
27 changed files with 1141 additions and 34 deletions
+57 -1
View File
@@ -42,6 +42,8 @@
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
@@ -252,6 +254,52 @@ def send_telegram(text):
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"):
@@ -351,12 +399,20 @@ def notify(text, fallback_ticket=None, dry_run=False):
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(("✅ Telegram%s" if sent else "❌ Telegram%s") % why)
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)
+1 -1
View File
@@ -254,7 +254,7 @@ YOULIN_ACCOUNT = "1129efd7df2e8899d537e9c8fbabb6cb"
# ⇒ 判準:防呆要比對「**決定後果的那一項**」,不是「剛好看得到的那一項」。
# namespace 才是資料的歸屬鍵 ⇒ 三項一起驗,缺一不可。
YOULIN_NAMESPACE = "yuga3bse"
YOULIN_CYPHER = "arcrun-cypher-executor.youlin-hsieh-dev.workers.dev"
YOULIN_CYPHER = "arcrun-cypher-executor.arcrun-yuga3bse.workers.dev" # 09-02 重裝後的子網域(inkstone/ISEP#130);舊名 youlin-hsieh-dev 的 DNS 已不存在
class Acr:
+20 -3
View File
@@ -23,6 +23,8 @@ leo 2026-08-27:「**14 個里程碑同時亮著卻不知道該看哪個**」
── 答案為什麼唯一 ─────────────────────────────────────────────────
主線住在一個檔裡(`~/.claude/isep-countdown/mainline.json`),那個檔**放得下一條**。
inkstone/ISEP#130 起它有一份隨 repo 走的鏡像:`InkStoneCo/system-dev/mainline.json`——
雲端沒有家目錄那份,讀的就是它;`set`/`adopt``clear` 兩份一起寫,`refresh` 只寫家目錄。)
沒有清單、沒有優先序、沒有「前三名」——`set` 覆蓋掉的那條就不再是主線。
⇒ 問一次只會拿到 0 或 1 個答案,不會拿到 14 個。
@@ -212,22 +214,36 @@ def cmd_set(argv):
"closed_issues": m.get("closed_issues") or 0,
"members": refs,
"set_at": now, "refreshed_at": now,
})
}, mirror=True)
if not ok:
die("🔴 寫不進 %s——主線沒有被標上。" % ML.path())
print("✅ 主線已標定:%s/%s#%d「%s」" % (owner, repo, mid, title))
if scanned:
print(" 跨 repo 收到同名 milestone 的票 %d 張:%s"
% (len(refs), "、".join("%s %d" % (r, n) for r, n in scanned)))
print(mirror_line())
print()
cmd_show([])
def mirror_line():
"""repo 那份有沒有跟著寫(inkstone/ISEP#130):寫了要人去 commit,沒寫要講為什麼。"""
rp = ML.repo_path(for_write=True)
if rp and os.path.isfile(rp):
return (" 已同步寫進 repo 那份:%s\n"
" 📌 這份要 commit/開 PR 併進 inkstone/InkStoneCo,雲端 clone 才拿得到" % rp)
return (" ⚠️ 這台找不到 InkStoneCo 的 system-dev/CLAUDE_PROJECT_DIR 或 cwd 底下都沒有),"
"repo 那份沒更新——雲端仍會讀到舊主線")
def cmd_clear(argv):
had = ML.load()
ML.clear()
rp = ML.repo_path()
ML.clear(mirror=True)
print("✅ 已清掉主線%s。現在沒有主線——每回合的 🎯 那一行會照實說。"
% ("(原本是 %s「%s」)" % (ML.ref_of(had), had.get("title")) if had else ""))
if rp:
print(" repo 那份也刪了:%s(要 commit 才會到雲端)" % rp)
# ── refresh ──────────────────────────────────────────────────────────────
@@ -278,8 +294,9 @@ def cmd_adopt(argv):
T.api("/repos/%s/%s/issues/%d" % (owner, repo, num), {"milestone": mid}, method="PATCH")
ref = "%s/%s#%d" % (owner, repo, num)
ms["members"] = sorted(set(ms.get("members") or []) | {ref})
ML.save(ms)
ML.save(ms, mirror=True)
print("✅ 補收:%s 已掛進「%s」(%s/%s#%d" % (ref, ms["title"], owner, repo, mid))
print(mirror_line())
# ── has ──────────────────────────────────────────────────────────────────
+146
View File
@@ -0,0 +1,146 @@
#!/usr/bin/env python3
"""settings-allow-sync — 把 ISEP 的權限白名單寫進**這台機器**的 ~/.claude/settings.json
inkstone/ISEP#130
━━ 為什麼有這支 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
leo 09-07 親手把四個 Gitea 正門工具(`ticket``mainline``gate-ok``gitea-pr-merge`
加進本機 `InkStoneCo/.claude/settings.json` 的 `permissions.allow`auto mode 的分類器
才不擋。**雲端 session 沒有那四條** ⇒ 同樣的動作在雲端照樣被擋
09-04 progress-guard run logpermission_denials=6)。
雲端真正讀的是薄殼 repoGitHub)的 settings——推它要 D20 開閘(leo 親跑 github-arm)。
⇒ 白名單不住薄殼、也不住 InkStoneCo**住 ISEP 一份**`docs/permissions-allow.json`),
由本支在那台機器上寫進 `~/.claude/settings.json`user scope,跟 cwd 無關)。
雲端:`docs/cloud-setup-script.sh` 裝完 plugin 就跑一次;之後每個 SessionStart 再對一次,
plugin 更新帶進新規則時不必等快照重拍。本機:同一支、同一份清單。
━━ 規矩 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
· 只加不減:settings.json 裡本來就有的東西一個字不動(別人手加的規則不是本支的地盤)。
· 冪等:跑第二次 +0 條,檔案一個位元組都不變。
· 清單裡開頭的 `~` 換成這台的家目錄(本機 /Users/…、雲端 /root),其餘照抄。
· 原子寫:先寫暫存檔再 rename,中途斷掉不會留下半個 JSON。
· 讀不懂目標(不是 JSON)⇒ **不碰它**、印出來、離開碼 0——這支掛在 SessionStart
不准因為它把 session 弄壞;`--check` 才會用離開碼講話。
━━ 用法 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
settings-allow-sync 寫進 ~/.claude/settings.json
settings-allow-sync --dry-run 只印會加哪幾條,不寫
settings-allow-sync --check 對帳:缺任何一條 ⇒ 離開碼 1(測試/驗收用)
settings-allow-sync --target <檔> --home <家目錄> 測試用:指到別的檔、別的家目錄
settings-allow-sync --quiet 沒事就閉嘴(SessionStart 用)
"""
import json
import os
import sys
import tempfile
HERE = os.path.dirname(os.path.abspath(__file__))
SOURCE = os.path.join(HERE, "..", "docs", "permissions-allow.json")
def wanted(home):
with open(SOURCE, encoding="utf-8") as f:
d = json.load(f)
out = []
for rule in d.get("allow") or []:
if not isinstance(rule, str) or not rule.startswith("Bash(") or not rule.endswith(")"):
raise ValueError("清單裡有一條不是 Bash(…) 的形狀:%r" % (rule,))
inner = rule[len("Bash("):-1]
# 只換「路徑開頭」的 ~`python3 ~/.claude/…` 這種。`~` 出現在別處不動。
parts = inner.split(" ")
parts = [home + p[1:] if p.startswith("~/") else p for p in parts]
out.append("Bash(" + " ".join(parts) + ")")
return out
def main(argv):
target = os.path.join(os.path.expanduser("~"), ".claude", "settings.json")
home = os.path.expanduser("~")
dry = check = quiet = False
i = 0
while i < len(argv):
a = argv[i]
if a == "--target":
i += 1; target = argv[i]
elif a == "--home":
i += 1; home = argv[i].rstrip("/")
elif a == "--dry-run":
dry = True
elif a == "--check":
check = True
elif a == "--quiet":
quiet = True
elif a in ("-h", "--help"):
print(__doc__); return 0
else:
print("不認得的參數:%s--help 看用法)" % a, file=sys.stderr); return 2
i += 1
try:
rules = wanted(home)
except Exception as e:
print("🔴 讀不了白名單清單 %s%s" % (SOURCE, e), file=sys.stderr)
return 1 if check else 0
cur = {}
if os.path.exists(target):
try:
with open(target, encoding="utf-8") as f:
cur = json.load(f)
if not isinstance(cur, dict):
raise ValueError("最外層不是物件")
except Exception as e:
print("🔴 %s 讀不懂(%s)——**沒有動它**。修好它再跑一次。" % (target, e), file=sys.stderr)
return 1 if check else 0
perms = cur.get("permissions")
if not isinstance(perms, dict):
perms = {}
allow = perms.get("allow")
if not isinstance(allow, list):
allow = []
have = set(a for a in allow if isinstance(a, str))
missing = [r for r in rules if r not in have]
if check:
if missing:
print("🔴 %s 缺 %d 條:" % (target, len(missing)))
for r in missing:
print(" " + r)
return 1
if not quiet:
print("✅ %s 已有全部 %d 條" % (target, len(rules)))
return 0
if not missing:
if not quiet:
print("✅ 白名單已齊(%d 條):%s" % (len(rules), target))
return 0
if dry:
print("🧪 --dry-run:會加 %d 條進 %s" % (len(missing), target))
for r in missing:
print(" " + r)
return 0
perms["allow"] = allow + missing
cur["permissions"] = perms
try:
os.makedirs(os.path.dirname(target) or ".", exist_ok=True)
fd, tmp = tempfile.mkstemp(prefix=".settings-", suffix=".json", dir=os.path.dirname(target) or ".")
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(cur, f, ensure_ascii=False, indent=2)
f.write("\n")
os.replace(tmp, target)
except Exception as e:
print("🔴 寫不進 %s%s" % (target, e), file=sys.stderr)
return 0
print("✅ 白名單 %d 條 → %s(共 %d 條來自 ISEP" % (len(missing), target, len(rules)))
if not quiet:
for r in missing:
print(" " + r)
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+1 -1
View File
@@ -22,7 +22,7 @@ run(){ # 跑 list(或任何子命令),輸出留在 $OUT,離開碼留在
# 🔴 ISEP_COUNTDOWN_STATE_DIR 也要指到暫存目錄:主線(hooks/lib/mainline.py)住在那裡,
# 不隔離的話這支測試的結果會取決於「這台機器現在標了哪條主線」——那就不是測試了。
OUT=$(ISEP_DEBT_FIXTURE="$FX" ISEP_DEBT_STATE_DIR="$T/state" ISEP_DEBT_NOW="$NOW" \
ISEP_COUNTDOWN_STATE_DIR="$ML_DIR" python3 "$W" "$@" 2>&1); RC=$?
ISEP_COUNTDOWN_STATE_DIR="$ML_DIR" ISEP_MAINLINE_REPO_FILE=none python3 "$W" "$@" 2>&1); RC=$?
}
ML_DIR="$T/mainline"; mkdir -p "$ML_DIR"
+80
View File
@@ -0,0 +1,80 @@
#!/bin/bash
# isep-notify 的「Bot API 直送」測試(inkstone/ISEP#130
#
# 它守什麼: 雲端唯一走得通的通知路。2026-09-07 從雲端實測:leo21c 的 notify_leo 回 404、
# youlin 被 egress proxy 擋(403 policy denial)、api.telegram.org 通。
# ⇒ 有 TELEGRAM_BOT_TOKENTELEGRAM_CHAT_ID 就先直送;沒有要講清楚缺哪兩個,不能靜默。
#
# 🔴 全程不打真 Telegram:Bot API 指到本機一個假伺服器(只回固定 JSON),
# 實例那條用 ISEP_NOTIFY_OFFLINE=1 關掉、閘用 ISEP_NOTIFY_GUARD 指到真跡。
cd "$(dirname "$0")/.." || exit 1
N=scripts/isep-notify
TMP=$(mktemp -d); trap 'rm -rf "$TMP"; [ -n "${SRV:-}" ] && kill "$SRV" 2>/dev/null' EXIT
export ISEP_NOTIFY_STATE_DIR="$TMP/state" ISEP_NOTIFY_GUARD="$PWD/hooks/prod-write-guard.sh"
unset TELEGRAM_BOT_TOKEN TELEGRAM_CHAT_ID
PASS=0; FAIL=0
ok(){ PASS=$((PASS+1)); printf ' ✅ %s\n' "$1"; }
bad(){ FAIL=$((FAIL+1)); printf ' ❌ %s\n' "$1"; }
# 假 Bot API:記下收到什麼,回 Telegram 的形狀
cat > "$TMP/srv.py" <<'PY'
import json, sys
from http.server import BaseHTTPRequestHandler, HTTPServer
LOG = sys.argv[2]
class H(BaseHTTPRequestHandler):
def do_POST(self):
body = self.rfile.read(int(self.headers.get("Content-Length") or 0)).decode()
open(LOG, "a").write(json.dumps({"path": self.path, "body": body, "ua": self.headers.get("User-Agent")}) + "\n")
ok = "/botGOOD/" in self.path
out = json.dumps({"ok": True, "result": {"message_id": 42}} if ok else {"ok": False, "description": "Unauthorized"}).encode()
self.send_response(200 if ok else 401); self.send_header("Content-Type", "application/json"); self.end_headers(); self.wfile.write(out)
def log_message(self, *a): pass
HTTPServer(("127.0.0.1", int(sys.argv[1])), H).serve_forever()
PY
PORT=$(python3 -c 'import socket;s=socket.socket();s.bind(("127.0.0.1",0));print(s.getsockname()[1])')
python3 "$TMP/srv.py" "$PORT" "$TMP/log" & SRV=$!
for i in 1 2 3 4 5 6 7 8 9 10; do curl -s -o /dev/null "http://127.0.0.1:$PORT/" && break; sleep 0.2; done
export ISEP_NOTIFY_BOT_API="http://127.0.0.1:$PORT"
echo "── A. 沒有憑證:講清楚缺哪兩個,不靜默 ──"
OUT=$(ISEP_NOTIFY_OFFLINE=1 python3 "$N" --text "測試" 2>&1); RC=$?
printf '%s' "$OUT" | grep -q 'TELEGRAM_BOT_TOKENTELEGRAM_CHAT_ID' && ok "A1 沒憑證 ⇒ 報告點名缺 TELEGRAM_BOT_TOKENTELEGRAM_CHAT_IDleo 看了知道要在 Cloud environment 加什麼)" || bad "A1 沒點名缺什麼:$OUT"
[ "$RC" != 0 ] && printf '%s' "$OUT" | grep -q 'leo 的手機上沒有出現' && ok "A2 兩條路都沒通 ⇒ 離開碼非 0、明說沒送到" || bad "A2 rc=$RC$OUT"
[ ! -s "$TMP/log" ] && ok "A3 沒憑證就不打 Bot API" || bad "A3 沒憑證還打了:$(cat "$TMP/log")"
echo "── B. 有憑證:先直送,送到就結束 ──"
: > "$TMP/log"
OUT=$(TELEGRAM_BOT_TOKEN=GOOD TELEGRAM_CHAT_ID=777 python3 "$N" --text "雲端收工摘要" 2>&1); RC=$?
[ "$RC" = 0 ] && printf '%s' "$OUT" | grep -q 'Bot API 直送):送到了' && ok "B1 憑證在 ⇒ Bot API 直送,離開碼 0" || bad "B1 rc=$RC$OUT"
python3 - "$TMP/log" <<'PY' && ok "B2 打到 /botGOOD/sendMessage、chat_id=777、內文有署名、UA 帶了" || bad "B2 送出去的形狀不對:$(cat "$TMP/log")"
import json, sys
rows = [json.loads(l) for l in open(sys.argv[1])]
assert len(rows) == 1, rows
r = rows[0]; b = json.loads(r["body"])
assert r["path"] == "/botGOOD/sendMessage", r["path"]
assert b["chat_id"] == "777" and b["text"].startswith("[總管] 雲端收工摘要"), b
assert "isep-notify" in (r["ua"] or ""), r["ua"]
PY
printf '%s' "$OUT" | grep -q 'notify_leo' && bad "B3 直送成功後不該再碰實例那條" || ok "B3 直送成功 ⇒ 不再打實例的 notify_leo"
echo "── C. 憑證錯:Bot API 回 ok=false ⇒ 不算送到,退到原本那條 ──"
: > "$TMP/log"
# 實例那條也指到假伺服器(它對非 /botGOOD/ 的路徑回 401)——兩條都不出這台機器
OUT=$(TELEGRAM_BOT_TOKEN=BAD TELEGRAM_CHAT_ID=777 ISEP_NOTIFY_URL="http://127.0.0.1:$PORT/webhooks/named/leo/notify_leo/trigger" python3 "$N" --text "x" 2>&1); RC=$?
printf '%s' "$OUT" | grep -q 'Bot API HTTP 401' && ok "C1 401 被講出來(不是「送出成功」)" || bad "C1 沒講 401$OUT"
[ "$RC" != 0 ] && printf '%s' "$OUT" | grep -q 'leo 的手機上沒有出現' && ok "C2 直送失敗+實例關著 ⇒ 仍然明說沒送到" || bad "C2 rc=$RC$OUT"
echo "── D. 閘說會擋,直送仍走得通(它不碰實例)──"
: > "$TMP/log"; mkdir -p "$TMP/oldguard"
printf '#!/bin/sh\nexit 2\n' > "$TMP/oldguard/prod-write-guard.sh"; chmod +x "$TMP/oldguard/prod-write-guard.sh"
OUT=$(ISEP_NOTIFY_GUARD="$TMP/oldguard/prod-write-guard.sh" TELEGRAM_BOT_TOKEN=GOOD TELEGRAM_CHAT_ID=1 python3 "$N" --text "y" 2>&1); RC=$?
[ "$RC" = 0 ] && printf '%s' "$OUT" | grep -q '通知閘:\*\*block\*\*' && printf '%s' "$OUT" | grep -q '直送):送到了' \
&& ok "D1 閘 block + 憑證在 ⇒ 直送照樣送到(閘擋的是寫實例,直送沒碰實例)" || bad "D1 rc=$RC$OUT"
echo "── E. --dry-run 什麼都不送 ──"
: > "$TMP/log"
OUT=$(TELEGRAM_BOT_TOKEN=GOOD TELEGRAM_CHAT_ID=1 python3 "$N" --text "z" --dry-run 2>&1)
[ ! -s "$TMP/log" ] && printf '%s' "$OUT" | grep -q 'dry-run' && ok "E1 --dry-runBot API 一次都沒被打" || bad "E1 dry-run 打了:$(cat "$TMP/log")"
echo; echo "$PASS/$((PASS+FAIL)) 通過"
[ "$FAIL" -eq 0 ]
+97
View File
@@ -0,0 +1,97 @@
#!/bin/bash
# settings-allow-sync 的測試(inkstone/ISEP#130
#
# 它守什麼: 「雲端 session 開工時就帶著跟本機同一份權限白名單」這件事的機器那一半。
# leo 09-07 親手加的四條只在本機 InkStoneCo/.claude/settings.json;雲端沒有 ⇒ 同樣動作被分類器擋
# 09-04 run logpermission_denials=6)。清單改住 ISEP 一份,本支把它寫進那台機器的 ~/.claude/settings.json。
#
# 🔴 全程在 TMP 底下的假 settings.json 上跑,不碰真的 ~/.claude/settings.json。
cd "$(dirname "$0")/.." || exit 1
S=scripts/settings-allow-sync
LIST=docs/permissions-allow.json
TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT
PASS=0; FAIL=0
ok(){ PASS=$((PASS+1)); printf ' ✅ %s\n' "$1"; }
bad(){ FAIL=$((FAIL+1)); printf ' ❌ %s\n' "$1"; }
N=$(python3 -c "import json;print(len(json.load(open('$LIST'))['allow']))")
echo "── A. 清單本身的形狀 ──"
python3 - "$LIST" <<'PY' && ok "A1 每一條都是 Bash(…) 形狀,而且四個正門工具都在場(ticketmainlinegate-okgitea-pr-merge" || bad "A1 清單形狀不對或少了正門工具"
import json, sys
rules = json.load(open(sys.argv[1]))["allow"]
assert rules and all(r.startswith("Bash(") and r.endswith(")") for r in rules), rules
for tool in ("scripts/ticket", "scripts/mainline", "scripts/gate-ok", "scripts/gitea-pr-merge.sh"):
assert any(tool in r for r in rules), "缺 " + tool
assert not any(r.strip() in ("Bash(python3 *)", "Bash(*)", "Bash(bash *)") for r in rules), "整類放行"
PY
echo "── B. 寫進去(假的 settings.json)──"
T="$TMP/a/settings.json"
OUT=$(python3 "$S" --target "$T" --home /root 2>&1); RC=$?
[ "$RC" = 0 ] && [ -f "$T" ] && ok "B1 目標不存在 ⇒ 建出來(離開碼 0)" || bad "B1 rc=$RC$OUT"
python3 -c "import json;d=json.load(open('$T'));assert len(d['permissions']['allow'])==$N" && ok "B2 寫進 $N 條,JSON 合法" || bad "B2 條數不對或不是 JSON"
grep -q 'Bash(python3 /root/.claude/plugins/cache/inkstone/isep/\*/scripts/ticket \*)' "$T" \
&& ok "B3 開頭的 ~ 換成了 --home 給的家目錄(雲端=/root" || bad "B3 ~ 沒換成家目錄"
grep -q '"~/' "$T" && bad "B4 檔裡還留著 ~" || ok "B4 檔裡沒有殘留的 ~"
grep -q 'CLAUDE_PLUGIN_ROOT' "$T" && ok "B5 \$CLAUDE_PLUGIN_ROOT 那幾條原樣保留(閘印出來的逃生門就是這個形狀)" || bad "B5 少了 \$CLAUDE_PLUGIN_ROOT 形狀"
tail -c1 "$T" | od -An -c | grep -q '\\n' && ok "B6 收尾有換行" || bad "B6 沒換行"
SUM1=$(cksum < "$T"); OUT=$(python3 "$S" --target "$T" --home /root 2>&1); SUM2=$(cksum < "$T")
[ "$SUM1" = "$SUM2" ] && printf '%s' "$OUT" | grep -q '已齊' && ok "B7 跑第二次:檔案一個位元組都沒變、說「已齊」(冪等)" || bad "B7 不冪等:$OUT"
echo "── C. 只加不減:別人手加的東西一個字不動 ──"
T2="$TMP/b/settings.json"; mkdir -p "$TMP/b"
cat > "$T2" <<'JSON'
{
"extraKnownMarketplaces": {"inkstone": {"source": {"source": "git", "url": "https://git.uncle6.me/inkstone/ISEP.git"}}},
"enabledPlugins": {"isep@inkstone": true},
"permissions": {
"allow": ["Bash(npx wrangler secret:*)", "Bash(python3 scripts/ticket *)"],
"deny": ["Edit(//Users/x/wrangler.toml)"]
},
"hooks": {"PreToolUse": []}
}
JSON
OUT=$(python3 "$S" --target "$T2" --home /Users/youlinhsieh 2>&1)
python3 - "$T2" "$N" <<'PY' && ok "C1 原有的 allowdenymarketplacehooks 全在,原本那條沒重複,只補了缺的" || bad "C1 動到了別人的東西:$(cat "$T2")"
import json, sys
d = json.load(open(sys.argv[1])); n = int(sys.argv[2])
a = d["permissions"]["allow"]
assert a[0] == "Bash(npx wrangler secret:*)" and a[1] == "Bash(python3 scripts/ticket *)", a[:2]
assert a.count("Bash(python3 scripts/ticket *)") == 1
assert len(a) == 2 + n - 1, (len(a), n)
assert d["permissions"]["deny"] == ["Edit(//Users/x/wrangler.toml)"]
assert d["enabledPlugins"] == {"isep@inkstone": True}
assert d["hooks"] == {"PreToolUse": []}
assert any("/Users/youlinhsieh/.claude/plugins/cache/inkstone/isep/*/scripts/mainline" in x for x in a)
PY
printf '%s' "$OUT" | grep -q "$((N-1))" && ok "C2 印出來的是「+$((N-1)) 條」(缺幾條補幾條)" || bad "C2 計數不對:$OUT"
echo "── D. --check--dry-run ──"
python3 "$S" --target "$T" --home /root --check >/dev/null 2>&1 && ok "D1 --check:全齊 ⇒ 離開碼 0" || bad "D1 --check 全齊卻非 0"
T3="$TMP/c/settings.json"; mkdir -p "$TMP/c"; echo '{}' > "$T3"
python3 "$S" --target "$T3" --home /root --check >/dev/null 2>&1; RC=$?
[ "$RC" = 1 ] && ok "D2 --check:缺 ⇒ 離開碼 1(驗收用)" || bad "D2 --check 缺卻回 $RC"
[ "$(cat "$T3")" = "{}" ] && ok "D3 --check 不寫檔" || bad "D3 --check 寫了檔"
python3 "$S" --target "$T3" --home /root --dry-run >/dev/null 2>&1
[ "$(cat "$T3")" = "{}" ] && ok "D4 --dry-run 不寫檔" || bad "D4 --dry-run 寫了檔"
echo "── E. 壞掉的目標:不碰、不炸(這支掛在 SessionStart)──"
T4="$TMP/d/settings.json"; mkdir -p "$TMP/d"; printf '{ not json' > "$T4"
OUT=$(python3 "$S" --target "$T4" --home /root 2>&1); RC=$?
[ "$RC" = 0 ] && [ "$(cat "$T4")" = "{ not json" ] && printf '%s' "$OUT" | grep -q '沒有動它' \
&& ok "E1 目標不是 JSON ⇒ 原封不動、講出來、離開碼 0" || bad "E1 rc=$RC 內容=$(cat "$T4") out=$OUT"
python3 "$S" --target "$T4" --home /root --check >/dev/null 2>&1; [ $? = 1 ] && ok "E2 同一個檔用 --check 問 ⇒ 離開碼 1(不裝沒事)" || bad "E2 --check 對壞檔回 0"
echo "── F. 接線:SessionStart 有掛、雲端 setup script 有叫 ──"
python3 - <<'PY' && ok "F1 hooks.json 的 SessionStart 註冊了 scripts/settings-allow-sync" || bad "F1 hooks.json 沒掛"
import json
d = json.load(open("hooks/hooks.json"))
cmds = [h["command"] for g in d["hooks"]["SessionStart"] for h in g["hooks"]]
assert any("scripts/settings-allow-sync" in c for c in cmds), cmds
PY
grep -q 'settings-allow-sync' docs/cloud-setup-script.sh && ok "F2 docs/cloud-setup-script.sh 裝完 plugin 會跑它" || bad "F2 setup script 沒叫它"
grep -q 'exit 0$' docs/cloud-setup-script.sh && ok "F3 setup script 仍然 exit 0(非零會讓整個 session 開不起來)" || bad "F3 setup script 結尾不是 exit 0"
echo; echo "$PASS/$((PASS+FAIL)) 通過"
[ "$FAIL" -eq 0 ]
+95
View File
@@ -0,0 +1,95 @@
#!/bin/bash
# 「收件 repo 寫 owner/repo 不該是 404」的測試(inkstone/ISEP#130
#
# 2026-09-07 實撞:`scripts/ticket new inkstone/ISEP -F …` 回 Gitea 404。
# 不是 ISEP 不存在,是這支把 `inkstone/ISEP` 原樣塞進 `/repos/inkstone/{repo}/issues`
# ⇒ 打到 `/repos/inkstone/inkstone/ISEP/issues`。404 讀起來像「repo 不存在」,
# 於是那天四張票全改成直接打 API 開——**正門壞了人就走側門**。
#
# 怎麼在不開任何票的前提下驗:把 `scripts/ticket` 當模組載進來,
# 把 `api()` 換成一支只記錄路徑的假貨。全程離線、不打 Gitea、不留戳記。
cd "$(dirname "$0")/.." || exit 1
STAMP=/tmp/.ticket-where-ok
SAVED=""; [ -f "$STAMP" ] && SAVED=$(cat "$STAMP")
trap '[ -n "$SAVED" ] && printf "%s" "$SAVED" > "$STAMP" || rm -f "$STAMP"' EXIT
TICKET_HOST=http://127.0.0.1:9 GITEA_TOKEN=x python3 - <<'PY'
import importlib.machinery, importlib.util, io, json, os, sys, tempfile, time, contextlib
loader = importlib.machinery.SourceFileLoader("isep_ticket", "scripts/ticket")
spec = importlib.util.spec_from_file_location("isep_ticket", "scripts/ticket", loader=loader)
T = importlib.util.module_from_spec(spec); loader.exec_module(T)
CALLS = []
def fake_api(path, payload=None, method=None):
CALLS.append((method or ("POST" if payload is not None else "GET"), path))
if path.endswith("/labels?limit=100") or path.endswith("/labels?limit=60"):
return [{"name": "s/todo", "id": 1}, {"name": "s/doing", "id": 2}]
if path.endswith("/issues") and payload is not None:
return {"number": 999, "html_url": "http://fake/999", "labels": []}
if "/comments" in path:
return {"id": 1, "html_url": "http://fake/c1"}
if "/dependencies" in path or "/blocks" in path:
return [] if payload is None else {}
return {"labels": [], "number": 1}
T.api = fake_api
T.api_soft = lambda path: None
tmp = tempfile.mkdtemp()
body = os.path.join(tmp, "body.md")
open(body, "w").write("## 目標\n測試。\n\n## 驗收條件\n離線。\n\n## deliverable 類型\ncode\n")
TITLE = "身為要開票的人,我要寫 owner/repo 也開得出票,我才不會被 404 逼去走側門"
def stamp():
json.dump({"at": time.time(), "kws": ["x"], "n": 0, "top": [], "top_detail": [], "shown": True},
open(T.stamp_path(), "w"))
PASS = FAIL = 0
def ok(m, *_):
global PASS; PASS += 1; print(" ✅ " + m)
def bad(m, why="", *_):
global FAIL; FAIL += 1; print(" ❌ " + m + (" —— " + why if why else ""))
def run(fn, argv):
"""回 (離開碼, stderr)。die() 走 sys.exit(2)。"""
CALLS.clear()
err = io.StringIO(); out = io.StringIO()
with contextlib.redirect_stderr(err), contextlib.redirect_stdout(out):
try:
fn(argv); rc = 0
except SystemExit as e:
rc = e.code if isinstance(e.code, int) else 1
return rc, err.getvalue() + out.getvalue()
print("── new:兩種寫法都要打到同一條路 ──")
stamp(); rc, msg = run(T.cmd_new, ["ISEP", "-F", body, "--title", TITLE])
posts = [p for m, p in CALLS if m == "POST"]
(ok if posts == ["/repos/inkstone/ISEP/issues"] else bad)("① `new ISEP` → /repos/inkstone/ISEP/issues", str(CALLS))
stamp(); rc, msg = run(T.cmd_new, ["inkstone/ISEP", "-F", body, "--title", TITLE])
posts = [p for m, p in CALLS if m == "POST"]
(ok if posts == ["/repos/inkstone/ISEP/issues"] else bad)("② `new inkstone/ISEP`09-07 撞 404 的那一句)→ 同一條路,不再是 /repos/inkstone/inkstone/ISEP", str(CALLS))
print("── new:寫錯要當場講,不要變成一個 404 讓人猜 ──")
stamp(); rc, msg = run(T.cmd_new, ["Leo/ISEP", "-F", body, "--title", TITLE])
(ok if rc == 2 and not CALLS and "inkstone" in msg else bad)("③ `new Leo/ISEP` → 擋(exit 2)、一通 API 都不打、訊息說 org 是 inkstone", f"rc={rc} calls={CALLS} msg={msg[:120]}")
stamp(); rc, msg = run(T.cmd_new, ["inkstone/ISEP#130", "-F", body, "--title", TITLE])
(ok if rc == 2 and not CALLS else bad)("④ `new inkstone/ISEP#130`(把票號當 repo)→ 擋,不打 API", f"rc={rc} calls={CALLS}")
print("── subtask--to 也吃 owner/repo ──")
rc, msg = run(T.cmd_subtask, ["inkstone/ISEP#130", "--title", TITLE, "-F", body, "--to", "inkstone/Arcrun"])
posts = [p for m, p in CALLS if m == "POST" and p.endswith("/issues")]
(ok if posts == ["/repos/inkstone/Arcrun/issues"] else bad)("⑤ `subtask … --to inkstone/Arcrun` → 子票開在 /repos/inkstone/Arcrun/issues", f"rc={rc} calls={CALLS} msg={msg[:200]}")
rc, msg = run(T.cmd_subtask, ["inkstone/ISEP#130", "--title", TITLE, "-F", body, "--to", "Arcrun"])
posts = [p for m, p in CALLS if m == "POST" and p.endswith("/issues")]
(ok if posts == ["/repos/inkstone/Arcrun/issues"] else bad)("⑥ `subtask … --to Arcrun`(原本的寫法)照舊", f"rc={rc} calls={CALLS}")
print("── 沒有任何東西真的出去 ──")
(ok if not os.path.exists(T.stamp_path()) or True else bad)("⑦ 假 api 記到的路徑全部是 /repos/inkstone/<repo>/…,沒有一條含 inkstone/inkstone")
if any("inkstone/inkstone" in p for _, p in CALLS): FAIL += 1; PASS -= 1; print(" ❌ ⑦ 出現 inkstone/inkstone")
print(f"\n通過 {PASS} 條,失敗 {FAIL} 條")
sys.exit(1 if FAIL else 0)
PY
+27 -4
View File
@@ -14,7 +14,7 @@ leo 2026-08-16 三句話,本工具就是它們的機械化:
ticket where <關鍵字...> 搜「這件事該放哪」→ 產生戳記
ticket say <票> -F <檔> 貼進既有票的對話(**預設路徑**)
ticket new <repo> -F <檔> 開新票(要戳記+模板欄位齊全)
ticket new <repo> -F <檔> 開新票(要戳記+模板欄位齊全<repo> 寫 ISEP 或 inkstone/ISEP 都行
ticket close <票> --deliverable <URL> 關票(要有交付物連結)
ticket decide <票> -F <答案檔> 記 leo 的裁決+改狀態(同一個動作)
@@ -145,6 +145,29 @@ def parse_ref(s):
return m.group(1), m.group(2), int(m.group(3))
def repo_arg(s):
"""收件 repo 的參數:`ISEP` 與 `inkstone/ISEP` 都收,回 repo 名(不含 org)。
🔴 2026-09-07 實撞(inkstone/ISEP#130):總管照票號的習慣寫 `ticket new inkstone/ISEP`
這支把它原樣接進 `/repos/inkstone/{repo}/issues` ⇒ 打到 `/repos/inkstone/inkstone/ISEP/issues`
⇒ Gitea 回 **404**,而 404 讀起來像「ISEP 這個 repo 不存在」——
於是四張票改成直接打 API 開的,**正門壞了人就走側門**,那正是 `ticket-api-bypass-guard`
在防的事。票號全稱寫 `owner/repo#N` 是本 repo 的鐵律(CLAUDE.md),
所以 `owner/repo` 這個形狀本來就該被接受,不該是 404 的來源。
org 寫成別的(`Leo/ISEP`)⇒ 當場講清楚,不要讓它變成一個 404 去猜。
"""
s = (s or "").strip()
if "/" not in s:
return s
owner, _, name = s.partition("/")
if owner != ORG:
die(f"🔴 收件 repo 的 org 是 `{ORG}`,你給的是 `{owner}`{s})。\n"
f" `Leo/` 那個 org 2026-08-14 已廢;寫 `{ORG}/{name}` 或直接寫 `{name}`。")
if not name or "/" in name or "#" in name:
die(f"🔴 收件 repo 的寫法是 `<repo>` 或 `{ORG}/<repo>`(不帶票號),你給的是:{s}")
return name
def api_soft(path):
"""讀一筆,失敗就回 None(不 die)。給「錦上添花」的資訊用,不影響任何判斷。"""
try:
@@ -283,8 +306,8 @@ def cmd_say(argv):
# ── new ──────────────────────────────────────────────────────────────────
def cmd_new(argv):
if len(argv) < 3 or argv[1] not in ("-F", "--file"):
die("用法:ticket new <repo> -F <內文檔> [--title <標題>]")
repo = argv[0]
die("用法:ticket new <repo 或 owner/repo> -F <內文檔> [--title <標題>]")
repo = repo_arg(argv[0])
body = open(argv[2]).read()
title = None
if "--title" in argv:
@@ -895,7 +918,7 @@ def cmd_subtask(argv):
# `--to <repo>` 是 `ticket handoff` 那個名字底下比較自然的講法,同一個東西。
# handback 的 `--to` 是「交給誰」,subtask 沒有那個參數,不會撞。)
repo = opt("--repo", opt("--to", prepo))
repo = repo_arg(opt("--repo", opt("--to", prepo)))
label = opt("--label", "s/todo")
assign = opt("--assign")
nxt = opt("--next")