#!/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 log:permission_denials=6)。 雲端真正讀的是薄殼 repo(GitHub)的 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:]))