防得了失誤,防不住意圖:給 AI 的 shell 裝一把鎖

裝上攔截器的第一天,agent 為了清理一個試用目錄,直接幹了一道 rm -rf,目標路徑還落在我的家目錄底下。還好攔截器第一時間硬生生把它擋了下來。

被攔之後,這傢伙沒打算放棄,自己換了條路:改用 Python 內嵌程式碼做同一件事。它不在命令列打刪除指令,而是把一段刪除目錄的程式碼嵌在內文中送進去。結果攔截器不只看指令表面,還會拆開內嵌程式碼做第二層比對(它內建一組內嵌程式碼分析規則,不是什麼通用的語意理解),於是又把它抓到了第二次。

兩次都不是我刻意設計的測試,而是它真實跑出來的指令。而且撞上的規則,正好就是我本來就要求人工確認的那批危險指令。裝好當天我核對過,工具內建的禁區跟我自己訂的清單幾乎對得上,一條白名單都沒調。這就是我這套工作流裡最真實的現場:agent 真的可能下出危險指令,而且這次被擋下來之後,它還自己換了個法子再試一次。

我裝的工具是 Dicklesworthstone/destructive_command_guard(以下簡稱 dcg)。先講清楚,這篇文章裡寫的所有行為,都綁定在我本機實測過的版本(v0.6.5 到 v0.8.0)。上游後來很快又接連釋出新版,截至 2026 年 8 月已經出到 v0.10.0,所以這裡記錄的是當時的快照,不是工具的現況。

引入動機其實沒什麼戲劇性。我自己對遞迴刪除這類指令本來就有警惕,也在社群看過幾起 agent 抹掉資料庫或目錄的災情。既然看到一個看起來夠成熟的開源工具,就順手把它掛上去了。

鎖裝在 AI 自己關不掉的地方

這道鎖掛在 PreToolUse hook 層:agent 每一條 shell 指令真正執行之前,都會先被這個掛載點叫起來檢查一次。agent 手上就有 shell,照理說改個設定就能把 hook 拆了,但鎖有兩道保命機制:接線被動過,工具會自己把它補回去;shell 每次啟動也會檢查接線還在不在,斷了就跳警報。拆鎖不是做不到,是做不到「無聲」,動手的那一刻就會被發現。

我測了三種情境:主對話、派出去的子 agent、批次工作流裡跑的 agent,也就是我平常讓 AI 工作的三種形式,攔截器全數生效。被攔下來的 agent 也只是乾淨地回報指令被擋,不會傻傻陷進無限重試的死迴圈。

這裡要把宣稱界定清楚:這把鎖給的保證,是 agent 沒辦法無聲地把它卸掉,不是物理上拆不掉。至於它有沒有別的路可以走,是另一件事,後面會算這筆帳。

這也呼應了我之前在第十五篇寫過的主張:能靠確定性機制保證的事,就不要寄望模型靠自律來遵守。

第一天就開始誤攔

鎖帶來的成本從來就不是理論。裝上去的第一天就連環誤攔,而且還分成兩型。

第一型是只要指令文字裡「提到」危險字串就攔。攔截器看的是 Bash 指令的表面文字,所以只要字串裡夾帶破壞性指令的樣子,不管它是引號裡拿來測試的文字,還是要寫進檔案的文件內容,全都會被當成危險指令按掉。

第二型更麻煩,往家目錄重新導向寫檔被當成截斷風險。這型直接打壞了我既有的自動化流程:我有一個長時間跑的監控流程,每一輪都要把狀態檔寫進家目錄,結果每次寫入都全部失敗。(這個流程後來把寫檔方式整個改掉、不再走 shell 重新導向,才恢復正常。)

我當時的預期是新工具本來就要慢慢調,所以沒動過拆掉它的念頭,就這樣繼續跑。

AI 解釋,人放行

誤攔如果沒有出口,這把鎖最後一定會被拆掉。

dcg 其實原本就帶有單次放行碼(一組一次性的數字碼,只放行被攔下的那一條指令,用過即失效),但尷尬的是 agent 根本看不到。Claude Code 轉給模型的拒絕訊息裡只留下理由,放行碼被吃掉了。出口明明在那裡,對 AI 來說卻是隱形的。

為了打通這條路,我在它外面包了一層腳本,把放行碼跟一份協作協議直接印在模型面前。協議裡訂了四個規則:不要改寫指令來繞過、說明執行意圖、請使用者手動輸入放行指令、放行後一字不差重發原指令。

這套機制最後真的走通了:腳本把放行碼揭露出來,AI 說明意圖並把放行指令交給我,我手動在終端機輸入放行,原指令一字不差重發成功。這就是「AI 解釋、人放行」,決定權握在人類手上,但給 AI 一個把話講清楚的機會。

先自首一個縫:放行碼是印給 AI 看的,真正擋著它「自己把放行指令跑掉」的,其實也是那份協議。這個縫先記著,後面講邊界的時候會回來收。

我原本以為要看誤報次數,後來才發現量錯了對象

剛開始試用的時候,我給自己設定的收案指標是「誤攔(把正常操作當危險指令擋下)的數量要降到可以接受的程度」。

第一週只粗估過一個量級:誤攔約 20 次。第二週改拿工具自己的攔截紀錄逐筆精算,共 34 次。兩種計算方式口徑不同、不能直接當成上升趨勢比,但至少看得出來一件事:誤攔完全沒有降到可以忽略的程度。照原本設定的標準,這工具早就該被判定失敗了。

我原本以為這代表試用搞砸了,後來交叉核對了兩個獨立的來源(工具留下的待放行清單,跟我自己的對話紀錄),才發現另一個關鍵事實:同一週裡,我手動輸入放行碼的次數是 0 次,因為誤攔導致任務失敗的次數也是 0 次。那 34 次被擋掉的指令,完全不需要我跳出來收拾,agent 自己換個寫法就繞過去把事情做完了。

這裡先劃一條線:協議禁止的是「被攔下的那條指令改寫再闖」:真踩到禁區,就該停下來請示;而這 34 次是誤攔,任務本身合法,agent 換一條合法的做法把事情做完(比如改用編輯工具寫檔),在我的定義裡不算違規,是我樂見的自我修復。

這時我才驚覺自己從一開始就量錯了對象。誤攔的次數根本不是重點,重點是誤攔的成本最後落在誰身上。如果落在人類身上,害我要頻繁跳出來手動放行、甚至任務卡死失敗,那才是真成本;但如果只是落在 AI 身上,讓它多繞一步路,這完全在可接受的範圍內。

我當下就把收案指標改成「誤攔有沒有造成打擾或任務失敗」。至於誤攔的總次數是多少,從那天起我就再也沒去統計過了。

繞路跟繞過,走的是同一條路

前面說這把鎖關不掉,這句話是真的。但關不掉的是鎖,不是門。

第一條路是把指令寫成腳本檔再執行。攔截器檢查的是你在命令列輸入的那串文字,一旦把指令內容寫進腳本檔,執行時攔截器只會看到你呼叫了某個檔案,根本看不見檔案裡面寫了什麼。寫腳本檔這個動作本身也不會被攔——它走的是編輯工具、不是 shell 指令,而鎖只掛在 shell 這一個入口。諷刺的是,這招正是我拿來避開第一型誤攔的標準做法。繞路跟繞過,技術上走的就是同一條路。

第二條路是把路徑改寫成展開後的絕對路徑。有一條防止搬移敏感目錄的規則,字面只比對 ~ 這種家目錄縮寫寫法。同一道搬移指令,用縮寫寫會被攔,換成展開後的絕對路徑就直接放行。

發現這條絕對路徑漏洞時,我最後決定不利用它,也不特別寫放行規則去蓋它。至於當時為什麼做出這個決定,我現在已經不記得了,總之就讓它維持原樣。

再回頭看第一天那個真攔場景。agent 被連續擋了兩次之後選擇停手,把問題交回給我。那時候協作協議都還沒寫出來,所以停手不是任何機制保證的結果,真要繞,落腳本檔那扇門當時就開著。後來把「不要繞過」寫成明文協議,正是想把這種停手從僥倖變成約定。但約定終究是約定:如果它哪天鐵了心要把指令落成腳本檔再跑,我現有的設定是擋不住的。

這件事的結論其實很直白:這把鎖防得了失誤,防不住意圖。一個因為失誤隨手下錯危險指令的 agent,會被擋在執行之前;但一個鐵了心認定「我非做成這件事不可」的 agent,門絕對不止一扇。意圖層面的最後防線,終究還是協議與人類本身。前面自首的那個縫也在這裡收:連那組放行碼,擋著 AI 自己拿去用的,同樣只是協議。

鎖自己也會壞

除了邊界問題,攔截器本身也會出狀況,在試用期間我就撞過兩次。

第一個狀況是評估超時會靜默放行(fail-open)。攔截器對安全評估設有時間上限,只要超時,預設行為竟然是直接放行,而且完全不發出警報。這在我的日誌裡真實發生過一次;日誌裡就只有那一行紀錄,當下放過去的是哪條指令、有沒有造成後果,已經查不到了。換句話說,這把鎖在最忙、延遲最高的時候,其實是偷偷開著的。上游後來在 issue #213 裡把「無聲」這部分修掉了:超時改成回傳明確的不確定判定,至少不再靜默放過。

第二個狀況是歷史紀錄功能會寫壞資料庫。它自帶的判定歷史資料庫底層有缺陷,會把資料寫壞並無上限膨脹。設定檔裡雖然寫著最大容量限制,但程式根本沒去引用那個參數。最後我把歷史紀錄功能關掉、刪掉整個資料庫,騰出 2.17GB 的硬碟空間。關掉之後攔截功能依然正常運作,被擋下來的日誌也照樣寫入。

這個資料庫問題是我自己在上游開了 issue #229 回報的,作者回應非常積極,三天內就換掉底層元件並發版修復。這裡必須幫上游講句公道話:問題已經修好了,我這裡記錄的是當時跑舊版本時的真實體驗。

四週之後的帳本

先把時間軸擺正,前面出現過的數字分屬三個時間窗:第一週粗估誤攔約 20 次;第二週逐筆精算 34 次,手動放行 0 次也是那一週的帳;改完判準之後我就不再統計誤攔。到了結案前的最後一週,帳本長這樣:145 次攔截(全是日常使用裡真實觸發的,不含任何測試,也不分真攔誤攔),我人工放行 3 次,介入率 2.1%(3 除以 145)。那 3 次都是我明確拍板過的動作,放行之後也都順利執行完畢。

範圍修正還是要補:這 145 次裡有一部分,是攔截器自己標成「光看指令文字判斷不了安危」的類別,我也沒有逐筆追查每個任務的最終結局。所以精確地說,我量到的是「以人工放行次數當代理指標,人的介入成本極低」,不是「誤攔完全零成本」。

四週下來,這類工具真正的驗收單位只有三項:有沒有引發新的安全退化、人的介入成本多高、有沒有造成任務失敗。誤攔總次數不在裡面。拿這三項對帳:新的安全退化沒看到(那個超時放行的洞在最後一週零發生);人的介入是 3 次;至於每一次攔截後任務有沒有全部善終,我沒有逐筆追查,最後一項不下滿分結論。它的定位也就清楚了:不是萬能的 shell 解析器,不是「判不出來就拒絕」的絕對硬閘,更不是唯一的安全邊界;即使固定測試全部通過,也不代表沒有未知的繞法。它就是多層防禦裡的其中一層,而且它自己都會壞過兩次,本來就沒資格排在最後一道。