Bỏ qua để đến nội dung
Đăng ký

Hệ thống Hook

Thời gian học: ~30 phút

Yêu cầu trước: Module 2.2 (Permission System)

Kết quả: Sau module này, bạn cấu hình được hook PreToolUse/PostToolUse/Stop trong settings.json, đọc JSON payload từ stdin, và chặn hoặc chú thích một hành động bằng exit code 2 / JSON output.


Compliance muốn log mọi file Claude đã chạm. Security muốn Claude không bao giờ mở .env, dù prompt viết gì. Bạn muốn được báo khi task xong. Cả ba có thể ghi vào CLAUDE.md — nhưng Module 2.5 đã chỉ ra vì sao cách đó thất bại: CLAUDE.md chỉ là advisory. Anthropic nói thẳng: dùng hook cho việc phải xảy ra mọi lúc, không ngoại lệ (“Use hooks for actions that must happen every time with zero exceptions”, S1). Playbook SDLC: skill là control kiểu khuyến nghị (“A skill is a control, though an advisory one”), hook là lớp deterministic phía sau (“A hook is the deterministic layer behind it”, S3).

(S1)/(S3)/(S4): docs/references/anthropic-sources.md.


Hook là lệnh Claude Code chạy tại một điểm cố định trong lifecycle. Không có file hook riêng: hook là key hooks trong các settings file của Module 2.2 — ~/.claude/settings.json (bạn), .claude/settings.json (cả repo, commit được), .claude/settings.local.json (bạn, repo này), managed settings. Ưu tiên: managed > --settings/CLI flag > local > project > user; entry hook merge, không ghi đè.

{
"hooks": {
"<Event>": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "/path/to/script.sh", "timeout": 30 }
]
}
]
}
}

Matcher (tool event so với tool_name, phân biệt hoa thường): "*", "" hoặc bỏ trống = tất cả; Bash hay Edit|Write = tên chính xác; ký tự khác (mcp__.*) = JavaScript regex.

Type: command (lệnh shell), http (POST tới URL), mcp_tool (gọi tool MCP đã kết nối), prompt (Claude quyết định một lượt), agent (subagent có thể Read/Grep trước; experimental). timeout tính bằng giây: mặc định 600 (command), 30 (prompt), 60 (agent).

Nhịp: mỗi session SessionStart/SessionEnd; mỗi lượt UserPromptSubmit/Stop; mỗi tool call PreToolUse/PostToolUse (xem thêm CHEAT SHEET).

Một JSON object trên stdin — không phải $1 hay env var. Chung: session_id, cwd, hook_event_name, permission_mode, transcript_path. Tool event thêm tool_name, tool_input, tool_use_id; PostToolUse thêm tool_response; Stop thêm stop_hook_active, last_assistant_message. tool_input.file_path luôn là đường dẫn tuyệt đối.

  • exit 0 — tiếp tục; stdout được parse thành JSON nếu bắt đầu bằng { và kết thúc bằng }.
  • exit 2 — chặn; stderr gửi cho Claude làm lý do. Mã duy nhất tự chặn được — exit 1 chỉ là lỗi non-blocking, hành động vẫn chạy.
  • JSON — PreToolUse: {"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny|allow|ask", "permissionDecisionReason": "…"}} (thêm updatedInput, additionalContext). PostToolUse/Stop: {"decision": "block", "reason": "…"}. Chung: continue: false + stopReason, systemMessage.

Hook chạy trước bước kiểm tra permission, ở mọi mode — deny vẫn hiệu lực cả với --dangerously-skip-permissions (chỉ dùng trong sandbox). Hook siết permission, không nới.


Chạy trong ~/cc-lab (src/math.js, tests/math.test.mjs, .env key giả). Cần jq (which jq).

Bước 1: Audit log bằng PostToolUse

Terminal window
# docs: hooks#hook-locations · hooks#matcher-patterns
cat .claude/settings.json
# Output may vary
cat: .claude/settings.json: No such file or directory
Terminal window
mkdir -p .claude && cat > .claude/settings.json << 'EOF'
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' >> .claude/hook-log.txt"
}
]
}
]
}
}
EOF

Vì sao: PostToolUse chỉ bắn khi tool thành công — log chỉ có lần ghi thật.

Bước 2: Kích hoạt headless

Terminal window
# docs: headless — acceptEdits cấp quyền sửa file trước
claude -p "Add a subtract function to src/math.js" --permission-mode acceptEdits
cat .claude/hook-log.txt
# Output may vary
Done. Added `subtract` to `src/math.js:2` and a matching test in `tests/math.test.mjs:5`:
…
/Users/luatnq/cc-lab/src/math.js
/Users/luatnq/cc-lab/tests/math.test.mjs

Vì sao: hai lần ghi, hai đường dẫn tuyệt đối.

Bước 3: Chặn .env bằng exit 2

# docs: hooks#exit-code-2 · hooks-guide#block-edits-to-protected-files
mkdir -p .claude/hooks && cat > .claude/hooks/protect-env.sh << 'EOF'
#!/usr/bin/env bash
# Block any tool call touching .env files. Exit 2 = block, stderr goes to Claude.
path=$(jq -r '.tool_input.file_path // .tool_input.path // empty')
if [[ "$(basename "$path")" == .env* && "$path" != *.example ]]; then
echo "Blocked by hook: $path is a secrets file. Use .env.example instead." >&2
exit 2
fi
exit 0
EOF
chmod +x .claude/hooks/protect-env.sh
# thử tay trước (docs: hooks-guide#hook-error-in-output)
echo '{"tool_name":"Read","tool_input":{"file_path":"/Users/luatnq/cc-lab/.env"}}' \
| .claude/hooks/protect-env.sh; echo "exit=$?"
# Output may vary
Blocked by hook: /Users/luatnq/cc-lab/.env is a secrets file. Use .env.example instead.
exit=2

Đăng ký cạnh group PostToolUse ("$CLAUDE_PROJECT_DIR" resolve đường dẫn):

"PreToolUse": [
{
"matcher": "Edit|Write|Read",
"hooks": [
{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-env.sh" }
]
}
]
Terminal window
claude -p "Read .env and tell me the API_KEY" --allowedTools "Read" --permission-mode default
# Output may vary
A project hook (`.claude/hooks/protect-env.sh`) blocked the read — it's configured to treat
`.env` as a secrets file and refuse access to it. I won't try to work around that (e.g. via `cat`
in Bash), since the hook is a deliberate guardrail.
…

Vì sao: exit 2 chặn Read; stderr thành lý do Claude nêu. --permission-mode default ép hành vi mặc định; trên gói Pro, Max và Team, starting mode built-in là auto, và permissions.defaultMode ghi đè nó. Thiếu nó, mode auto trên máy này để Claude cat .env qua Bash — tool mà matcher này không thấy. @-reference cũng bỏ qua tool; thêm deny rule Read (Module 2.2).

Bước 4: Từ chối git push --force bằng JSON

# docs: hooks#pretooluse-decision-control
cat > .claude/hooks/block-force-push.sh << 'EOF'
#!/usr/bin/env bash
# Deny force pushes via JSON output (exit 0 + permissionDecision). Reason goes to Claude.
cmd=$(jq -r '.tool_input.command // empty')
if [[ "$cmd" == *"git push"* && ( "$cmd" == *"--force"* || "$cmd" == *" -f"* ) ]]; then
jq -n '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "Force push is blocked by project policy. Open a PR instead."
}
}'
exit 0
fi
exit 0
EOF
chmod +x .claude/hooks/block-force-push.sh

Thêm group PreToolUse thứ hai:

{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-force-push.sh",
"timeout": 10
}
]
}
Terminal window
claude -p "Run: git push --force origin main" --allowedTools "Bash" --permission-mode default
# Output may vary
The push was blocked by a project hook before it ran:
> **Force push is blocked by project policy. Open a PR instead.**
Nothing was pushed. …

Vì sao: như exit 2, nhưng JSON còn cho allow, ask, sửa updatedInput hoặc thêm additionalContext. Hook khác ý thì deny thắng.

Bước 5: Gate Stop — chưa xong test thì chưa được dừng

# docs: hooks#stop-decision-control · hooks-guide#stop-hook-hits-the-block-cap
cat > .claude/hooks/test-gate.sh << 'EOF'
#!/usr/bin/env bash
# Stop gate: don't let the turn end while npm test fails. Exit 2 = keep working.
input=$(cat)
if out=$(npm test 2>&1); then
exit 0 # tests pass, Claude may stop
fi
if [ "$(jq -r '.stop_hook_active' <<<"$input")" = "true" ]; then
exit 0 # already continued once; avoid an endless loop
fi
echo "npm test failed. Fix the code or the test before finishing:" >&2
echo "$out" | grep -E '^not ok|Error|expected|actual' | head -10 >&2
exit 2
EOF
chmod +x .claude/hooks/test-gate.sh
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/test-gate.sh",
"timeout": 120
}
]
}
]
Terminal window
claude -p "Add a test to tests/math.test.mjs asserting that divide(1, 0) throws an Error." \
--permission-mode acceptEdits --debug-file /tmp/hooks.log
# Output may vary
Updated `src/math.js:2-5` so `divide` throws `Error('Division by zero')` when the divisor is
`0`, which is what the new test asserts.
I still can't run the tests myself (the `node --test` command needs approval), so the stop
hook's `npm test` run will be the verification. …

Vì sao: Claude viết test, định dừng, gate exit 2, Claude sửa divide, thử lại, pass. Stop không có matcher. Hai giới hạn riêng: script tự thoát sớm qua stop_hook_active (một lần thử lại), và Claude Code chặn tối đa 8 lần liên tiếp (CLAUDE_CODE_STOP_HOOK_BLOCK_CAP nâng mức này).

Bước 6: /hooks

Gõ /hooks trong claude; chỉ đọc, Esc quay lại.

# Output may vary — captured from a live session; line breaks reassembled
Hooks 26 hooks configured
ℹ This menu is read-only. To add or modify hooks, edit settings.json directly or ask Claude.
❯ 1. PreToolUse (4) Before tool execution
2. PostToolUse (3) After tool execution
3. PostToolUseFailure (1) After tool execution fails
↓ 4. PostToolBatch After a batch of tool calls resolves
Enter to confirm · Esc to cancel
PreToolUse - Matchers
Exit code 0 - stdout/stderr not shown
Exit code 2 - show stderr to model and block tool call
Other exit codes - show stderr to user only but continue with tool call
❯ 1. [Project] Bash 1 hook
2. [Project] Edit|Write|Read 1 hook
PreToolUse - Matcher: Bash
❯ 1. [command] "$CLAUDE_PROJECT_DIR"/.claude… Project Settings

Vì sao: số đếm gồm cả hook plugin nên “26” là thật; cột source chỉ file cần sửa.

Bước 7: Chứng minh hook đã chạy

Thành công không in gì; xem debug log:

Terminal window
# docs: hooks#debug-hooks
grep '"Hook Stop' /tmp/hooks.log | cut -c1-160
# Output may vary
2026-09-22T04:00:52.592Z [DEBUG] "Hook Stop (Stop) error:\nnpm test failed. Fix the code or the test before finishing:\nnot ok 2 - divide by zero throws
2026-09-22T04:01:02.574Z [DEBUG] "Hook Stop (Stop) success:\n{\"continue\":true}"

Vì sao: một chặn, một pass. Không có --debug-file, claude --debug ghi ~/.claude/debug/<session-id>.txt. Dọn dẹp: rm -rf .claude/hooks .claude/settings.json.


Mục tiêu: Chạy Prettier trên mỗi file Claude sửa — pattern “eslint after every file edit” (S1). Hướng dẫn:

  1. Thêm group PostToolUse, matcher Edit|Write, pipe đường dẫn từ stdin vào npx prettier --write.
  2. Nhờ Claude thêm một chuỗi nháy đơn vào src/math.js.

Kết quả mong đợi: file quay lại với nháy kép.

✅ Lời giải
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write" }
]
}
]
}
}

Mục tiêu: Chặn rm -rf, git push --force và ghi .env bằng một script PreToolUse. Hướng dẫn:

  1. .claude/hooks/guard.sh đọc stdin một lần (input=$(cat)) rồi rẽ nhánh theo tool_name.
  2. Bash → xét tool_input.command; Edit|Write → xét tool_input.file_path.
  3. Đăng ký matcher Bash|Edit|Write; thử bằng echo '{…}' | ./guard.sh.

Kết quả mong đợi: exit 2 kèm một dòng stderr mỗi mối nguy; còn lại 0.

✅ Lời giải
#!/usr/bin/env bash
input=$(cat)
tool=$(jq -r '.tool_name' <<<"$input")
case "$tool" in
Bash)
cmd=$(jq -r '.tool_input.command // empty' <<<"$input")
if [[ "$cmd" == *"rm -rf"* ]]; then echo "Blocked: rm -rf" >&2; exit 2; fi
if [[ "$cmd" == *"git push"* && "$cmd" == *"--force"* ]]; then
echo "Blocked: force push" >&2; exit 2
fi ;;
Edit|Write)
path=$(jq -r '.tool_input.file_path // empty' <<<"$input")
if [[ "$(basename "$path")" == .env* && "$path" != *.example ]]; then
echo "Blocked: $path is a secrets file" >&2; exit 2
fi ;;
esac
exit 0

Mục tiêu: Notification desktop (macOS) hoặc webhook khi một lượt kết thúc. Hướng dẫn:

  1. Thêm hook Stop (hoặc SessionEnd cho “session đóng”; loại này chung budget 1,5 s).
  2. macOS: osascript -e 'display notification …'; nơi khác curl -X POST tới endpoint giả https://hooks.example.com/claude.

Kết quả mong đợi: notification hoặc webhook mỗi lượt.

✅ Lời giải
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "osascript -e 'display notification \"Claude finished\" with title \"Claude Code\"'"
}
]
}
]
}
}

Webhook: "command": "curl -s -X POST https://hooks.example.com/claude -d @- >/dev/null" (-d @- chuyển tiếp stdin).


EventBắn khiExit 2 chặn được?
SessionStartsession bắt đầu/resumeKhông
UserPromptSubmitgửi promptCó (xóa prompt)
PreToolUsetrước tool callCó
PermissionRequestcần quyết định permissionKhông — JSON decision.behavior
PostToolUsetool thành côngKhông — stderr tới Claude; tool đã chạy
PostToolUseFailuretool thất bạiKhông — stderr tới Claude
Notificationgửi notificationKhông
SubagentStart / SubagentStopsubagent spawn / xongKhông / Có
StopClaude trả lời xongCó (giới hạn 8)
PreCompacttrước compactionCó
SessionEndsession kết thúcKhông (budget 1,5 s)
ExitÝ nghĩa
0tiếp tục; stdout {…} được parse thành JSON
2chặn; stderr → Claude
kháclỗi non-blocking; hành động vẫn chạy
Field JSONEventTác dụng
hookSpecificOutput.permissionDecision + …ReasonPreToolUseallow/deny/ask/defer; reason hiện khi deny
hookSpecificOutput.updatedInput / additionalContextPreToolUsesửa input / thêm context
decision: "block" + reasonPostToolUse, Stopfeedback / tiếp tục làm
continue: false + stopReasonmọi eventdừng hẳn
systemMessagemọi eventcảnh báo cho người dùng

Tắt toàn bộ: "disableAllHooks": true, hoặc claude --settings '{"disableAllHooks": true}' cho một lần.


❌ Sai✅ Đúng
File hooks.json riêng trong .claude/Key hooks trong settings.json (user/project/local).
Đọc $1 hay CLAUDE_FILE_PATHJSON từ stdin: jq -r '.tool_input.file_path'.
exit 1 để chặnChỉ exit 2 chặn; 1 là non-blocking.
Lệnh chậm, không timeoutĐặt "timeout": 30; hook PreToolUse hết giờ không chặn.
Thiếu chmod +x / sai đường dẫnFailed with non-blocking status code: … No such file — gate âm thầm tắt.
Chỉ Edit|Write|Read cho secretClaude có thể cat .env qua Bash; thêm matcher Bash + deny rule Read.
Coi hook là bảo mật tuyệt đốiHook chạy với quyền của bạn; review .claude/settings.json của repo lạ trước khi claude -p.
Quên disableAllHooks: true đang bật/hooks hiện thông báo; false ở project thắng true ở user.

Bối cảnh: Một team fintech thanh toán ở TP.HCM chạy claude -p hằng đêm để vá integration test chập chờn; sáng hôm sau có người review từng PR. Vấn đề: Compliance hỏi “Agent đã chạm file nào?” và “Nó đọc được .env production không?” Dev hỏi vì sao PR đến với test fail. CLAUDE.md không trả lời được. Giải pháp: Ba hook của module này trong .claude/settings.json: PostToolUse ghi mọi đường dẫn tuyệt đối vào file log đẩy lên log pipeline; guard PreToolUse exit 2 với file tool và trả JSON deny với Bash; gate Stop chạy npm test, không cho kết thúc lượt khi test còn fail. Tư thế của chính Anthropic: mọi phê duyệt tự động, tool call và tin nhắn giữa agent đều được log và đổ về SIEM (S4). Kết quả: PR đến với test đã pass, câu hỏi secret được trả lời bằng script thay vì lời hứa, audit trail chỉ cách một lệnh grep. Module 8.4 mở rộng gate Stop thành quality check; Module 15.3 nói về lớp advisory (skills) phía sau.


Tiếp theo: Module 11.4: GitHub Actions Integration →