learn-claude-code/s04_hooks at main · shareAI-lab/learn-claude-code

权限和agent loop的框架已经差不多了

例如权限验证,功能的扩展还是尽量不放在loop中,防止代码过于臃肿

采用的方法是Hooks,将功能扩展挂在外面

check_permission()移动到hook中

循环内不再调用任何其他函数,只需要trigger_hooks("PreToolUse", block)

同时定义了四个事件,分别表示不同hook触发的时机

事件触发时机典型用途
UserPromptSubmit用户输入提交后、进入 LLM 前输入验证、注入上下文
PreToolUse工具执行前权限检查、日志记录
PostToolUse工具执行后副作用(自动 git add 等)、输出检查
Stop循环即将退出时收尾清理(CC 还支持强制续跑)

扩展通过 register_hook() 添加,循环只调用 trigger_hooks()

Code

  1#!/usr/bin/env python3
  2"""
  3s04: Hooks — move extension logic out of the loop, onto hooks.
  4
  5  User types query
  6  7  8  ┌──────────────────┐
  9  │ UserPromptSubmit │ ── trigger_hooks() before LLM
 10  └────────┬─────────┘
 11 12  ┌────────────┐     ┌─────────────────────────────┐
 13  │  messages  │────▶│  LLM (stop_reason=tool_use?)│
 14  └────────────┘     │   No ──▶ Stop hooks ──▶ exit │
 15                     │   Yes ──▶ tool_use block ──┐ │
 16                     └────────────────────────────┘ │
 17 18                                          ┌──────────────────┐
 19                                          │ trigger_hooks()   │
 20                                          │  PreToolUse:      │
 21                                          │   permission_hook │
 22                                          │   log_hook        │
 23                                          └───────┬──────────┘
 24                                                  │ (not blocked)
 25                                          ┌───────▼──────────┐
 26                                          │ TOOL_HANDLERS[x]  │
 27                                          └───────┬──────────┘
 28 29                                          ┌───────▼──────────┐
 30                                          │ trigger_hooks()   │
 31                                          │  PostToolUse:     │
 32                                          │   large_output    │
 33                                          └───────┬──────────┘
 34 35                                          results ──▶ back to messages
 36
 37Changes from s03:
 38  + HOOKS registry (event -> list of callbacks)
 39  + register_hook() / trigger_hooks()
 40  + context_inject_hook (UserPromptSubmit)
 41  + permission_hook, log_hook (PreToolUse)
 42  + large_output_hook (PostToolUse)
 43  + summary_hook (Stop)
 44  - check_permission() removed from loop body
 45    (logic moved into permission_hook, triggered via PreToolUse)
 46
 47Run: python s04_hooks/code.py
 48Needs: pip install anthropic python-dotenv + ANTHROPIC_API_KEY in .env
 49"""
 50
 51import os, subprocess
 52from pathlib import Path
 53
 54try:
 55    import readline
 56    readline.parse_and_bind('set bind-tty-special-chars off')
 57    readline.parse_and_bind('set input-meta on')
 58    readline.parse_and_bind('set output-meta on')
 59    readline.parse_and_bind('set convert-meta off')
 60except ImportError:
 61    pass
 62
 63from anthropic import Anthropic
 64from dotenv import load_dotenv
 65
 66load_dotenv(override=True)
 67if os.getenv("ANTHROPIC_BASE_URL"):
 68    os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
 69
 70WORKDIR = Path.cwd()
 71client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))
 72MODEL = os.environ["MODEL_ID"]
 73
 74SYSTEM = f"You are a coding agent at {WORKDIR}. Use tools to solve tasks. Act, don't explain."
 75
 76
 77# ═══════════════════════════════════════════════════════════
 78#  FROM s02-s03 (unchanged): Tool Implementations
 79# ═══════════════════════════════════════════════════════════
 80
 81def safe_path(p: str) -> Path:
 82    path = (WORKDIR / p).resolve()
 83    if not path.is_relative_to(WORKDIR):
 84        raise ValueError(f"Path escapes workspace: {p}")
 85    return path
 86
 87def run_bash(command: str) -> str:
 88    try:
 89        r = subprocess.run(command, shell=True, cwd=WORKDIR,
 90                           capture_output=True, text=True, timeout=120)
 91        out = (r.stdout + r.stderr).strip()
 92        return out[:50000] if out else "(no output)"
 93    except subprocess.TimeoutExpired:
 94        return "Error: Timeout (120s)"
 95
 96def run_read(path: str, limit: int | None = None) -> str:
 97    try:
 98        lines = safe_path(path).read_text().splitlines()
 99        if limit and limit < len(lines):
100            lines = lines[:limit] + [f"... ({len(lines) - limit} more lines)"]
101        return "\n".join(lines)
102    except Exception as e:
103        return f"Error: {e}"
104
105def run_write(path: str, content: str) -> str:
106    try:
107        file_path = safe_path(path)
108        file_path.parent.mkdir(parents=True, exist_ok=True)
109        file_path.write_text(content)
110        return f"Wrote {len(content)} bytes to {path}"
111    except Exception as e:
112        return f"Error: {e}"
113
114def run_edit(path: str, old_text: str, new_text: str) -> str:
115    try:
116        file_path = safe_path(path)
117        text = file_path.read_text()
118        if old_text not in text:
119            return f"Error: text not found in {path}"
120        file_path.write_text(text.replace(old_text, new_text, 1))
121        return f"Edited {path}"
122    except Exception as e:
123        return f"Error: {e}"
124
125def run_glob(pattern: str) -> str:
126    import glob as g
127    try:
128        results = []
129        for match in g.glob(pattern, root_dir=WORKDIR):
130            if (WORKDIR / match).resolve().is_relative_to(WORKDIR):
131                results.append(match)
132        return "\n".join(results) if results else "(no matches)"
133    except Exception as e:
134        return f"Error: {e}"
135
136TOOLS = [
137    {"name": "bash", "description": "Run a shell command.",
138     "input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
139    {"name": "read_file", "description": "Read file contents.",
140     "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}},
141    {"name": "write_file", "description": "Write content to a file.",
142     "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
143    {"name": "edit_file", "description": "Replace exact text in a file once.",
144     "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
145    {"name": "glob", "description": "Find files matching a glob pattern.",
146     "input_schema": {"type": "object", "properties": {"pattern": {"type": "string"}}, "required": ["pattern"]}},
147]
148
149TOOL_HANDLERS = {
150    "bash": run_bash, "read_file": run_read, "write_file": run_write,
151    "edit_file": run_edit, "glob": run_glob,
152}
153
154
155# ═══════════════════════════════════════════════════════════
156#  NEW in s04: Hook System (s03 permission logic now via hooks)
157# ═══════════════════════════════════════════════════════════
158
159HOOKS = {"UserPromptSubmit": [], "PreToolUse": [], "PostToolUse": [], "Stop": []}
160
161def register_hook(event: str, callback):
162    HOOKS[event].append(callback)
163
164def trigger_hooks(event: str, *args):
165    for callback in HOOKS[event]:
166        result = callback(*args)
167
168        # 如果正常就是返回None
169        # 但如果有hook想要阻止工具调用(比如权限检查失败),就可以返回一个字符串(原因)
170        if result is not None:  # teaching shortcut: block this tool call
171            return result
172    return None
173
174
175# s03 permission check logic, now wrapped as a hook
176# s03中的check_permission()函数的逻辑现在被包装成一个Hook
177# 更加简洁易于维护
178
179DENY_LIST = ["rm -rf /", "sudo", "shutdown", "reboot", "mkfs", "dd if="]
180DESTRUCTIVE = ["rm ", "> /etc/", "chmod 777"]
181
182def permission_hook(block):
183    """PreToolUse: s03 check_permission() logic moved here."""
184    if block.name == "bash":
185        for pattern in DENY_LIST:
186            if pattern in block.input.get("command", ""):
187                print(f"\n\033[31m⛔ Blocked: '{pattern}'\033[0m")
188                return "Permission denied by deny list"
189        for kw in DESTRUCTIVE:
190            if kw in block.input.get("command", ""):
191                print(f"\n\033[33m⚠  Potentially destructive command\033[0m")
192                print(f"   Tool: {block.name}({block.input})")
193                choice = input("   Allow? [y/N] ").strip().lower()
194                if choice not in ("y", "yes"):
195                    return "Permission denied by user"
196    if block.name in ("write_file", "edit_file"):
197        path = block.input.get("path", "")
198        if not (WORKDIR / path).resolve().is_relative_to(WORKDIR):
199            print(f"\n\033[33m⚠  Writing outside workspace\033[0m")
200            print(f"   Tool: {block.name}({block.input})")
201            choice = input("   Allow? [y/N] ").strip().lower()
202            if choice not in ("y", "yes"):
203                return "Permission denied by user"
204    return None
205
206def log_hook(block): # 在PreToolUse阶段记录每次工具调用的日志
207    """PreToolUse: log every tool call."""
208    args_preview = str(list(block.input.values())[:2])[:60]
209    print(f"\033[90m[HOOK] {block.name}({args_preview})\033[0m")
210    return None
211
212def large_output_hook(block, output): # 在PostToolUse阶段检查工具输出,如果过大则发出警告
213    """PostToolUse: warn on large output."""
214    print(f"\033[33m[HOOK] output from {block.name}: {len(str(output))} chars\033[0m")
215    if len(str(output)) > 100000:
216        print(f"\033[33m[HOOK] ⚠ Large output from {block.name}: {len(str(output))} chars\033[0m")
217    return None
218
219# UserPromptSubmit hook: log user input before it reaches the LLM
220def context_inject_hook(query: str):
221    print(f"\033[90m[HOOK] UserPromptSubmit: working in {WORKDIR}\033[0m")
222    return None
223
224# Stop hook: print summary when loop is about to exit
225def summary_hook(messages: list):
226    tool_count = sum(1 for m in messages
227                     for b in (m.get("content") if isinstance(m.get("content"), list) else [])
228                     if isinstance(b, dict) and b.get("type") == "tool_result")
229    print(f"\033[90m[HOOK] Stop: session used {tool_count} tool calls\033[0m")
230    return None
231
232register_hook("UserPromptSubmit", context_inject_hook)
233register_hook("PreToolUse", permission_hook)
234register_hook("PreToolUse", log_hook)
235register_hook("PostToolUse", large_output_hook)
236register_hook("Stop", summary_hook)
237
238
239# ═══════════════════════════════════════════════════════════
240#  agent_loop — same structure as s03, but no hard-coded check
241#  s03: if not check_permission(block): ...
242#  s04: if trigger_hooks("PreToolUse", block): ...
243# ═══════════════════════════════════════════════════════════
244
245def agent_loop(messages: list):
246    while True:
247        response = client.messages.create(
248            model=MODEL, system=SYSTEM, messages=messages,
249            tools=TOOLS, max_tokens=8000,
250        )
251        messages.append({"role": "assistant", "content": response.content})
252
253        if response.stop_reason != "tool_use":
254
255            # STOP HOOKS
256            force = trigger_hooks("Stop", messages)
257            if force:
258                messages.append({"role": "user", "content": force})
259                continue
260            return
261
262        results = []
263        for block in response.content:
264            if block.type != "tool_use":
265                continue
266
267            # s04 change: hook replaces hard-coded check_permission()
268
269            # PRE TOOL USE HOOK
270            blocked = trigger_hooks("PreToolUse", block)
271            if blocked:
272                results.append({"type": "tool_result", "tool_use_id": block.id,
273                                "content": str(blocked)})
274                continue
275
276            handler = TOOL_HANDLERS.get(block.name)
277            output = handler(**block.input) if handler else f"Unknown: {block.name}"
278
279            # POST TOOL USE KOOK
280            trigger_hooks("PostToolUse", block, output)  # s04: post hook
281
282            results.append({"type": "tool_result", "tool_use_id": block.id, "content": output})
283
284        messages.append({"role": "user", "content": results})
285
286
287if __name__ == "__main__":
288    print("s04: Hooks — extension logic on hooks, loop stays clean")
289    print("Type a question, press Enter. Type q to quit.\n")
290
291    history = []
292    while True:
293        try:
294            query = input("\033[36ms04 >> \033[0m")
295        except (EOFError, KeyboardInterrupt):
296            break
297        if query.strip().lower() in ("q", "exit", ""):
298            break
299
300        # USERPROMPTSUBMIT hook
301        trigger_hooks("UserPromptSubmit", query) 
302
303        history.append({"role": "user", "content": query})
304        agent_loop(history)
305        for block in history[-1]["content"]:
306            if getattr(block, "type", None) == "text":
307                print(block.text)
308        print()

Claude Code

实际上Hooks存在27个

类别事件
工具相关PreToolUse, PostToolUse, PostToolUseFailure
会话相关SessionStart, SessionEnd, Stop, StopFailure, Setup
用户交互UserPromptSubmit, Notification, PermissionRequest, PermissionDenied
子 AgentSubagentStart, SubagentStop
压缩相关PreCompact, PostCompact
团队相关TeammateIdle, TaskCreated, TaskCompleted
其他Elicitation, ElicitationResult, ConfigChange, WorktreeCreate, WorktreeRemove, InstructionsLoaded, CwdChanged, FileChanged

StopHook

Loop中的stop hook其实非常危险

如果hook中的逻辑不满意,它可能会让loop重新做一遍

但是风险是无限循环

因此CC设置了一个stopHookActive = True

标记已经执行过一次Hook了,也就是允许你重试一次

第二次跳过Hook逻辑

hook_stopped_continuation

PostToolUse hooks 返回时,如果已经根据设计,发现当前任务已经完成了,继续做没有意义

会产生一个 hook_stopped_continuation

代码检测到后设置 shouldPreventContinuation = true,循环退出