Câu chuyện thật:
- Áp dụng quy trình 4 bước thiết kế một hook từ đầu
- Đọc hiểu JSON mà Claude Code push vào stdin của hook
- Dùng đúng exit code để communicate với Claude (0 vs 2)
- List được các built-in tool (Read, Edit, Bash...) và schema input của chúng
- Nhận diện khi nào hook cần nhìn tool_input.file_path vs field khác
Mở đầu: Tại sao viết hook lần đầu khó?
Dev: "Tôi muốn hook block nếu Claude định
rm -rf. Nhưng tôi không biết JSON Claude push vào stdin có field gì, hay exit code nào để block...
Vấn đề không phải khái niệm, mà là thiếu chỉ dẫn về data shape và control flow.
Bài này filled that gap. Bạn sẽ biết:
- Quy trình 4-bước để thiết kế hook trước khi code
- JSON shape cho mỗi loại tool
- Exit code rules
- Cách inspect JSON nếu không nhớ
Quy trình 4 bước thiết kế hook
┌──────────────────────────────────────────────────────────┐
│ │
│ 1. Chọn Pre hay Post? │
│ │ │
│ ▼ │
│ 2. Chọn matcher (tool nào trigger)? │
│ │ │
│ ▼ │
│ 3. Viết command nhận stdin JSON │
│ │ │
│ ▼ │
│ 4. Exit code + stderr để feedback Claude │
│ │
└──────────────────────────────────────────────────────────┘
Bước 1: Pre hay Post?
Câu hỏi quyết định: "Tôi muốn chặn hay muốn react?"
| Mục đích | Loại hook |
|---|---|
| Chặn tool call không an toàn | PreToolUse |
| Require confirm trước action | PreToolUse |
| Format/lint sau edit | PostToolUse |
| Run test sau change | PostToolUse |
| Feedback type error quay về Claude | PostToolUse |
| Audit log | Thường PostToolUse |
| Enforce naming trước Write | PreToolUse |
Khó quyết? Nghĩ về "nếu hook fail, tool đã chạy chưa?" — Pre: chưa, Post: rồi.
Bước 2: Matcher — Tool nào trigger?
Xem list built-in tool:
| Tool | Matcher string | Khi nào hook bạn cần trigger? |
|---|---|---|
| Read | Read | Track/block đọc file |
| Grep | Grep | Track/block search file |
| Glob | Glob | Ít khi cần hook |
| Edit | Edit | Format/typecheck sau sửa |
| Write | Write | Validate new file naming |
| MultiEdit | MultiEdit | Tương tự Edit |
| Bash | Bash | Block destructive commands |
| WebFetch | WebFetch | Block fetch URL nhất định |
| WebSearch | WebSearch | Rate limit |
| MCP tools | mcp__<server>__<tool> | Control MCP-specific |
Kết hợp:
- OR:
"Edit|Write|MultiEdit"→ mọi edit-like - All:
"*"→ mọi tool (ví dụ: log audit) - Wildcard prefix:
"mcp__playwright__*"(nếu runtime hỗ trợ — xem docs mới nhất)
Bước 3: Command nhận stdin JSON
Hook command phải:
- Đọc toàn bộ stdin (JSON object)
- Parse JSON
- Kiểm tra điều kiện
- Exit với code + stderr phù hợp
Ví dụ Node.js:
async function main() {
const chunks = [];
for await (const chunk of process.stdin) {
chunks.push(chunk);
}
const input = JSON.parse(Buffer.concat(chunks).toString());
// Logic check ở đây
// input = { session_id, hook_event_name, tool_name, tool_input, ... }
if (shouldBlock(input)) {
console.error("Reason why blocked");
process.exit(2);
}
process.exit(0);
}
main().catch(err => {
console.error("Hook error:", err);
process.exit(1); // 1 = error (unexpected), Claude continues
});
Tương đương Python:
import sys, json
def main():
data = json.load(sys.stdin)
if should_block(data):
print("Reason", file=sys.stderr)
sys.exit(2)
sys.exit(0)
main()
Hoặc shell (nhanh cho task đơn giản):
#!/bin/bash
jq -r '.tool_input.file_path' | grep -q "\.env$" && {
echo "Blocked .env access" >&2
exit 2
}
exit 0
Bước 4: Exit code
┌──────────────────────────────────────────────────────────┐
│ │
│ Exit 0 → OK, let Claude proceed │
│ (Post: continue; Pre: tool executes) │
│ │
│ Exit 2 → BLOCK (chỉ PreToolUse) │
│ Tool không chạy. stderr gửi cho Claude như │
│ error message. │
│ │
│ Exit 1 → Hook itself errored │
│ Claude tiếp tục như không có hook. │
│ (Không an toàn nếu hook là security) │
│ │
└──────────────────────────────────────────────────────────┘
Exit 2 chỉ có effect trên PreToolUse**. Trong PostToolUse, tool đã execute — exit 2 chỉ gửi error về Claude để nó react (thường là fix), không rollback.
JSON input — Cấu trúc chi tiết
Fields chung cho mọi hook event
{
"session_id": "2d6a1e4d-6abc-...",
"transcript_path": "/Users/you/.claude/transcripts/...",
"hook_event_name": "PreToolUse"
}
session_id: unique per Claude Code sessiontranscript_path: log file của session — hữu ích cho audithook_event_name:PreToolUse,PostToolUse, và các event khác (Bài 4.17)
PreToolUse — thêm tool_name + tool_input
{
"session_id": "...",
"transcript_path": "...",
"hook_event_name": "PreToolUse",
"tool_name": "Read",
"tool_input": {
"file_path": "/Users/you/Projects/app/.env"
}
}
tool_input shape khác nhau tùy tool:
| Tool | tool_input shape |
|---|---|
| Read | { file_path: string } |
| Grep | { pattern: string, path?: string, glob?: string } |
| Glob | { pattern: string, path?: string } |
| Edit | { file_path: string, old_string: string, new_string: string } |
| Write | { file_path: string, content: string } |
| MultiEdit | { file_path: string, edits: Array<{old,new}> } |
| Bash | { command: string, description?: string } |
| WebFetch | { url: string, prompt: string } |
| MCP tools | Schema specific theo server |
PostToolUse — thêm tool_response
{
"session_id": "...",
"hook_event_name": "PostToolUse",
"tool_name": "Edit",
"tool_input": {
"file_path": "src/app.ts",
"old_string": "...",
"new_string": "..."
},
"tool_response": {
"success": true
}
}
tool_response cung cấp output của tool — hữu ích để check "có thành công không" trước khi follow-up.
Code defensive khi đọc field
Field có thể khác tùy tool. Code defensive:
const path =
input.tool_input?.file_path || // Read, Edit, Write
input.tool_input?.path || // Grep, Glob
'';
const cmd = input.tool_input?.command || ''; // Bash
Đừng assume field tồn tại. Dùng optional chaining + default.
Ví dụ: Thiết kế 3 hook từng bước
Hook A: Block .env access
Bước 1: Pre hay Post? → Pre (block)
Bước 2: Matcher? → Read|Grep (tools có thể access file content)
Bước 3: Command logic
const path = input.tool_input?.file_path || input.tool_input?.path || '';
if (/\.env(\.|$)/.test(path)) {
console.error("Blocked .env file access. Use process.env at runtime.");
process.exit(2);
}
process.exit(0);
Bước 4: Exit code
- Match pattern → exit 2 + stderr
- Else → exit 0
Hook B: Auto-format TS
Bước 1? → Post (format sau khi edit)
Bước 2? → Edit|Write|MultiEdit
Bước 3?
const path = input.tool_input?.file_path || '';
if (!/\.(ts|tsx|js|jsx)$/.test(path)) process.exit(0);
const { execSync } = require('child_process');
try {
execSync(`npx prettier --write "${path}"`, { stdio: 'pipe' });
process.exit(0);
} catch (err) {
console.error("Prettier failed:", err.stderr?.toString());
process.exit(1); // 1 không block, chỉ log error
}
Bước 4? Exit 0 sau format. Exit 1 nếu Prettier crash (nhưng không rollback edit).
Hook C: Block dangerous bash
Bước 1? → Pre
Bước 2? → Bash
Bước 3?
const cmd = input.tool_input?.command || '';
const DANGEROUS = [
/\brm\s+-rf\s+\//,
/\bdd\s+if=/,
/\b:\(\)\s*\{\s*:\|:/, // fork bomb
/\bmkfs/,
/\bsudo\s+(rm|dd|mkfs)/,
];
for (const pat of DANGEROUS) {
if (pat.test(cmd)) {
console.error(`Blocked dangerous command: ${cmd}`);
process.exit(2);
}
}
process.exit(0);
Bước 4? Block + stderr.
Ví dụ thực chiến: Team setup 3 hook foundational
Context
Team 10 dev, fintech. Setup hook baseline:
- Block
.env+secrets/ - Auto-format + typecheck TS
- Log mọi Bash command
settings.json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Read|Grep",
"hooks": [
{ "type": "command", "command": "node $PWD/.claude/hooks/block-secrets.js" }
]
},
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "node $PWD/.claude/hooks/block-dangerous.js" }
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{ "type": "command", "command": "node $PWD/.claude/hooks/format-and-check.js" }
]
},
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "node $PWD/.claude/hooks/audit-log.js" }
]
}
]
}
}
Kết quả
4 hook, ~150 dòng code. Guardrails:
- Claude không đọc
.env/secrets/ - Claude không
rm -rf - Mọi TS edit auto-format + typecheck
- Mọi command shell Claude chạy có trong audit log
Team confidence dùng Claude Code lên cao — infrastructure enforce boundaries, không dựa vào prompt.
Case studies theo ngành
Fintech — PreToolUse block prod DB destructive
Setup: PreToolUse trên Bash match pattern DROP|TRUNCATE|DELETE FROM với target prod host → block.
- Kết quả: Zero incident data loss trong 1 năm.
Health tech — PreToolUse enforce audit
Setup: PreToolUse mọi tool, log vào append-only audit log với tamper-proof.
- Kết quả: HIPAA audit pass, có evidence mọi action.
Platform — PostToolUse Kubernetes manifest validation
Setup: PostToolUse trên Write/Edit file *.yaml — chạy kubectl apply --dry-run.
- Kết quả: Zero prod manifest error, catch before deploy.
Security — PreToolUse block public repo push
Setup: PreToolUse Bash match git push với remote public → confirm.
- Kết quả: Zero accidental secret leak vào repo public trong 8 tháng.
Design system — PostToolUse enforce token usage
Setup: PostToolUse trên file *.tsx — scan hardcoded color/spacing, feedback "use token instead".
- Kết quả: Design token adoption 95%, visual consistency tăng rõ.
Anti-patterns
Không check tool_name, apply logic cho mọi tool
Biểu hiện: Matcher *, nhưng logic expect file_path → crash khi gặp Bash.
Cách đúng: Matcher precise HOẶC early return nếu field không tồn tại.
Hook làm heavy work trong hot path
Biểu hiện: PostToolUse chạy npm run test full suite → 60s.
Cách đúng: Chạy subset, background, hoặc async queue.
Exit code sai
Biểu hiện: Dev nghĩ "exit 1" block — thực ra exit 1 chỉ báo hook error, Claude continue.
Cách đúng: Nhớ exit 2 để block (chỉ Pre).
Swallow error silent
Biểu hiện: Hook crash, catch nuốt error, exit 0.
Cách đúng: Log error to stderr, exit code phù hợp (1 cho error hook, không phải 0 giả vờ OK).
Regex quá lỏng
Biểu hiện: /env/ match cả environment.ts (public file).
Cách đúng: Tight regex: /\.env(\.[a-z]+)?$/ match .env, .env.local, .env.production.
Mẹo nâng cao
Mẹo 1: Test hook độc lập
Trước khi deploy team, test hook trong isolation:
echo '{"hook_event_name":"PreToolUse","tool_name":"Read","tool_input":{"file_path":".env"}}' | node your-hook.js
echo "Exit: $?"
Mock input → chạy hook → check exit code. Nhanh.
Mẹo 2: Share hook qua npm package
Team lớn: publish hook library @acme/claude-hooks, mỗi dev npm install, config trỏ đến package. Update một chỗ, cả team hưởng.
Mẹo 3: Conditional hook based trên branch
const branch = execSync('git branch --show-current').toString().trim();
if (branch === 'main' || branch.startsWith('release/')) {
// strict hooks
} else {
// relaxed hooks for dev branches
}
Mẹo 4: Timeout cho hook
Nếu hook có thể hang (network call), add timeout:
const timeout = setTimeout(() => {
console.error("Hook timed out");
process.exit(1);
}, 10_000);
// ... async work ...
clearTimeout(timeout);
process.exit(0);
Áp dụng ngay
Bài tập 1: Design hook trên giấy (10 phút)
Chọn 1 rule team bạn cần enforce. Trả lời 4 bước:
- Pre/Post? _____
- Matcher? _____
- Pseudo-code logic:
_____
- Exit code strategy: — Happy case: _____ — Violation: _____
Bài tập 2: Đọc JSON thực tế (15 phút)
Setup hook log-only (giống Bài 4.12 bài tập 1). Trigger các prompt khác nhau, check JSON cho:
- Prompt đọc file → JSON cho Read tool
- Prompt edit file → JSON cho Edit tool (chú ý
old_string,new_string) - Prompt chạy
npm test→ JSON cho Bash tool - Prompt grep code → JSON cho Grep tool
Ghi lại field chính cho mỗi. Giúp bạn viết hook tương lai không phải đoán.
Tóm tắt bài học
- 4-bước design: Pre/Post → Matcher → Command → Exit code.
- Exit 0 = allow, Exit 2 = block (Pre only), Exit 1 = hook error.
- JSON stdin shape tùy tool — code defensive với optional chaining.
- Test hook cô lập trước deploy team — mock input dễ.
- Tight regex — precise match, tránh false positive.
Tài liệu tham khảo
- Claude Code hooks schema
- Built-in tools reference
- Bài 4.14 — Implement hook chi tiết
- Bài 4.17 — Hook events khác (Stop, SubagentStop, ...)