跳转至

hook设置

hook的意义

  • 强制了模型必须采取这个行为。全局 CLAUDE.md 只是语言上限定,某种意义上来说是建议,而只有写进 hook 里面的才是必然会执行的
  • 本套 hook 体系把"需要用户决定/可截断"的决策点全部 GUI 化(tkinter 弹窗),避免在终端文本流中操作
  • 所有脚本位于 C:\Users\YWH18\.claude\hooks\,配置在 C:\Users\YWH18\.claude\settings.jsonhooks

关键经验

  • PyInstaller 打包的 exe 在 Claude Code 真实 hook 子进程环境里读不到 stdin(无论 console 还是 noconsole 模式),表现为:手动 echo JSON | exe 能弹窗,但 Claude Code spawn 时读空 → 无决策输出 → 不弹窗
  • 唯一可靠方案:Python 解释器直跑 .py"D:/APP/python312/python.exe" <脚本路径>),已实测全部通过
  • exe 文件保留为历史备份,不再引用

当前 settings.json 配置

完整配置与 C:\Users\YWH18\.claude\settings.json 逐字一致; env.ANTHROPIC_AUTH_TOKEN 为真实凭证,已脱敏为占位符 YOUR_API_KEY

{
  "hooks": {
    "Elicitation": [
      {
        "hooks": [
          {
            "command": "D:/APP/python312/python.exe C:/Users/YWH18/.claude/hooks/elicitation_dialog.py",
            "timeout": 300,
            "type": "command"
          }
        ],
        "matcher": ".*"
      }
    ],
    "Notification": [
      {
        "hooks": [
          {
            "command": "D:/APP/python312/python.exe C:/Users/YWH18/.claude/hooks/idle_notify.py",
            "timeout": 90,
            "type": "command"
          }
        ],
        "matcher": "idle_prompt"
      },
      {
        "hooks": [
          {
            "command": "D:/APP/python312/python.exe C:/Users/YWH18/.claude/hooks/notify_panel.py",
            "timeout": 300,
            "type": "command"
          }
        ],
        "matcher": "permission_prompt"
      }
    ],
    "PermissionRequest": [
      {
        "hooks": [
          {
            "command": "D:/APP/python312/python.exe C:/Users/YWH18/.claude/hooks/plan_dialog.py",
            "timeout": 300,
            "type": "command"
          }
        ],
        "matcher": "(ExitPlanMode|setMode)"
      }
    ],
    "PreToolUse": [
      {
        "hooks": [
          {
            "command": "D:/APP/python312/python.exe C:/Users/YWH18/.claude/hooks/block_builtin_web.py",
            "timeout": 30,
            "type": "command"
          }
        ],
        "matcher": "(WebSearch|WebFetch)"
      },
      {
        "hooks": [
          {
            "command": "D:/APP/python312/python.exe C:/Users/YWH18/.claude/hooks/decision_dialog.py",
            "timeout": 300,
            "type": "command"
          }
        ],
        "matcher": "^(Bash|Edit|Write|MultiEdit|NotebookEdit|Patch|TodoWrite|Agent|Task|AskUserQuestion|mcp__.*)$"
      }
    ]
  },
  "includeCoAuthoredBy": false,
  "model": "opus",
  "permissions": {
    "allow": [
      "Read",
      "Edit",
      "Write",
      "MultiEdit",
      "NotebookEdit",
      "NotebookRead",
      "mcp__agnes-vision__*",
      "mcp__github__*",
      "mcp__tavily__*",
      "Glob",
      "Grep",
      "TaskCreate",
      "TaskUpdate",
      "TaskGet",
      "TaskList",
      "TaskStop",
      "TaskOutput",
      "Bash(git *)",
      "Bash(conda *)",
      "Bash(conda.exe *)",
      "Bash(curl:*)",
      "Bash(claude mcp *)",
      "Bash(python *)",
      "Bash(python3 *)",
      "Bash(node *)",
      "Bash(npx *)",
      "Bash(ls *)",
      "Bash(dir *)",
      "Bash(cat *)",
      "Bash(head *)",
      "Bash(tail *)",
      "Bash(wc *)",
      "Bash(sort *)",
      "Bash(uniq *)",
      "Bash(grep *)",
      "Bash(rg *)",
      "Bash(cut *)",
      "Bash(tr *)",
      "Bash(tee *)",
      "Bash(find *)",
      "Bash(gcc *)",
      "Bash(g++ *)",
      "Bash(which *)",
      "Bash(where *)",
      "Bash(type *)",
      "Bash(date *)",
      "Bash(cal *)",
      "Bash(bc *)",
      "Bash(du *)",
      "Bash(df *)",
      "Bash(basename *)",
      "Bash(dirname *)",
      "Bash(realpath *)",
      "Bash(readlink *)",
      "Bash(uname *)",
      "Bash(env *)",
      "Bash(export *)",
      "Bash(printenv *)",
      "Bash(whoami *)",
      "Bash(id *)",
      "Bash(hostname *)",
      "Bash(nproc *)",
      "Bash(xargs *)",
      "Bash(sleep *)",
      "Bash(yes *)",
      "Bash(true *)",
      "Bash(false *)",
      "Bash(clear *)",
      "Bash(tput *)",
      "Bash(stty *)",
      "Bash(history *)",
      "Bash(echo *)",
      "Bash(printf *)",
      "Bash(pwd *)",
      "Bash(cd *)",
      "Bash(mkdir *)",
      "Bash(cp *)",
      "Bash(mv *)",
      "Bash(touch *)",
      "Bash(zip *)",
      "Bash(unzip *)",
      "Bash(tar *)",
      "Bash(gzip *)",
      "Bash(gunzip *)",
      "Bash(bunzip2 *)",
      "Bash(xz *)",
      "Bash(curl *)",
      "Bash(wget *)",
      "Bash(diff *)",
      "Bash(cmp *)",
      "Bash(ps *)",
      "Bash(top *)",
      "Bash(htop *)",
      "Bash(jobs *)",
      "Bash(ollama *)",
      "Bash(sqlite3 *)",
      "Bash(set *)",
      "Bash(start *)",
      "Bash(timeout *)",
      "Bash(tasklist *)",
      "Bash(taskkill *)",
      "Bash(setx *)",
      "Bash(reg query *)",
      "Bash(powercfg *)",
      "Bash(PYTHONIOENCODING=utf-8 *)",
      "Bash(ping *)",
      "Bash(nslookup *)",
      "Bash(dig *)",
      "Bash(host *)",
      "Bash(traceroute *)",
      "Bash(tracert *)",
      "Bash(ss *)",
      "Bash(netstat *)",
      "Bash(ipconfig *)",
      "Bash(getmac *)",
      "Bash(file *)",
      "Bash(stat *)",
      "Bash(ln *)",
      "Bash(awk *)",
      "Bash(sed *)",
      "Bash(paste *)",
      "Bash(join *)",
      "Bash(column *)",
      "Bash(od *)",
      "Bash(hexdump *)",
      "Bash(xxd *)",
      "Bash(explorer *)"
    ],
    "defaultMode": "acceptEdits"
  },
}

共享 UI 内核

dialog_ui.py — 供各 hook 脚本复用的弹窗渲染库。统一视觉体系:彩虹条 / 徽章 / 卡片 / 等宽详情框 / 按钮 / 置顶防遮挡 / 居中,并内置选择题组(AskUserQuestion)、表单字段(Elicitation)、统一日志(hook_error.log)。

#!/usr/bin/env python3
"""共享弹窗 UI 内核 - 供各 hook 脚本复用。

统一视觉:彩虹条 / 徽章 / 卡片 / 等宽详情框 / 按钮 / 置顶防遮挡 / 居中。
各钩子只负责"展示什么、点了做什么",这里负责"怎么展示"。
额外提供:单选/多选按钮组(AskUserQuestion)、表单字段(Elicitation)、统一日志。
"""

import sys
import os
import datetime
import tkinter as tk

# 隐藏 Windows 控制台窗口(所有钩子共用)
if sys.platform == 'win32':
    try:
        import ctypes
        ctypes.windll.user32.ShowWindow(
            ctypes.windll.kernel32.GetConsoleWindow(), 0)
    except Exception:
        pass

# ── 配色 ─────────────────────────────────────────────────────────────────
BG      = '#faf7ff'
CARD_BG = '#f5eeff'
CARD_BD = '#ddd0f0'
CODE_BG = '#f0eaf8'
CODE_BD = '#d5c5e0'
DIV     = '#e8d8f0'
TEXT    = '#3d2460'
SUB     = '#6b4f8a'
CODE_FG = '#4a3070'
ACC     = '#9c6cd4'
DEN     = '#c2788a'
DEN_HV  = '#b0687a'
MEM_HV  = '#8a5dc0'
OK_BG   = '#7db9a0'
WARN_BG = '#e74c3c'

UI   = 'Segoe UI'
MONO = 'Consolas'

_RAINBOW = ['#fda4af', '#fdba74', '#fde68a',
            '#a7f3d0', '#93c5fd', '#c4b5fd', '#f9a8d4']

# ── 日志:所有 hook 共用,写 __file__ 同目录的 hook_error.log ──────────────
_LOG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)),
                         'hook_error.log')


def log(message, *parts):
    """追加一行错误/调试日志。失败也不抛,避免拖垮主流程。"""
    try:
        stamp = datetime.datetime.now().isoformat(timespec='seconds')
        line = stamp + ' ' + message + \
            (' ' + ' '.join(str(p) for p in parts) if parts else '') + '\n'
        with open(_LOG_PATH, 'a', encoding='utf-8') as f:
            f.write(line)
    except Exception:
        pass


class Dialog:
    """一个弹窗。

    用法:
        d = Dialog('标题', badge='bash', badge_bg=ACC)
        d.add_desc('要做什么')
        d.add_code('命令详情')
        d.add_button('同意', on_click=lambda: allow())
        d.run(btn_ok)
    """

    def __init__(self, title='Claude Code', badge='', badge_bg=ACC,
                 width_min=460):
        self.root = tk.Tk()
        self.root.title(title)
        self.root.resizable(False, False)
        self.root.configure(bg=BG)

        # 顶部彩虹条
        rainbow = tk.Canvas(self.root, height=3, bg=BG, highlightthickness=0)
        rainbow.pack(fill='x')

        def _draw(event=None):
            rainbow.delete('all')
            w = rainbow.winfo_width() or width_min
            seg = w / len(_RAINBOW)
            for i, c in enumerate(_RAINBOW):
                rainbow.create_rectangle(
                    i * seg, 0, (i + 1) * seg, 3, fill=c, outline='')
        rainbow.bind('<Configure>', _draw)

        # 标题栏
        head = tk.Frame(self.root, bg=BG)
        head.pack(fill='x', padx=16, pady=(10, 0))
        if badge:
            tk.Label(head, text=f' {badge} ', bg=badge_bg, fg='#ffffff',
                     font=(UI, 9, 'bold'), padx=6, pady=2).pack(side='left')
        self._title_lbl = tk.Label(head, text='', bg=BG, fg=TEXT,
                                   font=(UI, 11, 'bold'))
        self._title_lbl.pack(side='left', pady=2)
        self._right = tk.Label(head, text='', bg=BG, fg=SUB, font=(UI, 10))
        self._right.pack(side='right', pady=2)

        tk.Frame(self.root, bg=DIV, height=1).pack(fill='x', padx=16,
                                                   pady=(6, 8))
        self._max_w = int(self.root.winfo_screenwidth() * 0.6)
        self._max_h = int(self.root.winfo_screenheight() * 0.65)
        self._wrap_targets = []
        self._closed = False

    def set_title(self, text):
        self._title_lbl.configure(text=f'  {text}')

    def set_right(self, text, color=None):
        self._right.configure(text=text, fg=color or SUB)

    # ── 区块 ──────────────────────────────────────────────────────────────

    def add_desc(self, text):
        """描述卡片(粗体)"""
        if not text:
            return None
        f = tk.Frame(self.root, bg=CARD_BG, highlightbackground=CARD_BD,
                     highlightthickness=1)
        f.pack(fill='x', padx=16, pady=(0, 6))
        lbl = tk.Label(f, text=f'\U0001f380  {text}', bg=CARD_BG, fg=TEXT,
                       font=(UI, 11, 'bold'), padx=10, pady=6, justify='left')
        lbl.pack(fill='x')
        self._wrap_targets.append(lbl)
        return lbl

    def add_text(self, text, fg=SUB, font_size=9):
        """普通说明文字"""
        if not text:
            return None
        lbl = tk.Label(self.root, text=text, bg=BG, fg=fg,
                       font=(UI, font_size), padx=16, pady=4,
                       justify='left', anchor='w')
        lbl.pack(fill='x')
        self._wrap_targets.append(lbl)
        return lbl

    def add_code(self, text, max_lines=8):
        """等宽详情框(带滚动)"""
        if not text:
            return None
        f = tk.Frame(self.root, bg=CODE_BG, highlightbackground=CODE_BD,
                     highlightthickness=1)
        f.pack(fill='x', padx=16, pady=(0, 4))
        lines = text.count('\n') + 1
        box = tk.Text(f, font=(MONO, 10), fg=CODE_FG, bg=CODE_BG,
                      wrap='char', height=min(lines, max_lines), bd=0,
                      padx=10, pady=8, cursor='arrow', relief='flat',
                      highlightthickness=0, takefocus=0)
        box.insert('1.0', text)
        box.configure(state='disabled')
        box.pack(side='left', fill='both', expand=True)
        if lines > max_lines:
            sb = tk.Scrollbar(f, command=box.yview, bd=0,
                              troughcolor=CODE_BG, activebackground=ACC,
                              elementborderwidth=0, highlightthickness=0)
            sb.pack(side='right', fill='y')
            box.configure(yscrollcommand=sb.set)
        return box

    def add_button(self, text, on_click, primary=False, color=ACC,
                   hover=MEM_HV, side='right', padx=(6, 0)):
        """按钮;primary=True 采用描边轮廓样式(焦点按钮)"""
        btn = tk.Button(
            self._btn_row(),
            text=text, command=on_click,
            bg=BG if primary else color,
            fg=ACC if primary else '#ffffff',
            font=(UI, 10),
            activebackground=CARD_BG if primary else hover,
            activeforeground=ACC if primary else '#ffffff',
            relief='flat', padx=18 if primary else 14, pady=6,
            cursor='hand2', bd=0,
            highlightthickness=1 if primary else 0,
            highlightbackground=ACC if primary else color)
        btn.pack(side=side, padx=padx)
        return btn

    def _btn_row(self):
        if not hasattr(self, '_bf'):
            self._bf = tk.Frame(self.root, bg=BG)
            self._bf.pack(fill='x', padx=16, pady=(6, 10))
        return self._bf

    # ── 选择题组(AskUserQuestion 用)──────────────────────────────────────

    def add_question(self, question, header, options, multi_select):
        """渲染一道选择题,返回取值函数 getter()。

        options: [{"label": str, "description": str?}]。
        getter() 返回选中 label;多选以英文逗号连接。未选返回 ''。
        """
        qf = tk.Frame(self.root, bg=CARD_BG,
                      highlightbackground=CARD_BD, highlightthickness=1)
        qf.pack(fill='x', padx=16, pady=(0, 6))

        # header 小标题
        if header:
            tk.Label(qf, text=header, bg=CARD_BG, fg=ACC,
                     font=(UI, 9, 'bold'), padx=10,
                     anchor='w').pack(fill='x', pady=(6, 0))
        # 问题正文
        q_lbl = tk.Label(qf, text=question, bg=CARD_BG, fg=TEXT,
                         font=(UI, 11, 'bold'), padx=10, pady=2,
                         justify='left', wraplength=self._max_w - 52)
        q_lbl.pack(fill='x')
        self._wrap_targets.append(q_lbl)

        opts_f = tk.Frame(qf, bg=CARD_BG)
        opts_f.pack(fill='x', padx=10, pady=(0, 8))

        chosen = {}            # 单选: {'v': label};多选: {label: BoolVar}
        var = tk.StringVar(value='')   # 单选共享

        for idx, opt in enumerate(options):
            label = opt.get('label', '')
            desc = opt.get('description', '')
            text = label + (f' - {desc}' if desc else '')
            if multi_select:
                bv = tk.BooleanVar(value=False)
                chosen[label] = bv
                cb = tk.Checkbutton(opts_f, text=text, variable=bv,
                                    bg=CARD_BG, fg=TEXT, selectcolor=CODE_BG,
                                    activebackground=CARD_BG,
                                    activeforeground=TEXT,
                                    font=(UI, 10), anchor='w',
                                    padx=4, pady=3)
                cb.pack(fill='x', anchor='w')
            else:
                rb = tk.Radiobutton(opts_f, text=text, variable=var,
                                    value=label, bg=CARD_BG, fg=TEXT,
                                    selectcolor=CODE_BG,
                                    activebackground=CARD_BG,
                                    activeforeground=WARN_BG,
                                    font=(UI, 10), anchor='w',
                                    padx=4, pady=3)
                rb.pack(fill='x', anchor='w')

                # 选中项:圆点(selectcolor)+文字变红;其余项保持浅圆点+深紫文字
                def _sync(*_args, _rb=rb, _v=var, _l=label):
                    sel = _v.get() == _l
                    _rb.configure(fg=WARN_BG if sel else TEXT,
                                  selectcolor=WARN_BG if sel else CODE_BG)
                var.trace_add('write', _sync)

        if not multi_select:
            chosen['v'] = var

        def getter():
            if multi_select:
                picks = [lab for lab, bv in chosen.items() if bv.get()]
                return ','.join(picks)
            return chosen['v'].get()

        return getter

    # ── 表单字段(Elicitation schema 用)──────────────────────────────────

    def add_form_field(self, name, prop, required):
        """按 JSON schema 属性渲染一个表单字段,返回取值函数。

        prop: {"type": "string"|"integer"|"number"|"boolean", "title": ...}
        getter() 返回对应 Python 值。
        """
        title = prop.get('title') or name
        ftype = prop.get('type', 'string')
        desc = prop.get('description', '')
        req = ' *' if required else ''
        ff = tk.Frame(self.root, bg=CARD_BG,
                      highlightbackground=CARD_BD, highlightthickness=1)
        ff.pack(fill='x', padx=16, pady=(0, 6))

        tk.Label(ff, text=f'{title}{req}', bg=CARD_BG, fg=ACC,
                 font=(UI, 9, 'bold'), padx=10,
                 anchor='w').pack(fill='x', pady=(6, 0))
        if desc:
            tk.Label(ff, text=desc, bg=CARD_BG, fg=SUB,
                     font=(UI, 8), padx=10, anchor='w',
                     justify='left', wraplength=self._max_w - 52
                     ).pack(fill='x')

        if ftype == 'boolean':
            bv = tk.BooleanVar(value=False)
            tk.Checkbutton(ff, text='是', variable=bv, bg=CARD_BG, fg=TEXT,
                           selectcolor=CODE_BG, font=(UI, 10), padx=10,
                           pady=6, anchor='w').pack(fill='x')

            def get_bool():
                return bv.get()
            return get_bool

        # string / integer / number 统一用单行输入框
        # 注意:tk.Entry 不支持 -padx/-pady 选项,内部边距由外层 pack 提供
        ev = tk.StringVar(value='')
        ent = tk.Entry(ff, textvariable=ev, font=(MONO, 10), fg=CODE_FG,
                       bg=BG, insertbackground=TEXT, relief='flat',
                       highlightthickness=1, highlightbackground=CODE_BD)
        ent.pack(fill='x', padx=10, pady=(0, 8))

        def get_val():
            raw = ev.get()
            if ftype == 'integer':
                try:
                    return int(raw)
                except ValueError:
                    return raw          # 非整数原样返回,让 server 校验
            if ftype == 'number':
                try:
                    return float(raw)
                except ValueError:
                    return raw
            return raw
        return get_val

    # ── 运行 ──────────────────────────────────────────────────────────────

    def run(self, focus_btn=None):
        """布局收尾:换行约束、居中、聚焦、主循环"""
        for lbl in self._wrap_targets:
            try:
                lbl.configure(wraplength=self._max_w - 44)
            except Exception:
                pass
        self.root.update_idletasks()
        w = self.root.winfo_reqwidth()
        h = self.root.winfo_reqheight()
        self.root.geometry(
            f'{min(max(w, 460), self._max_w)}x{min(h, self._max_h)}')
        self.root.eval('tk::PlaceWindow . center')
        if focus_btn:
            self._focus_win(focus_btn)
        self.root.mainloop()
        try:
            self.root.destroy()
        except Exception:
            pass

    def _focus_win(self, btn):
        if sys.platform == 'win32':  # 允许抢占前台焦点
            try:
                import ctypes
                ctypes.windll.user32.AllowSetForegroundWindow(-1)
            except Exception:
                pass
        self.root.lift()
        self.root.attributes('-topmost', True)
        self.root.focus_force()
        btn.focus_set()

        def _keep():
            try:
                if not self.root.winfo_exists():
                    return
                self.root.lift()
                self.root.attributes('-topmost', True)
            except Exception:
                return
            self.root.after(1500, _keep)
        self.root.after(1500, _keep)

    def close(self, exitcode=0):
        """只退出 mainloop 并销毁窗口;exitcode=-1 表示不主动 sys.exit,
        让调用方在 close 之后继续读取表单数据。"""
        if self._closed:
            return
        self._closed = True
        try:
            self.root.quit()
        except Exception:
            pass
        if exitcode >= 0:
            try:
                self.root.destroy()
            except Exception:
                pass
            sys.exit(exitcode)
        # exitcode == -1:仅退出 mainloop,窗口留给调用方 destroy
        try:
            self.root.destroy()
        except Exception:
            pass

决策逻辑核心

decision_core.py — 白名单匹配、删除检测、路径保护判定等纯逻辑(无 UI),供 decision_dialog.py 调用。

#!/usr/bin/env python3
"""PreToolUse 决策核心 - 工具白名单判断与路径保护(无 UI,供弹窗复用)。

纯规则层:读 settings 的 allow 规则、判断 Bash 是否命中白名单、判断写入
路径是否落在受保护根目录。不做任何 tkinter 调用,方便单独测试。
日志写入与脚本同目录的 hook_error.log。
"""

import os
import re
import fnmatch
import json
import datetime

WRITE_TOOLS = {'Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Patch'}
PASSTHROUGH_TOOLS = {'WebSearch', 'WebFetch'}      # 交给 block_builtin_web 处理
READ_TOOLS = {'Read', 'Glob', 'Grep', 'NotebookRead', 'ListMcpResourcesTool'}
INTERACTIVE_TOOLS = {'AskUserQuestion'}             # 需要回填答案,单独分流

PROTECTED_ROOTS = [
    os.path.expanduser('~'),
    os.environ.get('SystemRoot', r'C:\Windows'),
    r'C:\Program Files',
    r'C:\Program Files (x86)',
    r'C:\ProgramData',
]
RULE_PREFIX = 'FileProtect('
TOOL_RULE = 'ToolAllow('

DELETE_PATTERNS = [r'\brm\b', r'\brmdir\b', r'\bdel\b', r'\bRemove-Item\b',
                   r'\bunlink\b', r'\bshred\b', r'\bwipe\b', r'\bmkfs\b']

_LOG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)),
                         'hook_error.log')


def log(message, *parts):
    """追加一行日志,失败不抛。"""
    try:
        stamp = datetime.datetime.now().isoformat(timespec='seconds')
        line = stamp + ' ' + message + \
            (' ' + ' '.join(str(p) for p in parts) if parts else '') + '\n'
        with open(_LOG_PATH, 'a', encoding='utf-8') as f:
            f.write(line)
    except Exception:
        pass


def norm(path):
    return os.path.normcase(os.path.normpath(str(path))) if path else ''


def settings_paths():
    """全局 + 项目级 settings.json / settings.local.json。"""
    yield os.path.join(os.path.expanduser('~'), '.claude', 'settings.json')
    yield os.path.join(os.path.expanduser('~'), '.claude', 'settings.local.json')
    yield os.path.join(os.getcwd(), '.claude', 'settings.json')
    yield os.path.join(os.getcwd(), '.claude', 'settings.local.json')


def load_allow_rules():
    rules = []
    for p in settings_paths():
        try:
            if os.path.exists(p):
                s = json.load(open(p, 'r', encoding='utf-8'))
                rules.extend(r for r in s.get('permissions', {}).get('allow', [])
                             if isinstance(r, str))
        except Exception as exc:
            log('load_allow_rules 读取失败', p, repr(exc))
    return rules


def append_rule(rule):
    """把规则追加到项目级 .claude/settings.local.json。"""
    path = os.path.join(os.getcwd(), '.claude', 'settings.local.json')
    try:
        s = json.load(open(path, 'r', encoding='utf-8')) if os.path.exists(path) else {}
        s.setdefault('permissions', {}).setdefault('allow', [])
        if rule not in s['permissions']['allow']:
            s['permissions']['allow'].append(rule)
        os.makedirs(os.path.dirname(path), exist_ok=True)
        json.dump(s, open(path, 'w', encoding='utf-8'),
                  indent=2, ensure_ascii=False)
    except Exception as exc:
        log('append_rule 写入失败', rule, repr(exc))


def bash_allowed(cmd, rules):
    for r in rules:
        m = re.match(r'^Bash\((.+)\)$', r)
        if m and fnmatch.fnmatch(cmd, m.group(1)):
            return True
    return False


def remembered_files(rules):
    return [norm(r[len(RULE_PREFIX):-1])
            for r in rules if r.startswith(RULE_PREFIX)]


def tool_allowed(tool, rules):
    for r in rules:
        if not r.startswith(TOOL_RULE):
            continue
        inner = r[len(TOOL_RULE):-1]
        if inner == tool or (inner.endswith('*') and tool.startswith(inner[:-1])):
            return True
    return False


def is_delete(text):
    return any(re.search(p, text or '', re.IGNORECASE)
               for p in DELETE_PATTERNS)


def write_targets(tool, ti):
    """从 tool_input 抽出写入目标路径。NotebookEdit 用 notebook_path。"""
    key = 'notebook_path' if tool == 'NotebookEdit' else 'file_path'
    raw = ti.get(key) if isinstance(ti, dict) else ''
    out = []
    if isinstance(raw, str) and raw:
        out.append(raw)
    elif isinstance(raw, (list, tuple)):
        out.extend(x for x in raw if isinstance(x, str) and x)
    return out


def protected_paths(tool, data, rules):
    """返回输入中真正受保护的写入路径列表。"""
    cwd = norm(data.get('cwd') or os.getcwd())
    remembered = remembered_files(rules)
    roots = [norm(x) for x in PROTECTED_ROOTS if x]
    result = []
    for p in write_targets(tool, data.get('tool_input', {})):
        n = norm(p)
        if not n:
            continue
        if n == cwd or n.startswith(cwd + os.sep):
            continue
        if any(n == r or n.startswith(r + os.sep) for r in remembered):
            continue
        if any(n == rr or n.startswith(rr + os.sep) for rr in roots):
            result.append(p)
    return result


def detail(ti):
    """把 tool_input 渲染成等宽文本。"""
    if not isinstance(ti, dict):
        return str(ti)
    lines = []
    for k, v in ti.items():
        if k in ('description', 'timeout'):
            continue
        if isinstance(v, (str, int, float, bool)):
            lines.append('{0}: {1}'.format(k, v))
        else:
            lines.append('{0}: {1}'.format(k, json.dumps(v, ensure_ascii=False)))
    return '\n'.join(lines)

统一决策弹窗hook

decision_dialog.py — PreToolUse 统一决策弹窗,覆盖 Bash / 文件写入 / 其他工具三类决策(matcher 包含 Bash|Edit|Write|MultiEdit|NotebookEdit|Patch|TodoWrite|Agent|Task|mcp__.*|PermissionRequest)。

  • Bash 命令:白名单短路(命中 Bash(*) 规则直接放行不弹窗)、删除命令红色警告 + 120s 超时自动拒绝/同意
  • 文件写入:受保护路径(用户主目录/Windows/Program Files/ProgramData)弹窗确认;项目目录内不打扰;"记住"加入白名单
  • 其他工具:未白名单工具通用允许/拒绝("记住"写入 ToolAllow(工具名) 规则)
  • 按钮布局(右→左):拒绝 | 记住 | 同意/允许
#!/usr/bin/env python3
"""Claude Code PreToolUse hook - 统一决策弹窗(UI 层)。

类别分流(规则见 decision_core.py):
- WebSearch/WebFetch → 透传给 block_builtin_web(exit 0 不输出)
- 只读工具 / 已白名单 → 直接 allow,不弹窗
- AskUserQuestion → 渲染选项,回填 updatedInput.answers 后 allow(见文档 §1694-1750)
- Bash → 命令详情 + 删除检测 + 超时自动
- 写入类 → 受保护路径确认
- 其它未放行工具 → 通用允许/拒绝

输出契约(文档 §1717-1746):
- allow: stdout JSON {hookSpecificOutput:{hookEventName:'PreToolUse',
            permissionDecision:'allow', [updatedInput], [permissionDecisionReason]}} + exit 0
- deny:  exit 2,stderr 作为拒绝原因给 Claude
"""

import sys
import json
import os
import tkinter as tk  # noqa: F401  (确保 Windows 控制台隐藏早于 UI)
import decision_core as core
from dialog_ui import (Dialog, log, ACC, DEN, DEN_HV, MEM_HV, OK_BG,
                       WARN_BG, SUB)


def os_write(payload):
    """把决策 payload 写到 stdout(os.write 绕开 print 缓冲)。"""
    os.write(1, (json.dumps(payload, ensure_ascii=False) + '\n').encode())


def _allow(updated_input=None, reason=None):
    payload = {'hookSpecificOutput': {
        'hookEventName': 'PreToolUse',
        'permissionDecision': 'allow',
    }}
    if updated_input is not None:
        payload['hookSpecificOutput']['updatedInput'] = updated_input
    if reason:
        payload['hookSpecificOutput']['permissionDecisionReason'] = reason
    os_write(payload)


def _deny(reason='user denied'):
    sys.stderr.write(reason + '\n')
    sys.stderr.flush()
    sys.exit(2)


def show(data):
    tool = data.get('tool_name', '')
    ti = data.get('tool_input', {})

    # 交给 block_builtin_web 处理的内置 web 工具
    if tool in core.PASSTHROUGH_TOOLS:
        sys.exit(0)
    # 只读工具直接放行,不打扰
    if tool in core.READ_TOOLS:
        _allow()
        return
    # 交互式提问:渲染选项并回填答案
    if tool in core.INTERACTIVE_TOOLS:
        _ask_gate(data)
        return

    rules = core.load_allow_rules()

    if tool == 'Bash':
        _bash_gate(data, rules)
    elif tool in core.WRITE_TOOLS:
        _write_gate(data, rules)
    else:
        _other_gate(tool, data, rules)


# ── AskUserQuestion:渲染选项并回填 updatedInput.answers ───────────────────

def _ask_gate(data):
    """处理 AskUserQuestion:把每个问题渲染成单选/多选按钮组,
    提交时回填 updatedInput={questions:原数组, answers:{问题文本:label}}。"""
    ti = data.get('tool_input', {}) or {}
    questions = ti.get('questions', [])
    if not isinstance(questions, list) or not questions:
        # 没有问题就放行让 Claude Code 走默认流程
        _allow()
        return

    done = {'ok': False}
    d = Dialog('Claude Code · 提问', badge=' AskUserQuestion ', badge_bg=OK_BG)
    d.set_title('Claude 想问你')
    d.add_text('选择你的答案,点提交回传给 Claude:', fg=SUB)

    getters = []          # [(question_obj, getter_fn), ...]
    for q in questions:
        qtext = q.get('question', '')
        header = q.get('header', '')
        opts = q.get('options', []) or []
        multi = bool(q.get('multiSelect', False))
        if not opts:
            d.add_text(f'(问题无选项,跳过:{qtext})', fg=SUB)
            continue
        getter = d.add_question(qtext, header, opts, multi)
        getters.append((q, getter))

    def submit():
        if done['ok']:
            return
        done['ok'] = True
        answers = {}
        for q, getter in getters:
            val = getter()
            if val:
                answers[q.get('question', '')] = val
        # 文档 §1750:echo 回原 questions 并加 answers
        _allow(updated_input={'questions': questions, 'answers': answers})
        d.close()

    def cancel():
        if done['ok']:
            return
        done['ok'] = True
        _deny('user cancelled AskUserQuestion')
        d.close()

    d.add_button('取消', cancel, color=DEN, hover=DEN_HV)
    btn_ok = d.add_button('提交', submit, primary=True)
    # 不绑回车,避免多选题误提交空答案;关窗=取消
    d.root.protocol('WM_DELETE_WINDOW', cancel)
    d.run(btn_ok)


# ── Bash ────────────────────────────────────────────────────────────────

def _bash_gate(data, rules):
    ti = data.get('tool_input', {})
    cmd = ti.get('command', '') if isinstance(ti, dict) else ''
    is_delete = core.is_delete(cmd)
    # 删除类永远不短路
    if cmd and not is_delete and core.bash_allowed(cmd, rules):
        _allow()
        return

    timeout_seconds = 120
    default_allow = not is_delete
    done = {'ok': False}

    d = Dialog('Claude Code',
               badge='删除操作' if is_delete else 'Bash',
               badge_bg=WARN_BG if is_delete else ACC)
    d.set_title('请求执行命令')
    if isinstance(ti, dict) and ti.get('description'):
        d.add_desc(ti['description'])
    d.add_code(core.detail(ti))

    def act(allow, rem=False):
        if done['ok']:
            return
        done['ok'] = True
        if rem and cmd and '\n' not in cmd and len(cmd) <= 80:
            core.append_rule('Bash(' + cmd + ')')
        if allow:
            _allow()
        else:
            _deny('user denied bash: ' + cmd)
        d.close()

    def on_timeout():
        if done['ok']:
            return
        done['ok'] = True
        if default_allow:
            _allow()
        else:
            _deny('timeout - auto denied (delete): ' + cmd)
        d.close()

    d.add_button('拒绝', lambda: act(False), color=DEN, hover=DEN_HV)
    d.add_button('记住', lambda: act(True, True), color=ACC, hover=MEM_HV)
    btn_ok = d.add_button('同意', lambda: act(True), primary=True)

    remaining = [timeout_seconds]

    def tick():
        if done['ok']:
            return
        remaining[0] -= 1
        if remaining[0] <= 0:
            on_timeout()
            return
        d.set_right('{0}s 后自动{1}'.format(
            remaining[0], '同意' if default_allow else '拒绝'),
            color='#e74c3c' if not default_allow else None)
        d.root.after(1000, tick)

    d.set_right('{0}s 后自动{1}'.format(
        timeout_seconds, '同意' if default_allow else '拒绝'),
        color='#e74c3c' if not default_allow else None)
    d.root.after(1000, tick)

    d.root.bind('<Return>', lambda e: act(True))
    d.root.protocol('WM_DELETE_WINDOW', lambda: act(False))
    d.run(btn_ok)


# ── 写入保护 ────────────────────────────────────────────────────────────

def _write_gate(data, rules):
    tool = data.get('tool_name', '')
    protected = core.protected_paths(tool, data, rules)
    if not protected:
        _allow()
        return

    done = {'ok': False}
    d = Dialog('Claude Code · 文件保护',
               badge=' {0} · 受保护路径 '.format(tool), badge_bg=WARN_BG)
    d.set_title('文件保护确认')
    d.add_desc('将写入 {0}:'.format(tool) if len(protected) == 1
               else '{0} 个文件将写入:'.format(len(protected)))
    d.add_code('\n'.join(protected))
    d.add_text('该路径不在当前项目目录内,且位于受保护根目录下。'
               '如需写入请点"允许",或选"记住"加入白名单。')

    def act(allow, rem=False):
        if done['ok']:
            return
        done['ok'] = True
        if rem:
            for p in protected:
                core.append_rule(core.RULE_PREFIX + core.norm(p))
        if allow:
            _allow()
        else:
            _deny('user denied write: ' + '\n'.join(protected))
        d.close()

    d.add_button('拒绝', lambda: act(False), color=DEN, hover=DEN_HV)
    d.add_button('记住', lambda: act(True, True), color=ACC, hover=MEM_HV)
    btn_ok = d.add_button('允许', lambda: act(True), primary=True)
    d.root.bind('<Return>', lambda e: act(True))
    d.root.protocol('WM_DELETE_WINDOW', lambda: act(False))
    d.run(btn_ok)


# ── 其他工具 ────────────────────────────────────────────────────────────

def _other_gate(tool, data, rules):
    if core.tool_allowed(tool, rules):
        _allow()
        return

    done = {'ok': False}
    d = Dialog('Claude Code · 工具权限',
               badge=' {0} '.format(tool), badge_bg=OK_BG)
    d.set_title('请求使用工具')
    d.add_desc('Claude 请求调用工具 ' + tool)
    d.add_code(json.dumps({'tool': tool,
                           'input': data.get('tool_input', {})},
                          ensure_ascii=False, indent=2))
    d.add_text('该工具未在 allow 白名单中,是否允许本次调用?'
               '(记住=永久放行该工具)')

    def act(allow, rem=False):
        if done['ok']:
            return
        done['ok'] = True
        if rem:
            core.append_rule(core.TOOL_RULE + tool)
        if allow:
            _allow()
        else:
            _deny('user denied tool: ' + tool)
        d.close()

    d.add_button('拒绝', lambda: act(False), color=DEN, hover=DEN_HV)
    d.add_button('记住', lambda: act(True, True), color=ACC, hover=MEM_HV)
    btn_ok = d.add_button('允许', lambda: act(True), primary=True)
    d.root.bind('<Return>', lambda e: act(True))
    d.root.protocol('WM_DELETE_WINDOW', lambda: act(False))
    d.run(btn_ok)


def main():
    try:
        raw = sys.stdin.buffer.read()
        if not raw:
            return
        data = json.loads(raw.decode('utf-8'))
    except Exception as exc:
        log('decision_dialog stdin 解析失败', repr(exc))
        return
    try:
        show(data)
    except Exception as exc:
        log('decision_dialog show 异常', repr(exc))


if __name__ == '__main__':
    main()

计划审批弹窗hook

plan_dialog.py — PermissionRequest 钩子,覆盖 ExitPlanMode / setMode 等权限请求事件。

#!/usr/bin/env python3
"""Claude Code PermissionRequest hook - 计划/模式审批弹窗。

- ExitPlanMode:Claude 完成规划请求退出 plan 模式 → 展示 plan 内容,
  用户「执行 / 取消」,输出 decision.behavior allow/deny。
- setMode 等其它 PermissionRequest → 同样展示 tool_input 后确认。

输入字段(文档 §1805-1832):tool_name、tool_input、permission_suggestions。
ExitPlanMode 的 tool_input 含 plan(markdown)与 planFilePath(§1705-1713)。
输出(文档 §1834-1860):hookSpecificOutput.decision.behavior (allow/deny)。
注意:exit 2 不等于 deny(§1846),deny 必须用 decision.behavior='deny'。
"""

import sys
import json
import os
from dialog_ui import Dialog, log, ACC, DEN, DEN_HV, OK_BG, SUB


def os_write(payload):
    os.write(1, (json.dumps(payload, ensure_ascii=False) + '\n').encode())


def _decision(behavior):
    os_write({'hookSpecificOutput': {
        'hookEventName': 'PermissionRequest',
        'decision': {'behavior': behavior},
    }})


def show(data):
    tool = data.get('tool_name', 'PermissionRequest') or 'PermissionRequest'
    ti = data.get('tool_input', {}) or {}

    done = {'ok': False}
    d = Dialog('Claude Code · 需要你的决定',
               badge=' {0} '.format(tool), badge_bg=OK_BG)
    d.set_title('需要你的决定')

    if tool == 'ExitPlanMode':
        plan = ti.get('plan', '') if isinstance(ti, dict) else ''
        plan_path = ti.get('planFilePath', '') if isinstance(ti, dict) else ''
        d.add_desc('Claude 已完成规划,请求开始执行:')
        if plan:
            d.add_code(plan)
        if plan_path:
            d.add_text('计划文件:' + plan_path, fg=SUB, font_size=8)
    else:
        # setMode 或其它:原样展示 tool_input
        d.add_desc('Claude 请求执行 {0}:'.format(tool))
        d.add_code(json.dumps(ti, ensure_ascii=False, indent=2))

    d.add_text('选择后当前会话继续。', fg=SUB)

    def decide(behavior):
        if done['ok']:
            return
        done['ok'] = True
        _decision(behavior)
        d.close()

    btn_ok = d.add_button('执行', lambda: decide('allow'), primary=True)
    d.add_button('取消', lambda: decide('deny'), color=DEN, hover=DEN_HV)
    d.root.bind('<Return>', lambda e: decide('allow'))
    d.root.protocol('WM_DELETE_WINDOW', lambda: decide('deny'))
    d.run(btn_ok)


def main():
    try:
        raw = sys.stdin.buffer.read()
        if not raw:
            return
        data = json.loads(raw.decode('utf-8'))
    except Exception as exc:
        log('plan_dialog stdin 解析失败', repr(exc))
        return
    try:
        show(data)
    except Exception as exc:
        log('plan_dialog show 异常', repr(exc))


if __name__ == '__main__':
    main()

提问交互弹窗hook

elicitation_dialog.py — Elicitation 钩子(matcher .*)。当 MCP server 请求用户输入(elicitation)时触发,替代默认终端对话框,弹 GUI 窗口收集答案并回传,避免用户切窗错过提问。

  • form 模式:按 requested_schema 渲染表单字段,提交后回传 hookSpecificOutput={hookEventName:'Elicitation', action:'accept', content:{字段:值}}
  • url 模式:打开浏览器链接让用户完成认证,按钮回传 accept / decline
  • action 取值:accept(同意提交)/ decline(拒绝)/ cancel(取消)
#!/usr/bin/env python3
"""Claude Code Elicitation hook - MCP 提问交互弹窗。

当 MCP server 请求用户输入(elicitation)时触发,替代 Claude Code 默认终端
对话框,弹 GUI 窗口收集答案并回传,避免用户切走终端错过提问。

两种模式(文档 §3034-3104):
- form:按 requested_schema 渲染输入字段,提交后回传
         hookSpecificOutput={hookEventName:'Elicitation', action:'accept',
                             content:{字段:值}}
- url:  打开浏览器链接让用户完成认证,按钮回传 accept/decline

action 取值:accept(同意提交)/ decline(拒绝)/ cancel(取消)。
exit 0 + stdout JSON 生效;exit 2 也会 deny 但 stderr 不展示,故统一用 action。
"""

import sys
import json
import os
import webbrowser
import tkinter as tk
from dialog_ui import (Dialog, log, BG, ACC, DEN, DEN_HV, MEM_HV, OK_BG,
                       SUB, WARN_BG)


def os_write(payload):
    os.write(1, (json.dumps(payload, ensure_ascii=False) + '\n').encode())


def _respond(action, content=None):
    payload = {'hookSpecificOutput': {'hookEventName': 'Elicitation',
                                      'action': action}}
    if content is not None:
        payload['hookSpecificOutput']['content'] = content
    os_write(payload)


def show(data):
    mode = data.get('mode', 'form')
    if mode == 'url':
        _url_gate(data)
    else:
        _form_gate(data)


# ── form 模式:按 schema 渲染表单 ────────────────────────────────────────

def _form_gate(data):
    server = data.get('mcp_server_name', 'MCP')
    message = data.get('message', '') or '请提供以下信息'
    schema = data.get('requested_schema') or {}
    props = schema.get('properties', {}) or {}
    required = set(schema.get('required', []) or [])

    if not props:
        # 没有 schema 就只能原样放行让 Claude Code 走默认对话框
        _respond('decline')
        return

    done = {'ok': False}
    d = Dialog('Claude Code · MCP 提问',
               badge=' {0} '.format(server), badge_bg=OK_BG)
    d.set_title('需要你提供信息')
    d.add_desc(message)

    getters = []          # [(field_name, getter_fn)]
    for name, prop in props.items():
        getter = d.add_form_field(name, prop, name in required)
        getters.append((name, getter))

    def submit():
        if done['ok']:
            return
        done['ok'] = True
        content = {}
        for name, getter in getters:
            content[name] = getter()
        _respond('accept', content)
        d.close()

    def decline():
        if done['ok']:
            return
        done['ok'] = True
        _respond('decline')
        d.close()

    d.add_button('拒绝', decline, color=DEN, hover=DEN_HV)
    btn_ok = d.add_button('提交', submit, primary=True)
    d.root.bind('<Return>', lambda e: submit())
    d.root.protocol('WM_DELETE_WINDOW', decline)
    d.run(btn_ok)


# ── url 模式:打开浏览器 + 完成确认 ───────────────────────────────────────

def _url_gate(data):
    server = data.get('mcp_server_name', 'MCP')
    message = data.get('message', '') or '请完成浏览器认证'
    url = data.get('url', '')

    done = {'ok': False}
    d = Dialog('Claude Code · 浏览器认证',
               badge=' {0} '.format(server), badge_bg=ACC)
    d.set_title('需要打开浏览器')
    d.add_desc(message)

    if url:
        url_f = tk.Frame(d.root, bg=BG)
        url_f.pack(fill='x', padx=16, pady=(0, 6))
        tk.Button(url_f, text='打开链接', command=lambda: webbrowser.open(url),
                  bg=ACC, fg='#ffffff', font=('Segoe UI', 10), relief='flat',
                  padx=12, pady=5, cursor='hand2', bd=0,
                  activebackground=MEM_HV).pack(side='left')

    d.add_text('在浏览器完成认证后,点"已完成"继续。', fg=SUB)

    def done_cb():
        if done['ok']:
            return
        done['ok'] = True
        _respond('accept', {})
        d.close()

    def cancel():
        if done['ok']:
            return
        done['ok'] = True
        _respond('cancel')
        d.close()

    d.add_button('取消', cancel, color=DEN, hover=DEN_HV)
    btn_ok = d.add_button('已完成', done_cb, primary=True)
    d.root.bind('<Return>', lambda e: done_cb())
    d.root.protocol('WM_DELETE_WINDOW', cancel)
    d.run(btn_ok)


# ── 入口 ──────────────────────────────────────────────────────────────────


def main():
    try:
        raw = sys.stdin.buffer.read()
        if not raw:
            return
        data = json.loads(raw.decode('utf-8'))
    except Exception as exc:
        log('elicitation_dialog stdin 解析失败', repr(exc))
        return
    try:
        show(data)
    except Exception as exc:
        log('elicitation_dialog show 异常', repr(exc))


if __name__ == '__main__':
    main()

闲置通知hook

idle_notify.py — Notification(idle_prompt) 闲置提醒。当 Claude 空闲等待超过 60 秒时弹出,提醒用户"任务已完成,等待新指令"。

#!/usr/bin/env python3
"""Claude Code Notification hook - 闲置提醒弹窗(idle_prompt)。

Claude 完成回复约 60 秒后、用户仍未输入时触发。弹窗提醒"任务已完成,
等待新指令",避免用户错过 Claude 已停的事实。纯提醒,不产生任何决策。

Notification 钩子无法阻断或回传(文档 §2245),本脚本只做 UI 提醒。
"""

import sys
import tkinter as tk
from dialog_ui import log

# 隐藏 Windows 控制台窗口
if sys.platform == 'win32':
    try:
        import ctypes
        ctypes.windll.user32.ShowWindow(
            ctypes.windll.kernel32.GetConsoleWindow(), 0)
    except Exception:
        pass

BG = '#ede5f5'
CARD = '#fdfaff'
TEXT = '#4a2e6a'
SUB = '#7a5490'
ACC = '#c084d4'
ACC2 = '#ad70c0'
BD = '#cdbde0'
DIV = '#dccde8'

FONT = 'Segoe UI'
MONO = 'Consolas'

# 倒计时秒数;settings.json 的 timeout 必须 > 此值,否则进程会被先杀
COUNTDOWN = 60


def _center(root, w, h):
    root.geometry(f'{w}x{h}')
    root.eval('tk::PlaceWindow . center')


def _allow_set_foreground():
    if sys.platform != 'win32':
        return
    try:
        import ctypes
        ctypes.windll.user32.AllowSetForegroundWindow(-1)
    except Exception:
        pass


def _focus(root, btn):
    _allow_set_foreground()
    root.lift()
    root.attributes('-topmost', True)
    root.focus_force()
    btn.focus_set()

    def _keep_on_top():
        try:
            if not root.winfo_exists():
                return
            root.lift()
            root.attributes('-topmost', True)
        except Exception:
            return
        root.after(1500, _keep_on_top)
    root.after(1500, _keep_on_top)


def show_dialog():
    root = tk.Tk()
    root.title('Claude Code')
    root.resizable(False, False)
    root.configure(bg=CARD)
    closed = {'v': False}

    # 顶部彩虹条
    rainbow = tk.Canvas(root, height=3, bg=CARD, highlightthickness=0)
    rainbow.pack(fill='x')
    colors = ['#fda4af', '#fdba74', '#fde68a',
              '#a7f3d0', '#93c5fd', '#c4b5fd', '#f9a8d4']

    def _draw(event=None):
        rainbow.delete('all')
        w = rainbow.winfo_width() or 340
        seg = w / len(colors)
        for i, c in enumerate(colors):
            rainbow.create_rectangle(i*seg, 0, (i+1)*seg, 3, fill=c, outline='')
    rainbow.bind('<Configure>', _draw)

    # 颜文字
    tk.Label(root, text='\u2661(\u02c3\u0348 \u03b5 \u02c2\u0348 )', bg=CARD,
             font=(FONT, 14)).pack(pady=(18, 0))

    tk.Label(root, text='Claude', bg=CARD, fg=TEXT,
             font=(MONO, 13, 'bold')).pack(pady=(6, 0))

    tk.Frame(root, bg=DIV, height=1).pack(fill='x', padx=30, pady=(10, 12))

    tk.Label(root, text='任务已完成,等待新指令', bg=CARD, fg=SUB,
             font=(FONT, 11, 'bold')).pack()

    tk.Frame(root, bg=CARD, height=10).pack()

    def close():
        if closed['v']:
            return
        closed['v'] = True
        try:
            root.destroy()
        except Exception:
            pass
        sys.exit(0)

    btn = tk.Button(root, text='知道啦', command=close,
                    bg=ACC, fg='#fff', font=(FONT, 11, 'bold'),
                    activebackground=ACC2, activeforeground='#fff',
                    relief='flat', padx=28, pady=7, cursor='hand2', bd=0)
    btn.pack()

    tk.Frame(root, bg=CARD, height=6).pack()
    countdown_var = tk.StringVar(value='{0}s 后自动关闭'.format(COUNTDOWN))
    tk.Label(root, textvariable=countdown_var, bg=CARD, fg='#c4b5fd',
             font=(FONT, 8)).pack()

    remaining = [COUNTDOWN]

    def _tick():
        if closed['v']:
            return
        remaining[0] -= 1
        if remaining[0] <= 0:
            close()
        else:
            countdown_var.set('{0}s 后自动关闭'.format(remaining[0]))
            root.after(1000, _tick)
    root.after(1000, _tick)

    _focus(root, btn)
    root.bind('<Return>', lambda e: close())
    root.protocol('WM_DELETE_WINDOW', close)

    _center(root, 340, 225)
    root.mainloop()
    try:
        root.destroy()
    except Exception:
        pass


def main():
    # idle_prompt 不需要 stdin 内容,但必须读完避免管道破裂
    try:
        sys.stdin.buffer.read()
    except Exception:
        pass
    try:
        show_dialog()
    except Exception as exc:
        log('idle_notify show_dialog 异常', repr(exc))


if __name__ == '__main__':
    main()

屏蔽内置网页工具hook

block_builtin_web.py — PreToolUse(WebSearch|WebFetch) 静默拒绝,强制走 tavily-mcp。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Claude Code PreToolUse Hook - 阻止内置 web_search/web_fetch。

拦截 WebSearch / WebFetch,强制走 tavily-mcp。其余工具静默放行
(exit 0 无输出),交由 decision_dialog 处理。

输出契约(文档 §1717-1746):exit 0 + stdout JSON
{hookSpecificOutput:{hookEventName:'PreToolUse', permissionDecision:'deny',
 permissionDecisionReason:'...'}} 即拒绝。
"""

import sys
import json
import os

# 强制 stdin/stdout/stderr 使用 UTF-8(Windows 默认 GBK 会乱码)
for _stream in (sys.stdin, sys.stdout, sys.stderr):
    if _stream and hasattr(_stream, "reconfigure"):
        try:
            _stream.reconfigure(encoding="utf-8")
        except Exception:
            pass

from decision_core import log


def _deny(reason):
    """构建并输出标准拒绝响应(文档 §1734-1746)。"""
    payload = {
        "hookSpecificOutput": {
            "hookEventName": "PreToolUse",
            "permissionDecision": "deny",
            "permissionDecisionReason": reason,
        }
    }
    os.write(1, (json.dumps(payload, ensure_ascii=False) + "\n").encode())


def main():
    # 防御:窗口模式打包时 stdin 可能为 None
    if sys.stdin is None:
        log("block_builtin_web stdin 为 None(窗口模式打包?)")
        sys.exit(0)
    try:
        raw = sys.stdin.buffer.read()
    except Exception as exc:
        log("block_builtin_web 读 stdin 失败", repr(exc))
        sys.exit(0)
    if not raw or not raw.strip():
        sys.exit(0)

    try:
        payload = json.loads(raw.decode("utf-8"))
    except json.JSONDecodeError as exc:
        log("block_builtin_web JSON 解析失败", repr(exc))
        sys.exit(0)

    tool_name = payload.get("tool_name", "")

    if tool_name in ("WebSearch", "WebFetch"):
        reason = (
            "禁止使用内置工具 '{0}',请改用 tavily-mcp:\n"
            "  - 搜索:mcp__tavily__tavily_search\n"
            "  - 提取网页:mcp__tavily__tavily_extract"
        ).format(tool_name)
        _deny(reason)
        sys.exit(0)

    # 其它工具静默放行,交由 decision_dialog
    sys.exit(0)


if __name__ == "__main__":
    main()

通用提醒面板hook

notify_panel.py — Notification(permission_prompt) 兜底提醒。当 PreToolUse 的 decision_dialog 因异常静默失效、Claude Code 转入内置权限提示并等待约 6 秒后,本钩子弹窗兜底提醒"需要你回终端批准",防止用户错过。按 notification_type 切换标题/提示,未来加 matcher 即可复用于其它通知类型。

#!/usr/bin/env python3
"""Claude Code Notification hook - 通用提醒面板。

当前主要用于 permission_prompt:当 PreToolUse 的 decision_dialog 因异常
静默失效、Claude Code 转入内置权限提示并等待约 6 秒后,本钩子弹窗兜底
提醒"需要你回终端批准",防止用户错过。

通用性:按 notification_type 切换标题/提示,未来加 matcher 即可复用于
其它通知类型。Notification 钩子无法阻断或回传(文档 §2245),纯提醒。
"""

import sys
import json
import tkinter as tk
from dialog_ui import Dialog, log, ACC, MEM_HV, SUB


# 各 notification_type 的展示元数据:badge, 标题, 提示
_TYPE_META = {
    'permission_prompt': ('权限审批', '需要你批准工具使用',
                          'Claude 正在等待你批准某个操作,请回到终端完成审批。'),
}


def _silent_exit():
    sys.exit(0)


def show(data):
    # 文档 §2229-2243:Notification input 顶层即字段,无嵌套对象
    message = data.get('message', '') or ''
    title = data.get('title', '') or ''
    ntype = data.get('notification_type', '') or 'notification'

    badge, default_title, hint = _TYPE_META.get(
        ntype, (ntype, title or message or 'Claude 通知',
                '请回到终端查看详情。'))

    d = Dialog('Claude Code · 提醒', badge=' {0} '.format(badge), badge_bg=ACC)
    d.set_title(default_title)
    if message and message != default_title:
        d.add_desc(message)
    d.add_text(hint, fg=SUB)

    btn = d.add_button('关闭', _silent_exit, primary=True)
    d.root.bind('<Return>', lambda e: _silent_exit())
    d.root.protocol('WM_DELETE_WINDOW', _silent_exit)
    d.run(btn)


def main():
    try:
        raw = sys.stdin.buffer.read()
        if not raw:
            return
        data = json.loads(raw.decode('utf-8'))
    except Exception as exc:
        log('notify_panel stdin 解析失败', repr(exc))
        return
    try:
        show(data)
    except Exception as exc:
        log('notify_panel show 异常', repr(exc))


if __name__ == '__main__':
    main()

实测结论

2026-08-22 重启三轮回测,Python 直跑版本全部通过:

测试项 结果
白名单命令(ls)直接放行
非白名单命令弹窗,同意后执行
受保护路径写入弹窗,允许后创建
删除命令红色警告弹窗,同意后删除
提问提醒面板可见
内置 Web 搜索拦截并提示改用 tavily
计划审批(ExitPlanMode) ⚠️ 中转宿主未触发此事件,脚本逻辑已验证
模式切换(setMode) ⚠️ 中转宿主未触发此事件,配置已就位