- Permission mode = chốt MỘT LẦN mức tin tưởng thay vì duyệt từng thao tác. Sáu mode: manual (default — chỉ reads, còn lại hỏi), accept edits (reads + file edits + common file system bash commands), plan (read-only, chỉ đề xuất), auto (chấp nhận mọi thứ nhưng có classifier model riêng review từng action TRƯỚC khi chạy), dontAsk (chỉ tool pre-approved, còn lại auto-deny không prompt), bypassPermissions (bỏ mọi check = dangerously-skip-permissions, chỉ dùng trong container/VM cô lập). Shift+Tab cycle qua manual → accept edits → plan → auto; status bar luôn hiện mode hiện tại.
- Auto mode: classifier gác INTENT — chặn production deploy/migration, force push, pipe code tải về vào shell, gửi sensitive data ra endpoint ngoài, phá huỷ file; cho qua local edit, cài dependency từ lock file, read-only request, push branch của chính mình. Nhưng classifier KHÔNG kiểm correctness: refactor auth mà viết auth hỏng thì vẫn cho qua (hỏng ≠ nguy hiểm) → ghép auto mode với Stop hook chạy test. Auto gác intent TRƯỚC mỗi action, Stop hook gác correctness SAU khi xong. dontAsk dành cho lần chạy không người trực (CI, scheduled job, batch đêm) — pipeline chạy tiếp thay vì treo chờ một cái duyệt không ai bấm.
- Hook: chạy command của riêng bạn NGAY TRƯỚC (PreToolUse) hoặc NGAY SAU (PostToolUse) khi Claude gọi một tool. PreToolUse CHẶN được (cho phép, hoặc block + gửi error message ngược lại cho Claude); PostToolUse KHÔNG chặn được (tool đã chạy rồi) — chỉ làm follow-up (VD format file vừa sửa) + feedback cho Claude. matcher chỉ định tool bị nhắm (VD "Write|Edit", dấu | = OR). Khai báo trong settings.json (global ~/.claude / project / project-local) hoặc gõ /hooks. Câu chốt: Pre = kiểm soát Claude ĐƯỢC LÀM GÌ; Post = tăng cường thứ ĐÃ LÀM.
- Dựng một hook = 4 bước: (1) chọn Pre/Post, (2) xác định tool cần canh (matcher), (3) viết command nhận JSON tool call qua standard input (stdin), (4) dùng exit code báo allow/block. JSON gồm session_id, transcript_path, hook_event_name, tool_name, tool_input. Exit code 0 = success (stdout JSON được parse); exit code 2 = blocking error ở gần như mọi event (stderr feed ngược cho Claude làm context, giải thích vì sao bị chặn); mọi exit code khác là non-blocking — ⚠️ exit 1 TRÔNG như lỗi nhưng KHÔNG chặn, Claude vẫn chạy command. Exit 2 chặn được cả Stop ("chưa xong đâu"); PostToolUse chặn thì muộn (tool đã chạy) nhưng vẫn feed text về được; vài event bỏ qua chặn hoàn toàn (Notification, SessionStart). Với 3 event SessionStart / UserPromptSubmit / UserPromptExpansion, plain text ở stdout được cộng thẳng vào context. Ví dụ chặn đọc .env (bản hiện hành của khoá) chỉ cover tool Read qua tool_input.file_path; mỗi tool có input shape khác nhau (Grep gửi pattern + path là thư mục, Bash gửi command) nên check file_path KHÔNG bắt được grep/cat .env — muốn phủ đồng đều thì dùng permissions.deny "Read(**/.env)".
- Gotcha bảo mật hook: docs khuyến nghị dùng absolute path (KHÔNG relative) cho script hook → giảm path interception + binary planting attack. Nhưng absolute path khó share (mỗi máy để project ở thư mục khác). Cách giải: settings.example.json chứa placeholder $PWD; npm run setup chạy scripts/init-claude.js thay $PWD bằng absolute path máy bạn rồi copy & đổi tên thành settings.local.json — vừa share được (commit template) vừa giữ absolute path.
- Hai hook thực chiến giải điểm yếu AI-assisted dev: (1) TypeScript type-checking hook (PostToolUse) chạy tsc --noEmit sau mỗi edit → capture type error → feed lại Claude để sửa call site nó quên (VD đổi signature ở schema.ts nhưng quên main.ts); typed language dùng type checker, untyped thì dùng automated tests. (2) Query duplication hook dùng Agent SDK khởi chạy một Claude THỨ HAI review file trong ./queries, phát hiện query trùng → feedback bắt Claude tái dùng hàm có sẵn (VD getPendingOrders). Query hook nặng (tốn API mỗi edit) → chỉ monitor thư mục quan trọng. Nguyên tắc: compiler/linter = deterministic gate; AI review = probabilistic gate cho việc không có ground truth.
- Vì sao cần hook: luật viết trong CLAUDE.md là một REQUEST, không phải GUARANTEE — Claude "thường" nghe, mà trong lần chạy dài không ai canh thì "thường" là không đủ. Hook là deterministic code chạy tại một điểm cố định trong loop nên nó BẢO ĐẢM hành vi, biến luật từ "Claude thường nghe" thành "Claude không skip được".
- PreToolUse trả quyết định bằng cách in JSON ra stdout rồi exit 0. Field permissionDecision nhận allow (cho qua) / deny (chặn) / ask (đẩy về user tự quyết); còn defer chỉ dùng cho non-interactive -p run nơi tiến trình gọi tạm dừng tool rồi resume. ⭐ Nước đi ít người biết mà mạnh hơn: updatedInput để VIẾT LẠI call thay vì chặn — VD guardrail canh tool Bash nhận diện pattern sk_live_ rồi thay secret bằng placeholder: lệnh vẫn chạy, việc vẫn xong, nhưng secret không đi qua (redact thay vì block). Catch: updatedInput thay TOÀN BỘ input object nên phải echo lại các field không đổi, không thì mất.
- Claude Code bắn 33 hook event (docs 08/2026); nhúm hay dùng: PreToolUse (primitive cưỡng chế, dừng được việc trước khi xảy ra), PostToolUse (auto-format/lint sau tool call thành công), Stop (Claude muốn kết thúc turn — từ chối được, "chưa xong đâu"; bản song sinh SubagentStop), PreCompact/PostCompact, InstructionsLoaded (CLAUDE.md hoặc rule file được load — audit thứ gì thật sự vào context), SessionStart (mồi môi trường; source startup nếu chỉ muốn chạy lúc khởi động mới). ⚠️ Muốn bơm lại context SAU compaction thì KHÔNG dùng PostCompact — dùng SessionStart với matcher compact, đó mới là cái đưa output ngược vào hội thoại (VD in summary các file đang làm để Claude bắt nhịp thay vì bắt đầu lạnh).
- Ngoài Pre/PostToolUse, 7 event vòng đời hay dùng nhất (không phải toàn bộ danh sách 33): Notification (Claude xin quyền dùng tool, hoặc idle 60 giây), Stop (Claude trả lời xong), SubagentStop (subagent = "Task" trong UI chạy xong), PreCompact (trước compact thủ công/tự động), UserPromptSubmit (user submit prompt, TRƯỚC khi Claude xử lý), SessionStart (bắt đầu/resume session), SessionEnd (session kết thúc). Cấu trúc stdin đổi theo 2 trục: theo loại hook (Stop chỉ có hook_event_name + stop_hook_active, không có tool_name/tool_input) và theo tool được gọi (tool_input khác nhau; PostToolUse còn có thêm tool_response). Mẹo soi input: gắn tạm hook matcher "*" với 'jq . > post-log.json' để ghi input ra file mà xem shape thật.
TL;DR — Permission mode chốt MỘT LẦN mức tin tưởng thay vì duyệt từng thao tác — auto mode có classifier gác intent (chặn production deploy, force push). Hook là chỗ biến luật thành bảo đảm:
PreToolUsechặn được thao tác trước khi chạy,PostToolUsechạy sau. Luật trong CLAUDE.md là một request; hook mới là guarantee.
Phần 3/7 của ghi chú khoá Claude Code in Action — đề thi thử 219 câu (chấm điểm + giải thích) nằm ở tab "Đề thi thử" trên trang tổng quan.
Permission mode — chốt MỘT LẦN Claude được chạy gì
Các kỹ thuật tự chủ ở trên chỉ có nghĩa nếu Claude không phải dừng hỏi phép mỗi thao tác. Permission mode cho bạn quyết định một lần mức tin tưởng cho cả phiên làm việc, thay vì duyệt từng prompt một. Ba mode quen thuộc (manual, accept edits, plan) lo phần việc kè kè bên cạnh; phần hands-off thật sự nằm ở mode auto.
⚡ Cập nhật 14/08/2026 —
autogiờ là mode MẶC ĐỊNH, không cònmanual. Khoá (và mọi tài liệu trước 08/2026) dạy rằng session mới khởi động ở Manual. Từ 14/08/2026 điều đó đã đổi: trên gói Pro, Max và Team, chạy ở terminal hoặc VS Code extension, mode khởi động sẵn (built-in default) làauto— tức classifier duyệt thay bạn ngay từ prompt đầu. Cần v2.1.228+ (macOS/Linux/WSL) hoặc v2.1.233+ (Windows native); bản cũ hơn vẫn khởi động ở Manual.Vẫn khởi động ở
default(Manual) trong các trường hợp: gói Enterprise hoặc API key của Claude Console ·claude -pvà Agent SDK · Bedrock / Google Cloud Agent Platform / Microsoft Foundry / Claude Platform on AWS / apps gateway · settings đặtdisableAutoMode: "disable"· session đầu tiên sau khi cài/nâng cấp, hoặc khi feature-flag fetching bị tắt.⚠️ Bẫy cấu hình: giá trị
"auto"đặt trong.claude/settings.jsoncủa project KHÔNG có hiệu lực (và khi đó Claude Code dùng built-in default, bỏ luôndefaultModeở~/.claude/settings.json). Muốn ghimautothì đặt ở user settings; muốn kéo về Manual cho cả máy thì"defaultMode": "default". Kèm theo: classifier call của auto mode không còn tính vào usage limit trên các gói này.
Sáu permission mode
Mỗi mode kẻ một đường khác nhau giữa chạy thẳng và phải xin phép:
| Mode (tên trong settings) | Chạy không hỏi | Phần còn lại |
|---|---|---|
Manual (default) |
Chỉ reads | Mọi thứ khác hỏi trước |
Accept edits (acceptEdits) |
reads + file edits + common file system bash commands | Còn lại hỏi. Hợp khi iterate rồi review sau |
Plan (plan) |
reads — cộng thêm lệnh được classifier duyệt khi auto mode khả dụng (docs 08/2026) | Không sửa gì — chỉ nghiên cứu & đề xuất thay đổi |
Auto (auto) |
Chấp nhận mọi thứ, nhưng có classifier model riêng review từng action TRƯỚC khi chạy | — |
Don't ask (dontAsk) |
Chỉ tool đã pre-approve | Còn lại auto-deny, không prompt |
Bypass permissions (bypassPermissions) |
Bỏ qua mọi check (tương đương cờ --dangerously-skip-permissions) |
CHỈ chạy trong container/VM cô lập |
Cycling bằng Shift+Tab: khỏi nhớ lệnh cho từng mode — bấm Shift+Tab để luân phiên qua các mode hằng ngày. Status bar dưới màn hình luôn hiện mode đang dùng, liếc xuống là biết Claude đang được phép làm gì.
⚡ Vòng cycle đã đổi (docs 08/2026). Khoá dạy
manual → accept edits → plan → auto. Bản hiện hành: đang ởautothì phím đầu tiên nhảy vềdefault, rồi vòng chạydefault→acceptEdits→plan. Mode tuỳ chọn chèn vào SAUplan, thứ tựbypassPermissionstrước,autocuối — bật cả hai thì phải đi quabypassPermissionsmới tớiauto.dontAskkhông bao giờ nằm trong cycle (chỉ set bằng--permission-mode dontAsk). Nhãn status bar:⏸ manual mode on(xám) ·⏵⏵ accept edits on·⏸ plan mode on·⏵⏵ auto mode on·⏵⏵ don't ask on·⏵⏵ bypass permissions on.
Auto mode chạy thế nào
Auto là mode hands-off: Claude tự chạy, nhưng trước mỗi action có một classifier model riêng xem xét. Classifier gác INTENT (ý định) — nó soi những nước đi leo thang vượt quá thứ bạn thật sự yêu cầu.
Kiểu hành vi classifier được thiết kế để CHẶN:
- Production deploys và migrations.
- Force push, hoặc pipe code tải về thẳng vào shell.
- Gửi sensitive data ra endpoint bên ngoài.
- Phá huỷ file đang tồn tại của phiên làm việc.
Và cho qua phần việc thường ngày: local edits trong project của bạn, cài dependency từ lock file, các read-only request, và push lên branch của chính bạn.
Cái classifier KHÔNG làm được
Classifier kiểm intent, KHÔNG kiểm correctness (tính đúng). Nó không biết code có chạy được hay không. Nhờ Claude refactor authentication mà nó viết authentication hỏng → classifier vẫn cho qua, vì hỏng thì không nguy hiểm.
→ Vì thế auto mode đi kèm một Stop hook chạy test. Hai lớp bổ trợ nhau:
| Gác cái gì | Gác lúc nào | |
|---|---|---|
| Auto mode | Intent — Claude đang định làm gì | TRƯỚC mỗi action, trong lúc chạy |
| Stop hook | Correctness — code có thật sự chạy không | SAU khi Claude làm xong |
⚠️ Freshness: guardrail của auto mode vẫn đang tiến hoá — block list & allow list đổi theo thời gian, tra docs để lấy bản hiện hành, đừng học thuộc như luật cứng.
dontAsk — cho lần chạy không người trực
Don't ask là nước đi đúng khi không có người ngồi duyệt prompt: CI pipeline, scheduled job, batch chạy đêm. Chỉ tool pre-approved được phép; ngoài danh sách là auto-deny, không hỏi. Đó chính là điểm cốt: pipeline chạy tiếp thay vì treo chờ một cái duyệt mà không ai ở đó để bấm.
Khớp mode với việc
- Auto = mode hands-off — classifier gác intent trước mỗi action,
Stophook gác correctness sau đó. - Don't ask = pipeline không người trực.
- Bypass permissions = chỉ trong container/VM cô lập.
📌 Chi tiết đã verify ở docs (rà lại 08/2026) — hữu ích khi cấu hình thật:
- Đặt mode mặc định bằng khoá
defaultModetrong settings file;defaulthiển thị nhãn "Manual" trong CLI/extension và nhận aliasmanual.- Tổ chức chặn được hai mode nguy hiểm:
permissions.disableAutoModevàpermissions.disableBypassPermissionsMode="disable"(đặt ở managed settings thì user không override được).dontAskdeny cảAskUserQuestion— hợp lý, vì mode này tồn tại cho lúc không có ai trả lời.bypassPermissionsvẫn prompt ở năm chỗ, không phải hai: (1)askrule tường minh; (2) connector tool bị tổ chức đặtask; (3) MCP tool gắn_meta["anthropic/requiresUserInteraction"](cần v2.1.199 trở lên); (4) xoá thư mục gốc/home (rm -rf /,rm -rf ~) — chốt an toàn cuối (circuit breaker), và từ v2.1.208 nó nổ cả khi lệnh xoá nấp trong$(...), backtick hay<(...); (5) hai chốt cross-session messaging (isolatePeerMachines, và tin nhắn inbound từ session khác khi chưa có luậtcrossSessionInboundnào áp vào).
Áp dụng thực tế: đây đúng là bài least-privilege quen thuộc, chỉ đổi chủ thể từ nhân sự sang agent. Cách tôi phân vai: plan khi chưa tin cách hiểu bài của Claude (bắt "xác nhận rồi mới làm" — hợp khách Nhật), accept edits cho mạch sửa nhỏ trong repo tôi review từng commit, auto khi việc rõ và tôi có test làm lưới, dontAsk cho automation chạy đêm. Ba chốt tôi ghim: (1) auto KHÔNG phải bypass — nhầm hai cái này là nhầm chí mạng: một cái có classifier gác từng action, cái kia không còn gì gác; bypass chỉ sống trong container. (2) Câu "classifier gác intent, không gác correctness" là ranh giới trách nhiệm rõ nhất tôi từng thấy phát biểu về AI guardrail — nó nói thẳng rằng an toàn ≠ đúng, nên CI/test vẫn là của tôi, không giao được cho mode. (3) Cặp auto + Stop hook chính là mô hình two-gate tôi vẫn dùng trong delivery: một cửa trước (duyệt hành động) và một cửa sau (nghiệm thu kết quả) — bỏ cửa nào cũng thủng. Và nhớ: permission là thứ Claude Code cưỡng chế, còn
CLAUDE.mdchỉ thuyết phục model — cùng bài học đã gặp ở mục "luật cứng thì đẩy sang hook".
Hooks — chạy lệnh của bạn TRƯỚC/SAU khi Claude gọi tool
Hook cho phép bạn chạy command của riêng mình ngay trước hoặc ngay sau khi Claude định thực thi một tool. Cực hữu ích để tự động hoá workflow: chạy code formatter sau khi Claude sửa file, chạy test khi file đổi, hoặc chặn truy cập file cụ thể.
⭐ Vì sao cần hook — một câu: viết luật trong
CLAUDE.mdlà một request (lời đề nghị), không phải guarantee (bảo đảm). Ghi "always format after editing" thì Claude thường nghe. Thường. Nhưng trong một lần chạy dài bạn không ngồi canh, "thường" là không đủ. Hook là deterministic code chạy tại một điểm cố định trong loop → nó bảo đảm hành vi thay vì hy vọng. Nó biến luật từ "Claude thường nghe" thành "Claude KHÔNG SKIP được".
Hook chen vào luồng ở đâu
Nhớ lại luồng bình thường khi bạn nói chuyện với Claude Code:
- Query của bạn gửi tới model kèm tool definitions.
- Claude quyết dùng tool → trả về một formatted response.
- Claude Code thực thi tool đó và trả kết quả.
Hook chen vào ngay quanh bước 3 — cho bạn chạy code just-before hoặc just-after lúc tool được thực thi. Hai loại phổ biến nhất (còn loại khác ở lesson sau):
- PreToolUse — chạy TRƯỚC khi tool được gọi.
- PostToolUse — chạy SAU khi tool được gọi.
Khai báo hook ở đâu
Hook định nghĩa trong các Claude settings file — đúng bộ ba vị trí quen thuộc như CLAUDE.md:
| File | Phạm vi | Chia sẻ team? |
|---|---|---|
~/.claude/settings.json |
Global — mọi project | (máy cá nhân) |
.claude/settings.json |
Project | ✅ commit, chia sẻ team |
.claude/settings.local.json |
Project — cá nhân | ❌ không commit |
Viết tay trong file, hoặc dùng lệnh /hooks ngay trong Claude Code.
⚠️ Đừng lẫn: hook khai trong
settings.json, KHÁC với chỉ thị tự nhiên đặt trongCLAUDE.md. Cùng bộ ba vị trí (global / project / project-local) nhưng khác file, khác vai:CLAUDE.mdlà lời dặn cho model;settings.jsonlà cấu hình tất định cho harness.
PreToolUse — chạy TRƯỚC, chặn được
"PreToolUse": [
{
"matcher": "Read",
"hooks": [
{ "type": "command", "command": "node /home/hooks/read_hook.js" }
]
}
]
matcher chỉ định loại tool nào bị nhắm (ở đây là Read). Trước khi tool Read chạy, command được gọi. Command nhận được chi tiết về tool call Claude định thực hiện, rồi bạn có 2 lựa chọn cơ bản:
- Cho phép thao tác chạy bình thường.
- Block tool call và gửi một error message ngược lại cho Claude.
(Bản đầy đủ có 3 quyết định — allow / deny / ask — khi hook trả JSON thay vì chỉ dùng exit code, kèm khả năng viết lại call bằng updatedInput. Xem mục "PreToolUse trả quyết định bằng JSON" phía dưới.)
PostToolUse — chạy SAU, KHÔNG chặn được
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{ "type": "command", "command": "node /home/hooks/edit_hook.js" }
]
}
]
matcher "Write|Edit" nhắm nhiều tool cùng lúc (Write HOẶC Edit — dấu | như regex). Vì tool call đã xảy ra rồi, PostToolUse KHÔNG block được thao tác. Nhưng nó vẫn:
- Chạy follow-up operations (VD format lại file vừa sửa).
- Cấp feedback thêm cho Claude về tool use đó.
Dựng một hook — 4 bước
Hook chặn/kiểm soát tool call trước hoặc sau khi nó thực thi → kiểm soát chi tiết Claude được và không được làm gì. Tạo một hook gồm 4 bước:
- Chọn PreToolUse hay PostToolUse — Pre ngăn được tool call chạy; Post chạy sau khi tool đã dùng.
- Xác định loại tool call cần canh — chỉ rõ đúng tool nào sẽ kích hoạt hook (chính là
matcher). - Viết một command nhận tool call — command này nhận JSON data về tool call đề xuất qua standard input (stdin).
- (Nếu cần) command trả feedback cho Claude — exit code của command báo Claude allow hay block thao tác.
💡 Muốn biết chính xác tool nào đang có trong setup hiện tại (để đặt
matcher)? Hỏi thẳng Claude liệt kê — đặc biệt cần thiết vì danh sách tool thay đổi khi bạn thêm custom MCP server.
Dữ liệu tool call — JSON qua stdin
Khi hook command chạy, Claude gửi một khối JSON qua standard input mô tả tool call đề xuất:
{
"session_id": "2d6a1e4d-6...",
"transcript_path": "/Users/sg/...",
"hook_event_name": "PreToolUse",
"tool_name": "Read",
"tool_input": {
"file_path": "/code/queries/.env"
}
}
Command đọc JSON từ stdin → parse → quyết định allow/block dựa trên tool_name và tool_input. (Ví dụ trên: Claude định Read file /code/queries/.env.)
Exit code — cho hook KHÔNG nói JSON
Không phải hook nào cũng cần trả JSON; hook đơn giản thì exit code là đủ. Ba con số đáng nhớ:
| Exit code | Ý nghĩa |
|---|---|
0 |
Success. Nếu stdout là JSON → Claude parse. Plain text thì bị bỏ qua ở đa số event, NGOẠI TRỪ SessionStart, UserPromptSubmit, UserPromptExpansion — ở ba event này plain text được cộng thẳng vào context (đây chính là thứ làm hook giữ-state chạy được) |
2 |
Blocking error. stderr được feed ngược cho Claude làm context — đây là exit code chặn ở gần như mọi nơi |
| còn lại | Non-blocking. stderr chỉ được log, Claude chạy tiếp |
⚠️ Bẫy kinh điển — exit code
1: nhìn thì có vẻ là lỗi, nhưng nó KHÔNG chặn — Claude vẫn chạy command. Muốn dừng thật thì exit2, không phải1.
Vài nếp gấp nữa:
2chặn được cảStop— đó chính là cách bạn nói với Claude "chưa xong đâu".PostToolUsechạy SAU khi tool đã chạy → chặn ở đó là muộn, không cứu được tool call; nhưng vẫn feed text ngược cho Claude được.- Vài event bỏ qua chuyện chặn hoàn toàn (theo khoá:
Notification,SessionStart) — chúng hiện stderr rồi chạy tiếp, bất kể exit code.
PreToolUse trả quyết định bằng JSON — permissionDecision
Sức mạnh thật nằm ở PreToolUse, vì nó chặn được tool call trước khi chạy. Cách "nói chuyện ngược" với Claude: in JSON ra stdout rồi exit 0. Field then chốt là permissionDecision, nhận 3 giá trị:
| Giá trị | Nghĩa |
|---|---|
allow |
Cho call đi qua |
deny |
Chặn call |
ask |
Đẩy về cho user tự quyết |
Về kỹ thuật còn giá trị thứ tư là defer, nhưng nó chỉ áp cho non-interactive -p run — nơi một tiến trình gọi tạm dừng tool rồi resume sau. Hiếm khi phải dùng.
Khuôn JSON:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "...",
"updatedInput": {
"command": "..."
}
}
}
⭐ Redact thay vì block — updatedInput
Để ý field updatedInput: thay vì chặn một call, bạn VIẾT LẠI nó. Đây là cách bóc một secret ra khỏi lệnh bash mà vẫn cho lệnh chạy.
⚠️ Catch:
updatedInputthay TOÀN BỘ input object → phải echo lại những field bạn không đổi, không thì mất sạch.
Ví dụ thực chiến: một PreToolUse guardrail canh tool Bash. matcher chọn tool cần canh, thêm mệnh đề if (tuỳ chọn) để thu hẹp về đúng một loại lệnh. Claude được yêu cầu chạy một lệnh có secret trông như thật; hook chặn lại giữa đường, nhận diện pattern sk_live_, và thay bằng placeholder trước khi lệnh kịp thực thi.
→ Lệnh vẫn chạy. Việc vẫn xong. Nhưng secret không bao giờ đi qua. Đó là khác biệt giữa blocking và redacting — và hook làm được điều đó mỗi lần, không sót lần nào.
Ví dụ: chặn Claude đọc file .env
Use case kinh điển: ngăn Claude đọc file nhạy cảm như .env. Bản hiện hành của khoá chỉ chặn tool Read — matcher "Read", hook đọc tool_input.file_path:
- Path an toàn → exit
0(cho qua). - Path chứa
.env→ ghi lý do ra stderr + exit2→ Claude bị chặn và nhận được lời giải thích.
"PreToolUse": [
{
"matcher": "Read",
"hooks": [ { "type": "command", "command": "node $PWD/hooks/read_hook.js" } ]
}
]
// hooks/read_hook.js
process.stdin.setEncoding("utf8");
let input = "";
process.stdin.on("data", (d) => (input += d));
process.stdin.on("end", () => {
const toolArgs = JSON.parse(input);
const readPath = toolArgs.tool_input?.file_path || "";
if (readPath.includes(".env")) {
console.error("You cannot read the .env file"); // stderr -> Claude thấy
process.exit(2); // exit 2 = chặn
}
process.exit(0);
});
⚠️ Vì sao chỉ
Read? (điểm dễ nhầm — video cũ khác text mới) Video của khoá bản cũ có kèmGrep; bản text hiện hành đã bỏ, chỉ cònRead. Lý do: mỗi tool gửi input shape khác nhau —Readgửi{ file_path };Grepgửi{ pattern, path }vớipathlà thư mục tìm kiếm, không phải file;Bashgửi{ command }. Nên một check trênfile_pathbắt đượcReadnhưng không chặn đượcgrep API_KEYtoàn project haycat .envtrong Bash. Muốn phủ đủ: hoặc viết matcher riêng cho từng tool (soi đúng field của nó), hoặc — gọn hơn — dùngpermissions.denyvới rule như"Read(**/.env)"(áp đồng đều trên mọi tool). Xem hooks guide.
Áp dụng thực tế: đây là lúc "hook = guardrail tất định" trở nên cụ thể: chốt chặn KHÔNG nằm ở việc dặn Claude ("đừng đọc .env") — mà ở một script bên ngoài đọc
tool_input.file_pathrồi exit 2. Model có thể lơ lời dặn, nhưng không vượt được exit code. Hai điểm tôi luôn soi khi review một PreToolUse guard: (1) input shape của mỗi tool khác nhau → matcher = attack surface. Một hook checkfile_pathchỉ chặnRead;grep API_KEYtoàn project (Grepdùngpath= thư mục) haycat .env(Bashdùngcommand) vẫn lọt. Vì thế với thứ phải chặn tuyệt đối như secret, tôi không cố ghép nhiều matcher hook (dễ sót một shape) mà dùngpermissions.deny "Read(**/.env)"— rule áp đồng đều mọi tool, hook chỉ là lớp bổ trợ/feedback; (2) thông điệp stderr phải nói rõ lý do để Claude tự đổi hướng thay vì thử lại mù. Với khách Nhật, "chặn ngay tại tầng harness + log lại" là câu trả lời gọn cho "làm sao đảm bảo AI không chạm dữ liệu mật?" — không phải niềm tin, mà là cơ chế.
Ứng dụng thực tế của hook
- Code formatting — tự format file sau khi Claude sửa.
- Testing — tự chạy test khi file thay đổi.
- Access control — chặn Claude đọc/sửa file cụ thể.
- Code quality — chạy linter/type checker rồi feedback cho Claude.
- Logging — ghi lại file nào Claude access/modify.
- Validation — kiểm naming convention / coding standard.
⭐ Insight cốt lõi: PreToolUse = kiểm soát Claude ĐƯỢC LÀM GÌ (control what Claude can do); PostToolUse = tăng cường thứ Claude ĐÃ LÀM (enhance what Claude has done).
Áp dụng thực tế: hook là guardrail tất định — nó luôn chạy, không phụ thuộc Claude có "nhớ" hay không (khác chỉ thị mềm trong
CLAUDE.mdmà model có thể lơ). Cách tôi phân vai với hai loại: PreToolUse là chốt bảo vệ — vì chỉ nó block được, tôi đặt ở đây mọi thứ phải chặn tại nguồn: cấm đọc.env/secret, cấm sửa file sinh tự động (bài học repo này: đừng để build churn lọt vào commit), cấmrm -rfbậy. PostToolUse là chốt chuẩn hoá — nó không cứu được thao tác đã lỡ, nên đừng đặt "bảo vệ" ở đây; hợp nhất là auto-format/lint/typecheck + log để mọi thay đổi của Claude ra đúng chuẩn team mà không ai phải nhớ chạy tay. Về mặt Delivery, đây là "policy-as-code": thay vì viết một tài liệu "nhớ format trước khi push" rồi cầu mong đội tuân, tôi mã hoá luật vàosettings.jsoncommit chung — máy enforce, người khỏi cãi.
Gotchas — bảo mật hook & chia sẻ settings
Sau khi chạy npm run setup, trong .claude/ có hai file settings: settings.example.json (template, commit) và settings.local.json (sinh ra, cá nhân không commit). Vì sao tách ra? Do một khuyến nghị bảo mật hook trong docs Claude Code.
Khuyến nghị: dùng absolute path (KHÔNG dùng relative path) cho script hook. Lý do bảo mật: giảm path interception và binary planting attack — kẻ xấu đặt một script/binary giả trùng tên vào một thư mục (trên PATH hoặc thư mục tương đối) để hook chạy nhầm nó. Absolute path khoá cứng đúng file cần chạy.
Nhưng absolute path làm khó việc share settings.json: đường dẫn tuyệt đối tới script trên máy bạn khác máy người khác (mỗi người để project ở thư mục khác nhau).
Cách project này giải: settings.example.json đặt placeholder $PWD ở chỗ đường dẫn script. Khi chạy npm run setup: (1) cài dependencies, (2) chạy scripts/init-claude.js — script này thay $PWD bằng absolute path tới project trên máy bạn, copy settings.example.json rồi đổi tên thành settings.local.json.
→ Nhờ vậy vừa share được settings (commit template có $PWD), vừa giữ absolute path như khuyến nghị.
Áp dụng thực tế: đây là pattern "template + machine-specific generator" — commit cái khuôn (
settings.example.json), sinh cái thực thi theo từng máy (settings.local.json, không commit — đúng cặpCLAUDE.local.md/settings.local.json). Điểm architect: bảo mật (absolute path) và tính chia-sẻ-được kéo ngược nhau,init-claude.jslà cầu nối. Và bản thân generator ấy phải portable: khi tôi chạy trên Windows,$PWD=D:\...(dấu\là escape trong JSON ⇒ vỡJSON.parse) lại còn có dấu cách trong path ⇒ lệnhnode <path>đứt giữa chừng — phải forward-slash + quote path mới chạy. Bài học: một khuyến nghị bảo mật đúng vẫn có thể tạo bug vận hành nếu bỏ qua khác biệt OS. Với khách Nhật, "hook script luôn absolute path" là một gạch đầu dòng supply-chain hardening cụ thể (chống binary planting), không phải khẩu hiệu.
Hai hook thực chiến — TypeScript check & chống query trùng
Hook giải được vài điểm yếu kinh điển của AI-assisted dev trên project lớn. Hai ví dụ dưới đây chính là hooks/tsc.js và hooks/query_hook.js trong project thực hành.
1. TypeScript type-checking hook (PostToolUse)
Vấn đề gốc: khi Claude đổi một function signature, nó thường không cập nhật hết call site. VD nhờ thêm param verbose vào một hàm trong schema.ts → Claude sửa được definition nhưng quên call site ở main.ts → sinh type error mà Claude không bắt ngay.
Giải: một PostToolUse hook chạy TypeScript compiler sau mỗi lần sửa file:
- Chạy
tsc --noEmitđể kiểm type error. - Capture lỗi tìm được.
- Feed lỗi ngược cho Claude ngay.
- Prompt Claude đi sửa các file khác (call site).
→ Áp dụng cho mọi typed language có type checker. Ngôn ngữ untyped thì thay bằng automated tests.
2. Query duplication prevention hook (Agent SDK)
Vấn đề: project lớn nhiều query → Claude đôi khi viết trùng chức năng thay vì tái dùng code có sẵn — nhất là khi task phức tạp nhiều bước mà thao tác DB chỉ là một mảnh. VD "tạo Slack integration cảnh báo đơn pending > 3 ngày" → Claude viết query mới thay vì dùng getPendingOrders() đã tồn tại.
Giải: một review process:
- Trigger khi Claude sửa file trong
./queries. - Khởi chạy một instance Claude Code THỨ HAI (programmatically, qua Agent SDK).
- Nhờ instance thứ hai review thay đổi + kiểm query tương tự đã có.
- Nếu trùng → feed feedback cho instance gốc.
- Prompt Claude bỏ bản trùng, dùng lại code có sẵn.
Cân nhắc (trade-offs):
| TypeScript hook | Query duplication hook | |
|---|---|---|
| Chi phí | Nhẹ, chạy nhanh | Nặng — mỗi lần sửa ./queries khởi chạy 1 Claude mới (tốn time + API usage) |
| Lời khuyên | Bật thoải mái | Chỉ monitor thư mục quan trọng để giảm overhead |
Cả hai dùng Agent SDK để một Claude review công việc của Claude khác — "AI reviews AI".
Nguyên tắc mở rộng (áp cho project của bạn):
- Dùng compiler/linter output làm feedback tức thì.
- Dựng code review bằng AI instance riêng.
- Focus monitor thư mục giá trị cao (nơi tính nhất quán quan trọng nhất).
- Cân bằng lợi ích tự động hoá vs chi phí hiệu năng.
Áp dụng thực tế: hai hook này là hai archetype tôi luôn tách bạch.
tsc.jslà deterministic gate — trong sandbox nó gọi TS compiler API (ts.createProgram+noEmit), lỗi type là ground truth, đúng/sai không cãi; loại này tôi bật rộng tay vì rẻ và chắc.query_hook.jslà probabilistic gate — nóquery()qua Agent SDK để một Claude khác phán đoán trùng lặp; mạnh cho thứ không có compiler (trùng ngữ nghĩa, style), nhưng tốn tiền + latency + có thể sai, nên trong sandbox nó bịprocess.exit(0)tắt sẵn cho khỏi đốt credit. Quy tắc của tôi: việc nào có ground truth (type/test/lint) thì dùng cổng tất định; việc mờ mới nhờ AI, và luôn giới hạn ở thư mục critical để chi phí không phình. Với Delivery, đây là "shift-left": bắt lỗi ngay lúc sửa (call site lệch, query trùng) thay vì để lộ ở review/CI muộn hơn.
Còn hook nào nữa? Toàn bộ event + mẹo soi input
Pre/PostToolUse chỉ là hai loại phổ biến. Claude Code bắn 33 hook event (đối chiếu docs 08/2026) trong một session — không cần thuộc hết. Có một nhúm nhỏ bạn dùng đi dùng lại, và chúng nằm đúng những điểm trong agentic loop mà bạn muốn chen vào: session bắt đầu → prompt vào → tool được gọi → turn kết thúc.
| Hook event | Chạy khi |
|---|---|
PreToolUse |
TRƯỚC một tool call — primitive để cưỡng chế, loại duy nhất dừng được việc trước khi nó xảy ra |
PostToolUse |
SAU một tool call thành công — chỗ đặt auto-format / auto-lint |
Stop |
Claude muốn kết thúc turn — bạn từ chối được, nói "chưa xong đâu" nếu một điều kiện chưa thoả. Có bản song sinh SubagentStop cho lúc sub-agent xong |
PreCompact / PostCompact |
Trước và sau compaction |
InstructionsLoaded |
Một CLAUDE.md / rule file được load — tiện để audit xem thứ gì THẬT SỰ vào context |
SessionStart |
Đầu session, để mồi môi trường. Dùng source startup nếu chỉ muốn chạy lúc khởi động mới |
Notification |
Claude Code gửi notification — khi cần xin quyền dùng tool, hoặc sau khi idle 60 giây |
UserPromptSubmit |
User submit prompt, TRƯỚC khi Claude xử lý |
SessionEnd |
Một session kết thúc |
⚠️ Chỗ vấp nhiều người: muốn bơm lại context SAU compaction thì ĐỪNG dùng
PostCompact— dùngSessionStartvới matchercompact. Đó mới là cái thật sự đưa output của nó ngược vào hội thoại.
Chỗ rối: cấu trúc stdin đổi theo 2 trục:
- Theo loại hook (
PreToolUse,PostToolUse,Notification,Stop…) — mỗi loại field khác nhau. - Theo tool được gọi (chỉ với Pre/Post) —
tool_inputkhác nhau tuỳ tool.
VD một PostToolUse canh TodoWrite (tool Claude dùng để quản to-do) — có tool_name, tool_input.todos, và thêm tool_response:
{
"hook_event_name": "PostToolUse",
"tool_name": "TodoWrite",
"tool_input": { "todos": [{ "content": "write a readme", "status": "pending", "id": "1" }] },
"tool_response": { "oldTodos": [], "newTodos": [{ "content": "write a readme", "status": "pending", "id": "1" }] }
}
Còn một Stop hook thì không có tool_name/tool_input, chỉ:
{ "hook_event_name": "Stop", "stop_hook_active": false }
→ Shape khác nhau nhiều nên bạn có thể không biết trước command sẽ nhận cấu trúc gì.
Mẹo: hook "soi input". Gắn tạm một hook matcher: "*" chỉ để ghi input ra file:
"PostToolUse": [
{ "matcher": "*", "hooks": [ { "type": "command", "command": "jq . > post-log.json" } ] }
]
Nó ghi nguyên input vào post-log.json → mở ra xem chính xác dữ liệu nào được feed vào → viết command đúng field. (Chính là các dòng pre-log.json/post-log.json trong settings.example.json của sandbox.)
Giữ state qua một lần compact
Một pattern nữa đáng dựng sẵn. Khi Claude compact một hội thoại dài, nó đánh rơi rất nhiều chi tiết. Một SessionStart hook với matcher compact chạy ngay sau compaction — cho nó in ra một summary ngắn về những file bạn đang làm. Summary đó đi ngược vào context, nên Claude bắt nhịp tiếp thay vì bắt đầu lạnh.
(Ghép với bảng exit code ở trên mới thấy hết: SessionStart là một trong ba event mà plain text ở stdout được cộng vào context — chính vì thế hook giữ-state chỉ cần echo một đoạn text, khỏi cần JSON.)
Chốt lại phần hook
Hook biến một luật Claude thường theo thành luật nó luôn theo. Đừng dừng ở auto-format: guard tool bằng PreToolUse, gác cửa turn bằng Stop, và giữ state qua compact. Dựng thì tốn công lúc đầu, nhưng hoàn vốn ngay lần đầu nó bắt được một thứ trong lần chạy bạn còn chẳng ngồi xem.
Áp dụng thực tế: mấy event ngoài-tool này mở ra tự động hoá cả vòng đời phiên, không chỉ tool call:
SessionStartnạp context/env đầu phiên;UserPromptSubmitchèn context hoặc chặn prompt trước khi Claude đọc;Stopping tôi / chạy check cuối khi Claude xong;PreCompactlưu state trước khi lịch sử bị nén;Notificationbáo desktop khi Claude đang chờ tôi;SubagentStopgom kết quả Task. Nguyên tắc tôi ghim: chọn event HẸP nhất cho đúng tín hiệu cần (đừng nhét mọi thứ vàoPostToolUsematcher*). Và kỷ luật quan trọng nhất bài này dạy: đừng đoán schema — log nó ra rồi đọc. Cùng phản xạ debug với bài "JSON qua stdin", nay tổng quát hoá: hợp đồng input là theo-từng-event, nênjq . > log.jsonquan sát trước, code sau là bước bắt buộc, không phải tuỳ chọn.Ba thứ ở bản cập nhật này tôi thấy đáng giá nhất: (1) câu "request, not guarantee" — nó đặt tên đúng cho thứ tôi vẫn cãi nhau khi review process: luật nằm trong tài liệu là kỳ vọng, luật nằm trong code chạy mới là kiểm soát; y hệt lý do checklist dán tường thua một CI gate. (2) Redact thay vì block (
updatedInput) là thay đổi tư duy guardrail: guardrail không nhất thiết phải là "cấm" — nó có thể là "vẫn cho làm, nhưng làm phiên bản an toàn", nên không cản trở việc; đây đúng thứ tôi cần cho log/secret trong môi trường khách Nhật, nơi chặn thẳng thì dev sẽ tìm đường đi vòng. Nhớ cái bẫyupdatedInputthay cả object — quên echo lại field cũ là mất tham số, hỏng lệnh. (3)exit 1KHÔNG chặn là loại bug im lặng nguy hiểm nhất: hook trông như đang gác mà thực ra cửa vẫn mở — muốn chặn thìexit 2. CònSessionStartmatchercompact(không phảiPostCompact) là mảnh ghép cuối cho các phiên chạy dài: nén xong vẫn nhớ mình đang sửa file nào.
Phần tiếp theo: Claude Code: routine, headless & Agent SDK
Nguồn: Claude Code in Action (Anthropic Academy) — Copyright Anthropic. Phần đề thi thử cho khoá này nằm ở tab "Đề thi thử" trên trang tổng quan khoá.