iT邦幫忙

2026 iThome 鐵人賽

DAY 17
0
Claude AI

資深工程師的 Claude Code 工作筆記系列 第 17 篇

Day 17:寫一個 hook,讓某些事永遠不會發生

  • 分享至 

  • xImage
  •  

前面講過三層護欄,機構知識、建議性政策、決定性控制,當時把最後一層講得比較抽象,只說它不需要人參與、直接允許或阻擋。今天就想把這層護欄具體攤開來講清楚,這是一個真的可以動手設定出來的機制,不只是一個比喻而已。

這個機制本身,實際深入了解之後,涵蓋的範圍比我原本以為的還要廣得多、也深得多。一開始剛接觸的時候,只知道它能在某個動作發生之前先擋下來,後來仔細深入查過才發現,整個工具的生命週期裡,從一個工作階段剛開始、到使用者送出一句話、到某個動作真正執行、一直到這個工作階段結束,幾乎每一個轉折點,都可以插進這個機制。範圍之廣,讓我重新、也更深刻地理解了一次,原來這層護欄能做的事,遠遠不只是擋住危險指令這麼單一而已,它更像是在整個流程裡,到處都暗自裝了一個隨時可以插手的接口。

建議跟強制,差在誰說了算

前面講過的機構知識跟建議性政策,說到底都是寫給模型看的文字,模型讀了,照著做,這中間有一層判斷力介入的空間,模型可能讀懂了、也可能沒讀懂,可能決定遵守、也可能在某個情境下覺得不遵守比較合理。這種依賴判斷力的機制,再怎麼寫得詳細,都留著一絲不確定。

今天要講的這層不一樣,它不是寫給模型看的建議,是寫死在系統裡的規則,模型完全不需要讀懂、也不需要同意,規則該擋就擋,不會因為模型覺得這次的情況比較特殊就放行。這種機制能做到的事情比較少,只能做出簡單的允許或阻擋,換來的卻是百分之百的確定性,這個交換我自己覺得非常值得,有些事情就是完全不該留任何商量的餘地。

這種確定性帶來的安心感,是過去單靠文件跟提醒做事的時候,完全體會不到的,跟建議性政策完全不是同一個等級。建議性政策寫得再仔細,心裡還是會有一絲不踏實,因為終究得靠模型自己去讀、去理解、去決定要不要照做,中間隔著一層判斷,就留著一絲不確定。這層寫死的機制不一樣,設定好之後,不需要再去擔心模型會不會在某次例外的情況下選擇不遵守,它本來就沒有選擇的空間,規則定義了什麼情況該擋,符合條件就是擋,沒有例外。這種把不確定性直接消除掉的做法,比起花更多力氣把建議寫得更仔細,有效得多。

整個機制的核心,是一個數字

這個機制運作的原理,說穿了意外地簡單,第一次搞懂的時候,甚至覺得有點不可思議,這麼單純的東西,怎麼能撐起一整層號稱決定性的控制。做法就是寫一段程式,在特定時機被呼叫,呼叫完之後回傳一個數字,這個數字就決定了接下來會發生什麼事。回傳零,代表沒事,照原本的計畫繼續走。回傳二,代表擋下來,這個動作不會發生。回傳其他數字,會被記錄下來,但不會真的擋住任何事。

這三個數字的分法,我覺得設計得很巧妙,零跟二之間,沒有模糊地帶,只有過跟不過,這種非黑即白的設計,正好符合這層機制該有的樣子,決定性控制就該是決定性的,不該留一個「算是過一半」的模糊選項讓人猶豫。中間那個只記錄不阻擋的數字,則是留給那種想觀察、但還不想真的動手擋的情境,可以先用這個方式試跑一陣子,看看平常會被記錄到什麼,再決定要不要把它升格成真的會擋下來的規則。

這個中間地帶的設計,我第一次看到的時候覺得特別貼心,直接反映了一種很實際的工作習慣,新設一條規則之前,先觀察一陣子,確認這條規則不會誤傷到正常的操作,再讓它真正開始發揮阻擋的作用。如果一開始就直接讓新規則具備阻擋的能力,萬一規則本身設計得不夠精準,誤判了正常的操作,反而會造成一堆原本不該被卡住的事被卡住,變成另一種形式的破壞。先觀察、再升格,這個順序,讓新規則上線這件事本身,也變成一件可以循序漸進、慢慢驗證過一輪才正式生效的事,而不是一次到位、賭一把看會不會出問題。

攔下來跟事後處理,是完全不同的兩件事

事前能攔,事後只能反應:0 允許、1 記錄但不擋、2 阻擋

這個機制還有一個很關鍵的細節,決定了它能做什麼、不能做什麼,一開始很容易被忽略,就是觸發的時間點。有一種時機是在某個動作真正發生之前,這時候回傳擋下來的數字,這個動作就真的不會發生,一切都還來得及,彷彿一道還沒關上的門,隨時可以選擇不走過去。另一種時機是在某個動作已經發生之後,這時候就算回傳同一個擋下來的數字,動作已經做完了,擋不住,能做的只剩下反應,像是提醒接下來不要再做類似的事,或者順手去做一些後續的補救,這時候的擋,擋的已經不是那扇門,是門後面接下來的路。

這個差異第一次搞懂的時候,我覺得特別重要,因為這決定了同一個想擋住某件事的念頭,該設在哪個時機點,才會真正有效。如果一個風險,是等它發生了才處理也沒差的,放在事後這個時機沒問題。但如果是那種一旦發生就無法挽回的事,一定要設在事前那個時機,等到事後才想擋,為時已晚,這種誤判我自己也犯過,設錯了時機,才發現原來這個機制從一開始就擋不住。

判斷一件事該設在事前還是事後,我會先問自己一個很具體的問題,這件事如果真的發生了,有沒有辦法完全復原回原本的樣子。能完全復原的,放在事後處理就夠了,因為就算真的發生了,也還有機會補救回來。不能完全復原、或者復原的代價極高的,一定要設在事前,這種事沒有事後補救的空間,唯一能依靠的就是在它真正發生之前就攔下來。這個問題看似簡單,卻能很快把一堆原本模糊的念頭,排出清楚的優先順序,知道哪些才是真正值得花力氣設在事前的那幾件事。

這件事也讓我想起前面講過除錯的道理,問題要在發生之前就被攔下來,跟問題發生之後才去查清楚原因,是完全不同等級的成本。設在事前的機制,等於是把除錯這件事提早到問題根本不會發生的那一刻,省掉了後面一整段查清楚、修好、驗證的過程,這種提早攔截換來的效益,遠遠超過事後處理省下來的那一點設定成本。

不是每個動作都要被攔一次,要瞄準

光是決定在哪個時機點插入這個機制還不夠,還得決定這個機制該對哪些動作生效,不該讓每一個動作都被攔下來檢查一次,那樣會把整個流程拖得又慢又煩,而且大部分的檢查都是做白工。

這件事其實跟前面講過的核心清單、延伸清單那套分類邏輯很像,不是所有動作都該被一視同仁對待,大部分的日常操作,根本不需要被攔下來看一眼,只有少數幾種真正關鍵的操作,才真正值得花這道額外的檢查手續。把這個分寸拿捏得準確,是讓整套機制能長期維持下去、不被嫌麻煩而關掉的關鍵所在。

這件事的做法,是先用一個粗略的條件,篩出某一類的動作,像是只鎖定某一種操作方式,再用一個更精細的條件,篩出這類動作裡符合特定模式的那幾個,像是只鎖定某種危險的參數組合。這兩層篩選合起來疊加,才能做到真正精準,只在真正需要被攔下來的那個瞬間介入,其他時候則完全不打擾,讓整個流程照常順利運作。

這種兩層篩選的設計,我自己反覆用過幾次之後,覺得本身就是一種很值得學起來的思考方式,先用粗略的條件快速縮小範圍,再用精細的條件在這個縮小後的範圍裡做真正準確的判斷,這個順序比反過來、一開始就想用最精細的條件一次到位,要穩健得多,也容易維護得多,因為粗略的那一層改動的機會比較少,真正常常需要微調的,是精細的那一層,兩層分開之後,調整起來互不干擾。

瞄準得不夠精準,會出現兩種相反的問題,一種是瞄得太寬,正常的操作也被攔下來問東問西,煩到讓人想乾脆關掉這個機制,另一種是瞄得太窄,真正該擋的那個危險操作,換了一種寫法就漏網了。這兩種問題我都踩過,瞄準這件事需要花時間慢慢調,一次就瞄準到剛剛好的機率不高。

瞄得太寬這種失敗模式,一開始感覺不出來有什麼大不了,後果其實比表面上看起來更嚴重得多。一開始只是覺得煩,久了之後,人會開始對這個機制的提示產生疲乏,看到跳出來的警示,不加思索就直接放行,這種習慣一旦養成,就算哪天真的跳出一個該認真看待的警示,也會被同樣的方式隨手放行過去,等於讓這整個機制形同虛設。瞄得太窄這種失敗模式,更麻煩的地方在於它不會主動暴露出來,平常跑起來一切正常,一直到真正出事那一刻,才會發現原來設定的範圍根本沒有涵蓋到那個情況,而那個時候通常已經來不及了,這種沉默的失敗,比明顯的失敗更難被提早察覺。

我自己現在設定範圍的時候,會先故意設得寬一點,寧可一開始煩一點,也不要一開始就漏掉重要的情況,之後再根據實際跑起來的狀況,慢慢把範圍收窄到真正需要的地方,這個收窄的過程通常得花上好幾輪反覆調整,不是一次設定就能定案。這個順序,跟前面講過的中間那個只記錄不擋的數字,其實是同一種思路,先求不漏掉,再求不打擾,兩者都做到,才算是真的瞄準。

實際長什麼樣子

講了這麼多原則,不如直接看一次具體的樣子。擋住危險指令這個場景,設定檔裡會寫成這樣,先鎖定某一種操作方式,再用一個更精細的條件,鎖定符合危險模式的那幾種寫法:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "if": "Bash(rm -rf *|sudo *)",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/block-dangerous.sh"
          }
        ]
      }
    ]
  }
}

這段設定本身不做任何判斷,它只負責兩件事,鎖定是哪一種工具、再鎖定符合哪一種危險模式,真正的判斷邏輯,寫在被呼叫的那段程式裡:

#!/bin/bash
input=$(cat)
command=$(echo "$input" | jq -r '.tool_input.command')

if echo "$command" | grep -qE '(^|\s)(rm -rf|sudo\s)'; then
  echo "危險指令被攔截:$command" >&2
  exit 2
fi

exit 0

讀這段程式的時候,我會刻意把注意力放在最後兩行,符合危險模式,印出理由、回傳二,這個動作就真的被擋下來。不符合,回傳零,什麼事都不會發生,流程照常繼續。前面講了一整段的時機、數字、篩選,落地之後,就是這十幾行而已。

事後收尾這個場景,寫法又不太一樣,鎖定的是編輯類的動作,結束之後自動跑一次格式整理:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/format-and-lint.sh",
            "args": ["${tool_input.file_path}"]
          }
        ]
      }
    ]
  }
}

這段設定跟前面擋危險指令那段,骨架幾乎一模一樣,差別只在鎖定的事件換成了事後那個時機,而且被呼叫的那段程式,就算真的回傳了擋下來的數字,也改變不了這次編輯已經寫進去的事實,它能做的只是在這之後,順手跑一次整理,這就是前面講過那個容易被誤解的細節,實際攤開來看的樣子。

我自己會設的幾種情境

我會設的三種情境:事前擋住、事後收尾、開場準備

實際會用到這個機制的場景,我自己歸納起來大概三類。第一類是擋住真正危險、不可逆的操作,像是某些刪除指令、某些會影響系統權限的指令,這種事一旦發生就回不去,一定要設在事前那個時機,擋下來不讓它執行。

這一類的規則,我寫的時候會特別謹慎小心,因為設得太寬,會連帶擋住一些其實沒問題、只是寫法恰好符合那個危險模式的正常操作,設得太窄,又可能漏掉同一種危險操作換了一種寫法之後的變形。這種拿捏,沒有一次就完美的方法,我會先把真正想擋的那幾種最經典的危險寫法列出來,設好之後,實際用一陣子,再根據過程中發現的漏網案例,慢慢把規則補得更完整,而不是一開始就想著一次寫到天衣無縫,天衣無縫這種目標,本身就不太實際。

第二類是在某個動作完成之後,順手做一次自動的收尾,像是每次改完程式碼,就自動跑一次格式整理跟靜態檢查,這種事放在事後的時機剛剛好,因為這件事本身不需要搶在發生前阻止,反而是做完之後立刻整理,效果最好。

這一類的用法,我自己一開始也沒有特別重視,後來才體會到,它其實是最容易被低估價值的一種。表面上看起來只是省了一個手動整理的小動作,實際上它確保了每一次改動之後,都有一個固定的標準被套用一次,不會因為當下趕時間、懶得做,就漏掉這道收尾的手續。人會累、會趕時間、會偶爾想偷懶,這種自動收尾的機制不會,它每次都照做,不會挑哪天心情不好就跳過,長期累積下來,整個系統維持的一致性,會比單靠人工記得要去做這件事,穩定得多,也可靠得多。

第三類是在一段新的工作開始之前,先把當下的背景狀態準備好,像是目前在哪個分支、有哪些還沒提交的改動,這種情境不是在擋什麼,是主動把有用的資訊塞進一開始的脈絡裡,省得每次都要重新問一輪才能進入狀況。

這一類用法跟前面兩類最大的不同之處,是它完全不涉及任何允許或阻擋,純粹是在補充資訊而已。這讓我意識到,這整套機制真正的本質,不是一個專門用來阻擋壞事的工具,是一個可以在流程裡任何一個轉折點插入任意行為的接口,阻擋只是這個接口眾多可能性裡的其中一種,補充背景資訊是另一種,兩者的地位是平等的,不該因為阻擋這個用法比較顯眼、比較容易想到,就忽略了補充資訊這種同樣重要、卻比較低調的用法。

前面提到的這三類場景,分別對應到事前擋住、事後收尾、一開始就準備好背景這三種彼此不同的用法,同一套機制,因為介入的時機不同,能發揮的作用也完全不一樣,不是只有擋壞事這一種用法。

這三類之外,其實還有一個更細緻的使用方式,是讓這個機制在某個時機點去問一個更深入的問題,而不是單純的允許或阻擋。像是遇到一個看起來模稜兩可、不是單純對錯能判斷的操作,可以設計成在這個時機點,另外啟動一次比較仔細的判斷,再根據那次判斷的結果,決定要不要真的放行。這種用法,等於是把一部分需要拿捏分寸的判斷,暫時借用到這個機制裡來處理,而不是完全交給寫死的規則,算是前面兩種做法之間的一種折衷。這種折衷用法我自己實際用得不算多,但每次遇到那種介於明顯該擋跟明顯該放行之間的灰色地帶,這種折衷反而是最實用、也最合理的解法。

這個發現讓我重新、也更徹底地理解了這整套機制存在的意義,一開始接觸的時候,很直覺地把它想成是一種防呆裝置,專門用來阻止壞事發生而已。實際摸熟之後才發現,它更像是一組可以插在流程裡任何一個轉折點的接口,擋壞事只是其中一種最容易想到的用法,另外兩種用法,收尾跟準備背景,同樣重要,卻比較少被人提起,因為它們不涉及阻擋,看起來不像一般人直覺想到的那種安全機制。

第三類場景裡提到的準備背景,我覺得特別值得多說一點。每次重新開始一段工作,如果都要從零開始重新確認現在是什麼狀態,這件事看起來瑣碎,累積起來其實佔掉不少力氣。把這件事自動化之後,每次一開始,背景資訊就已經準備好擺在那裡,不用再花一輪力氣去問、去查,這種省下來的力氣,雖然每次看起來不多,長期累積下來的效益,不會輸給擋住壞事的那種用法。

容易被誤解的一個細節

有一個細節,我自己一開始也理解錯,後來才搞懂,值得特別提出來講。在動作已經完成之後的那個時機點,就算回傳了擋下來的那個數字,擋住的從來不是已經發生的那個動作,是接下來的下一步。這件事聽起來很細微,實際影響很大,因為一開始會很直覺地以為,設了這個機制,就能讓已經做完的事情被撤銷,實際上完全不是這麼一回事,已經發生的事情就是發生了,這個機制頂多讓接下來不要繼續往壞的方向走下去。

搞懂這個細節之後,我對什麼事該設在哪個時機,判斷準確很多,真正不可挽回的事,一定得設在動作發生之前,設在之後再怎麼寫,都是在補救,不是在預防,這兩件事的代價天差地遠。

這個誤解之所以容易發生,我覺得跟一般人對「阻擋」這兩個字的直覺理解有關。平常講到阻擋,直覺想到的畫面,是一個東西還沒發生、被擋在門外,很少人會直覺想到,阻擋這個動作,也可能發生在事情已經過去之後,只是擋住的對象換成了「接下來」而不是「這一次」。語言本身的直覺,反而讓人容易誤判這個機制實際的作用範圍,這也是為什麼我覺得這件事值得特別拿出來講清楚,因為光靠字面上的意思去猜,很容易猜錯。

我自己後來發展出一個簡單的檢查方式,設定任何一個事後時機的機制之前,會先問自己,如果這個機制真的判定該擋,擋住的到底是什麼。如果答案是「擋住剛剛那個已經做完的動作」,這個設定從一開始就是錯的,因為那個時機點根本做不到這件事。如果答案是「擋住接下來不要繼續往同個方向走」,才是這個時機點真正能發揮的作用,這個小小的自問自答,能幫我提早抓出不少設錯時機的錯誤判斷。

這層機制的分量,要搭配前面那層一起看

這層寫死的機制,威力很大,也因為威力大,更要搭配前面講過的分層邏輯一起用,不是什麼都寫成這種硬性擋下來的規則。真正該用這個機制處理的,是那種後果嚴重到不能留任何商量餘地的事,其他大部分的情況,靠建議性的提醒就夠了,不需要動用這麼重的手段。

我自己會把這件事想成一種資源分配的問題,每多設一條寫死的規則,就多一分日後維護的負擔,也多一分誤判別人正常操作的風險,這兩種成本從來都不是零,得謹慎花在真正值得的地方。如果什麼情況都想用這個最重的手段去處理,表面上看起來最保險、最讓人安心,實際上會讓整套系統變得又重又難維護,規則彼此之間互相打架的機會也會跟著變多,到頭來反而更不可靠。把這層機制留給真正重要的那一小撮情境,其他交給更輕量的做法,才是讓整套系統長期維持下去的辦法。

這件事呼應了前面講過的道理,判斷力越強、涉入越深的那一層,越要謹慎限制它能做的事。這層寫死的機制,涉入的判斷力幾乎是零,完全就是機械式的允許或阻擋,正因為它完全不依賴判斷力,才適合拿來處理那種絕對不能出錯的情境,把判斷力留給真正需要判斷的地方去發揮。

這種分層的概念,聽起來有點抽象,不容易一下子體會到它的用處,實際動手設定的時候,我會刻意問自己一個很具體的問題,這條規則究竟要不要帶任何判斷的成分。完全不需要任何判斷、只要符合某個明確條件就該擋下來的事,才真正適合放進這層寫死的機制裡,像是某個特定的危險指令,這種事不管在什麼情境下出現都不該被允許,完全不需要模型再多想。反過來,需要衡量上下文、需要權衡利弊的事,放進這層機制裡反而是錯的,因為這層機制天生就沒有能力做這種需要拿捏分寸的判斷,硬塞進來,只會做出一堆看起來莫名其妙、卡住正常操作的誤判。

這個篩選判斷的動作,我自己覺得實際上比寫程式本身還要花更多心思。很多時候一開始覺得某件事很重要、很想設成寫死的規則,認真想過一輪才發現,這件事其實根本沒有一個放諸四海皆準的標準答案,只要換個情境,原本覺得該擋的事,可能反而變成該放行的,這種帶著強烈情境依賴的事,就完全不該放進這層機制裡,該留給前面那兩層更有彈性的做法去慢慢處理。

真正的價值,是它真的會被執行

回頭想這整件事,我覺得這層機制最大的價值,不是它能做的判斷有多聰明,剛好相反,它能做的判斷非常簡單,只有允許跟阻擋兩種。真正的價值在於,它不管在什麼情況下,都會被真的執行一次,不會因為模型當下的判斷覺得這次算了就跳過,也不會因為當下趕時間、場面比較混亂,就悄悄被略過。

這種「不管什麼情況都會被執行」的特性,說起來很簡單,實際上卻是整套紀律裡最難單靠人力去維持、卻反而最容易靠機制輕鬆做到的那個部分。人會累、會分心、會在壓力大的時候判斷失準,程式完全不會有這些問題,寫好的規則,不管當下是風平浪靜還是手忙腳亂,執行的方式永遠都一模一樣。把這份穩定性交給機制去扛,人才能真正把精力留給那些需要臨場判斷的地方,這種清楚的分工,我覺得才是這整套做法裡真正高明、也最值得學的地方。

這件事跟前面講過的,一條檢查真正的價值在於敢不敢拿來做決定,是同一個道理,只是這次把「敢不敢做決定」這件事,直接寫死在系統裡,變成不需要每次重新決定,系統自己就會照著做。

這兩件事放在一起看,我覺得剛好構成一個完整的循環。先靠一條敢真的拿來擋改動的檢查,確認某個行為的邊界在哪裡,確認清楚之後,把這個邊界直接寫成一段會被自動執行的規則,從此這個邊界就不再需要靠人每次重新確認,系統自己會守住。檢查負責確認「這件事該不該被允許」,這層機制負責「確保這件事以後每次都照著同一個答案執行」,兩者分工清楚,缺一個都不完整,只有檢查、沒有寫死的機制,每次都得重新判斷一次,只有寫死的機制、沒有檢查驗證過,很可能寫死的是一條根本沒想清楚的規則。這種把判斷提前做好、寫死下來的習慣,貫穿在這整套做法裡的每一個環節,只是這次用的是一段真正會被執行的程式,而不是一份靠人去唸的文件。

這件事也讓我重新認真想了一次,為什麼過去會一直覺得,寫一份清楚的文件、把規則講明白,就已經算是足夠。文件寫得再清楚,終究需要有人去讀、去記得、去在對的時候想起來照做,這整個過程裡,只要任何一個環節鬆懈下來,規則就瞬間形同虛設,白寫了。把規則寫成一段真正會被執行的程式之後,這整個依賴人去記得、去遵守的過程就被完全繞過去了,規則本身就此變成了流程不可分割的一部分,不再只是一份放在旁邊、等人有空才主動去翻閱的參考資料。

這個轉變,我覺得是今天這整件事裡最值得花時間反覆理解的核心。過去習慣把紀律這件事,想成是一種需要持續提醒自己、持續自律才能維持的狀態,這種狀態很累,而且很容易在疲憊、在趕時間的時候鬆懈下來。把紀律寫成真正會被執行的機制之後,這件事就不再需要靠意志力撐著,系統自己會在該發揮作用的時候發揮作用,不管當下是不是正處在容易鬆懈的狀態。這種把依賴意志力的事,轉換成依賴機制的事,我覺得是對抗人性弱點最有效的辦法,比任何提醒自己要自律的方法都管用。

寫到這裡回頭仔細看一遍,今天講的這整套機制,其實跟前面每一天講過的道理,都清楚地指向同一個方向,能寫成機制的,就不要留給意志力去扛,能提前想清楚的判斷,就不要拖到當下才匆忙現場決定。差別只在於,今天這個機制,把這個道理落實到了最底層、最靠近實際動作真正發生的那個瞬間,再往下,已經沒有更貼近執行現場的地方可以設防了,這已經是最後一道防線。


上一篇
Day 16:把踩過的坑變成一條真的敢用的檢查,不是寫一份報告
系列文
資深工程師的 Claude Code 工作筆記 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言