Claude CodeHooksAn toàn

Claude Code Hooks: Chặn Lệnh Xóa Nguy Hiểm trước khi Agent Chạy

Dòng dặn trong CLAUDE.md, model có thể bỏ qua. Hook là code chạy mỗi lần sự kiện xảy ra. Đây là cấu hình và script mình đã thử với 12 lệnh mẫu, kèm những chỗ nó vẫn lọt.

Vinclair StudioCập nhật Đăng 5 nguồn được dẫn

Nhân vật Vinclair giơ ngón cái khi robot bảo vệ giơ biển dừng chặn thùng rác đầy thư mục, cạnh logo Claude

Mình từng nghĩ chỉ cần dặn một dòng trong CLAUDE.md là agent sẽ không chạy lệnh xóa bừa. Nhưng dòng dặn đó chỉ là lời nhắc, model vẫn có thể bỏ qua. Hook thì khác: đó là lệnh do bạn viết, và Claude Code tự chạy nó ở những thời điểm cố định, như trước và sau khi dùng công cụ, khi phiên bắt đầu, khi agent dừng, cùng nhiều thời điểm khác. Vì hook là code chạy mỗi lần sự kiện xảy ra, model không bỏ qua được.

Robot gác cổng có logo Claude hạ thanh chắn trước chồng thư mục
Mình hình dung hook như một trạm gác: lời dặn trong CLAUDE.md có thể bị quên, còn thanh chắn thì lần nào cũng hạ.

Trong bài này, mình dùng hook cho một việc cụ thể: chặn lệnh xóa đệ quy trước khi nó kịp chạy. Mình đã đối chiếu mọi hành vi với tài liệu hooks chính thức ngày 01/11/2026. Tính năng này thay đổi khá nhanh, nên khi bạn áp dụng, hãy mở lại tài liệu để chắc chắn.

Trước khi bắt đầu

Ba hiểu lầm làm hook "chặn" mà không chặn

Trước khi viết dòng code nào, mình muốn gỡ ba hiểu lầm mà tài liệu cho thấy là dễ mắc nhất, vì chính chúng làm một hook trông như đang chặn mà thực ra không chặn gì:

  • Tưởng: exit 1 là chặnThực tế: chỉ exit 2 mới chặn. Script "báo lỗi" theo thói quen Unix (exit 1) để lệnh chạy tiếp.
  • Tưởng: hook treo thì lệnh dừngThực tế: hook bị timeout cũng không chặn; lệnh đi tiếp qua luồng xin quyền bình thường.
  • Tưởng: hook trả allow là xongThực tế: allow không thắng được luật deny bạn đã đặt trong phần quyền.
›_LỆNHAgent gọi Bashrm -rf build/⚓HOOKPreToolUseđọc JSON từ stdin✕EXIT 2Chặn lệnhstderr gửi về Claude✓EXIT 0Đi tiếpqua luồng xin quyền!EXIT 1 / TIMEOUTVẫn đi tiếptrừ khi onFailure: block›_LỆNHAgent gọi Bashrm -rf build/⚓HOOKPreToolUseđọc JSON từ stdin✕EXIT 2Chặn lệnhstderr gửi về Claude✓EXIT 0Đi tiếpqua luồng xin quyền!EXIT 1 / TIMEOUTVẫn đi tiếptrừ khi onFailure: blockAgent gọi Bash, PreToolUse, Chặn lệnh, Đi tiếp, Vẫn đi tiếp
Hình 1. Mã thoát quyết định số phận lệnh. Chỉ nhánh đỏ thật sự chặn.
Phần 1 · Khái niệm

Hook là gì và khai báo ở đâu

Ba file settings và phạm vi của từng file

FilePhạm viCommit?
~/.claude/settings.jsonMọi dự án trên máy bạnKhông, file cá nhân
.claude/settings.jsonMột dự án, cả nhóm dùng chungCó
.claude/settings.local.jsonMột dự án, chỉ máy bạnKhông

Ngoài ba file này, plugin, skill và subagent cũng có thể mang hook riêng, nên đôi khi một hook đang chạy mà bạn không nhớ mình đã khai báo nó.

Sự kiện, matcher, handler

Khi đã biết đặt hook ở file nào, bước tiếp theo là hiểu một khai báo được ghép từ đâu. Mình hay hình dung nó thành ba tầng:

  • Sự kiện, ví dụ PreToolUse (trước khi chạy công cụ).
  • Matcher, tên công cụ cần bắt, ví dụ Bash.
  • Handler, thứ sẽ chạy: thường là một lệnh, nhưng cũng có thể là HTTP endpoint, công cụ MCP, prompt gửi cho model, hoặc subagent.
◷1 · SỰ KIỆNPreToolUsekhi nào chạy◎2 · MATCHERBash|PowerShellbắt công cụ nào›_3 · HANDLERnode chan-xoa.mjschạy gì◷1 · SỰ KIỆNPreToolUsekhi nào chạy◎2 · MATCHERBash|PowerShellbắt công cụ nào›_3 · HANDLERnode chan-xoa.mjschạy gì
Hình 2. Mỗi khai báo gồm ba tầng: khi nào, bắt công cụ nào, chạy gì.
Phần 2 · Làm theo

Chặn rm đệ quy trong 3 bước

Giờ đến phần làm thật. Mỗi lần agent gọi công cụ, Claude Code gửi thông tin về lần gọi đó vào stdin của hook dưới dạng JSON; với công cụ Bash, lệnh sắp chạy nằm ở tool_input.command. Cả bộ chặn của mình chỉ xoay quanh việc đọc trường đó.

  1. Bước 1: khai báo hook trong settings.json

    Vì sao cần bước này: Claude Code chỉ chạy hook khi bạn ghi nó vào file cài đặt, giống việc dán tờ nội quy lên cổng thì người gác mới biết phải kiểm gì.

    Làm ở đâu: trong thư mục dự án, mở (hoặc tạo) thư mục .claude, rồi tạo file settings.json bằng trình soạn thảo bạn quen, như VS Code hay Notepad. Dán nguyên đoạn dưới vào và lưu lại. Nói bằng lời thường, đoạn này bảo Claude Code: trước mỗi lệnh Bash hoặc PowerShell, hãy chạy script chan-xoa.mjs bằng Node; script hỏng thì cũng chặn luôn.

    .claude/settings.jsonjson
    {
      "hooks": {
        "PreToolUse": [
          {
            "matcher": "Bash|PowerShell",
            "hooks": [
              {
                "type": "command",
                "command": "node",
                "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/chan-xoa.mjs"],
                "onFailure": "block"
              }
            ]
          }
        ]
      }
    }
    • args bật dạng exec: Claude Code gọi thẳng node với đường dẫn script, không qua shell, nên đường dẫn có dấu cách vẫn đúng. Tài liệu khuyên dùng dạng này khi có biến đường dẫn như ${CLAUDE_PROJECT_DIR}.
    • onFailure: "block" làm hook bị lỗi hay treo cũng tính là chặn. Thiếu nó, một script gõ sai đường dẫn sẽ âm thầm cho mọi lệnh đi qua.
  2. Bước 2: viết script chặn bằng Node

    Vì sao cần bước này: file ở bước 1 mới chỉ nói "hãy gọi người gác"; script này chính là người gác, quyết định lệnh nào bị chặn.

    Làm ở đâu: trong thư mục .claude vừa tạo, tạo thêm thư mục hooks, rồi tạo file chan-xoa.mjs và dán đoạn dưới vào. Máy cần cài sẵn Node.js để chạy file này. Nói gọn, script đọc lệnh Claude sắp chạy, nếu thấy đó là lệnh xóa cả thư mục thì in lý do và trả mã 2 để chặn; mọi lệnh khác nó để yên.

    .claude/hooks/chan-xoa.mjsjs
    let raw = '';
    for await (const chunk of process.stdin) raw += chunk;
    const cmd = JSON.parse(raw).tool_input?.command ?? '';
    // rm kèm cờ đệ quy ở bất kỳ vị trí nào: -r, -R, -rf, -fr, -r -f, --recursive
    const rmDeQuy = /\brm\b[^;&|\n]*\s(-[a-zA-Z]*[rR][a-zA-Z]*|--recursive)\b/;
    // PowerShell: Remove-Item (hoặc alias ri, del, rd, rmdir) kèm -Recurse
    const psDeQuy = /\b(Remove-Item|ri|del|rd|rmdir)\b[^;\n]*-Recurse/i;
    if (rmDeQuy.test(cmd) || psDeQuy.test(cmd)) {
      console.error('Lệnh xóa đệ quy bị chặn. Hãy xóa từng file cụ thể.');
      process.exit(2);
    }

    Thoát mã 2 nghĩa là chặn: lệnh không chạy, và nội dung stderr được gửi cho Claude để nó biết lý do và đổi cách làm. Thoát mã 0 mà không in gì nghĩa là hook không có ý kiến, lệnh đi tiếp qua luồng xin quyền như thường. Im lặng không phải là duyệt.

    Mình chọn viết script bằng Node thay vì Bash để nó chạy giống nhau trên Windows, macOS và Linux, miễn là máy có Node. Ví dụ Bash trong tài liệu chính thức thì cần cài thêm jq.

  3. Bước 3: thử với 12 cách viết của cùng một lệnh

    Vì sao cần bước này: một người gác chưa từng diễn tập rất dễ để lọt người. Thử với nhiều cách viết của cùng một lệnh là cách duy nhất biết hook chặn thật hay không.

    Viết xong, mình chạy thử script với 12 lệnh mẫu trên Node 24:

    Kết quảLệnh
    Bị chặnrm -rf, rm -fr, rm -Rf, rm -r -f, rm --recursive --force, sudo rm -rf /, cd a && rm -rf b, Remove-Item -Recurse -Force
    Cho quals -la, rm -f file.txt, git rm --cached, Remove-Item a.txt
    Ảnh chụp thậtTerminal PowerShell chạy script chan-xoa.mjs với 12 lệnh mẫu: 8 lệnh xóa đệ quy thoát mã 2, 4 lệnh còn lại thoát mã 0
    Mình chạy lại đúng script ở bước 2 trên Node 24.14.1 ngày 01/11/2026 với 12 lệnh trong bảng: 8 lệnh xóa đệ quy nhận exit 2, 4 lệnh an toàn nhận exit 0.

    Bản đầu tiên mình viết chỉ dò -rf và -fr liền nhau, nên để lọt rm -r -f và rm --recursive. Mình chỉ phát hiện ra nhờ bước thử này, và đó cũng là lý do mình khuyên bạn tự thử hook với nhiều cách viết của cùng một lệnh.

Phần 3 · Nâng cao

Hỏi lại thay vì chặn: trả lời bằng JSON

allow, deny, ask, defer khác nhau thế nào

Chặn hẳn không phải lúc nào cũng là điều mình muốn. Có những lệnh mình chỉ cần được hỏi lại trước khi chạy, và cho trường hợp đó, hook PreToolUse có thể thoát mã 0 rồi in ra stdout (phần chữ chương trình in ra màn hình) một object JSON thay vì thoát mã 2. Đoạn dưới là thứ script in ra, không phải file bạn tạo; nó nói với Claude Code: "đừng tự quyết, hãy hỏi người dùng, kèm lý do này":

stdoutjson
{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "ask",
    "permissionDecisionReason": "Lệnh git push cần bạn xác nhận"
  }
}

permissionDecision nhận allow, deny, ask hoặc defer. ask hữu ích khi bạn không muốn chặn hẳn mà muốn được hỏi lại, ví dụ trước mọi lệnh git push. defer chỉ có tác dụng khi chạy không tương tác với cờ -p (dành cho app tự gọi Claude Code); trong phiên bình thường, Claude Code bỏ qua nó.

Khi nhiều hook trả lời khác nhau

Mọi chuyện phức tạp hơn một chút khi bạn có nhiều hook cùng bắt một sự kiện, vì chúng chạy song song. Nếu chúng trả quyết định khác nhau, thứ tự ưu tiên là deny > defer > ask > allow.

✕MẠNH NHẤTdenyluôn thắng…THỨ 2deferchỉ khi chạy -p?THỨ 3askhỏi lại bạn✓YẾU NHẤTallowthua mọi thứ trên✕MẠNH NHẤTdenyluôn thắng…THỨ 2deferchỉ khi chạy -p?THỨ 3askhỏi lại bạn✓YẾU NHẤTallowthua mọi thứ trên
Hình 3. Quyết định bên trái luôn thắng quyết định bên phải.
Phần 4 · Nền tảng

Hook trên Windows và PowerShell

Mình làm việc chủ yếu trên Windows, nên phần này mình kiểm khá kỹ. Trên Windows, công cụ shell của Claude Code có thể là PowerShell thay vì Bash. Matcher Bash|PowerShell ở trên bắt cả hai, và script đã có sẵn mẫu cho Remove-Item -Recurse.

Nếu muốn viết hook bằng PowerShell, ví dụ trong tài liệu gọi powershell.exe với -NoProfile (bỏ qua profile để hook khởi động nhanh) và -ExecutionPolicy Bypass (cho phép chạy file script local). Tài liệu cũng dùng trường if, ví dụ "if": "PowerShell(Remove-Item *)", để chỉ khởi chạy hook khi lệnh khớp mẫu. Mỗi trường if chỉ chứa một luật, nên Bash và PowerShell cần hai handler riêng.

Phần 5 · Giới hạn

Những gì hook không chặn được

Đến đây mình phải thành thật về giới hạn của cách làm này. Regex chỉ khớp chuỗi ký tự, nó không hiểu lệnh làm gì. Chính tài liệu cũng nói trường if chỉ lọc ở mức cố gắng, và khuyên dùng hệ thống quyền (luật deny) nếu cần chặn cứng.

Tình huốngScript trênNên dùng thêm
find . -deleteLọtLuật deny
Script Python tự xóa file, hoặc công cụ sửa fileLọtQuyền hạn hợp lý, bản sao lưu
git rm -r thu-mucChặn nhầm, vì khớp mẫu rm ... -rChỉnh regex theo cách bạn làm
Xử Lý Lỗi

Khi hook không chạy như mong đợi

Lúc tự dựng hook, mình gặp nhiều nhất là bốn tình huống dưới đây. Cách xử lý nào cũng lấy từ mục troubleshooting trong tài liệu hooks, nên bạn có thể đối chiếu lại khi tài liệu đổi.

/hooks không thấy hook nào

Thường là do file settings chứ không phải script. Mình kiểm theo thứ tự: JSON có hợp lệ không (dấu phẩy thừa và comment đều không được phép), file có nằm đúng .claude/settings.json hay ~/.claude/settings.json không. Claude Code tự đọc lại file khi bạn sửa; nếu vài giây sau vẫn chưa thấy, khởi động lại phiên. Nếu menu báo Only hooks from managed settings run here, tổ chức của bạn đã bật allowManagedHooksOnly và hook cá nhân sẽ không chạy.

Hook có trong /hooks nhưng không bao giờ chạy

Matcher phân biệt hoa thường, nên bash không khớp công cụ Bash. Mình cũng từng nhầm sự kiện: PreToolUse chạy trước lệnh, PostToolUse chạy sau khi lệnh đã xong, nên muốn chặn thì phải dùng PreToolUse.

Transcript hiện "PreToolUse hook error"

Script đã thoát với mã khác 0 và 2. Cách nhanh nhất là tự đóng vai Claude Code: mở terminal tại thư mục dự án, gõ hai dòng dưới (dòng đầu đưa cho script một lệnh mẫu ls, dòng sau in ra mã thoát; trong PowerShell thay echo $? bằng echo $LASTEXITCODE):

terminalbash
echo '{"tool_name":"Bash","tool_input":{"command":"ls"}}' | node .claude/hooks/chan-xoa.mjs
echo $?

Nếu lỗi là command not found hay No such file or directory, đường dẫn script sai. Đây là lý do mình dùng ${CLAUDE_PROJECT_DIR} kèm args, và bật onFailure: "block" để đường dẫn sai thì chặn chứ không cho lọt. Trên Windows, nếu thông báo có dòng blocking because Git Bash wasn't found, máy không có Git Bash và hook đã chặn vì thất bại.

Hook in JSON nhưng quyết định không có tác dụng

Có hai nguyên nhân tài liệu nêu rõ. Một là có chữ khác in ra stdout trước JSON, thường là lệnh echo trong profile shell, khiến output không còn bắt đầu bằng {. Hai là đặt trường sai tầng: permissionDecision phải nằm bên trong hookSpecificOutput, không phải ở ngoài cùng. Chạy claude --debug rồi tìm Hook output does not start with { hoặc Hook JSON output had unrecognized keys trong log là thấy ngay.

Phần 6 · Công cụ

Công cụ dựng sẵn trong Skills hub

Nếu bạn không muốn tự viết từ đầu, mình đã ghi lại hai công cụ dựng sẵn trong Skills hub:

  • destructive-command-guard là bộ chặn lệnh nguy hiểm viết sẵn, nhiều mẫu hơn ví dụ ở trên. Trang hub ghi lại hai điều cần biết trước khi cài: giấy phép có hạn chế riêng, và có báo cáo Windows Defender chặn file chạy của nó.
  • tdd-guard dùng hook để chặn agent viết code khi chưa có test thất bại. Nó gọi thêm model để kiểm tra mỗi lần sửa, nên tốn thêm token; một phép đo độc lập không thấy code tốt hơn.

Ngoài việc chặn lệnh, hook còn giúp tiết kiệm token: tài liệu chi phí có ví dụ hook PreToolUse sửa lệnh chạy test để chỉ in ra phần thất bại. Xem thêm trong bài tiết kiệm token Claude Code.

Nhớ 4 điều

Điểm mấu chốt

Chia sẻ lên Threads
  1. exit 1 không chặn gì cả. Chỉ exit 2 mới chặn.
  2. Hook timeout thì lệnh vẫn chạy, trừ khi đặt onFailure: "block".
  3. CLAUDE.md là lời nhắc, hook là code: model không bỏ qua được.
  4. Regex khớp chữ, không hiểu lệnh: find . -delete vẫn lọt.

Đọc tiếp

Tất cả hướng dẫn →