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 |
| 子 Agent | SubagentStart, 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,循环退出