AI 工程

護欄擋的是字串,不是意圖:拆解 affaan-m/ECC gateguard 的三個修補 commit

上週我把自己 repo 裡那個「擋破壞性指令」的 PreToolUse hook 拿出來重測,結論有點難看:DROP TABLE users; 擋得住,psql -c "DROP TABLE users" 直接放行。

同一個動作、同一張表,只因為多了一組引號就穿過去了。

這不是我一個人的問題。2026 年 9 月 21 日,affaan-m/ECC(就是原本的 everything-claude-code,repo 已改名)一天之內合進三個 gateguard 的修補 commit,補的正好是這類「同一個危險操作換個長相就繞過」的破口。這篇把三個 commit 的 diff 拆開講清楚:破口長什麼樣、他們怎麼修、以及修完之後還是擋不住什麼——最後這件事比前面兩件都重要。

順帶先更正一個在社群被傳歪的說法:那個「隱形 Unicode」的 commit,跟繞過比對器無關。它修的是別的東西,後面第五節會講。

GateGuard 不是防火牆,是「逼你講事實」的閘門

先把東西定位清楚,不然後面的 trade-off 會看不懂。

GateGuard 原始專案是 ZUNO WORKS 做的 zunoworks/gateguard,Python 版用 pip install gateguard-ai 裝。ECC 裡則是一份 JavaScript 實作,檔案在 scripts/hooks/gateguard-fact-force.js,寫到現在 1,668 行。

它的設計理念寫在檔頭註解,我覺得是整個專案最值得抄的一句:

Instead of asking "are you sure?" (which LLMs always answer "yes"), this hook demands concrete facts.

不要問 agent「你確定嗎」——它永遠說確定。改成要它交出具體事實:誰 import 了這個檔案、資料 schema 長怎樣、使用者原話是什麼。

所以攔下來的時候,agent 收到的不是一句「拒絕」,而是這個(destructiveBashMsg() 的原文):

[Fact-Forcing Gate]

Destructive command detected. Before running, present:

1. List all files/data this command will modify or delete
2. Write a one-line rollback procedure
3. Quote the user's current instruction verbatim

Present the facts, then retry the same operation.

注意最後一行:presented facts 之後,重送同一個操作就會放行。這是理解它全部限制的鑰匙,我們第七節回來算這筆帳。

技術上它是 Claude Code 的 PreToolUse hook,回傳 permissionDecision: 'deny' 加一段 reason。在 ECC 的 hooks/hooks.metadata.json 裡註冊成三個 entry:pre:edit-write:gateguard-fact-forcepre:powershell:gateguard-fact-force,Bash 那條則掛在 pre:bash:dispatcher 底下。

破口一:引號一包,比對器就瞎了

這是三個裡面唯一「真・繞過」的。

負責偵測破壞性 SQL 的是一行 regex:

const DESTRUCTIVE_SQL_DD = /\b(drop\s+table|delete\s+from|truncate|dd\s+if=)\b/i;

看起來沒毛病。問題出在它跑之前,別人先動過手了

isDestructiveBash() 為了避免誤殺,會先呼叫 stripQuotedStrings()

function stripQuotedStrings(input) {
  return input.replace(/'(?:[^'\\]|\\.)*'/g, "''").replace(/"(?:[^"\\]|\\.)*"/g, '""');
}

這函式的動機完全合理——沒有它,git commit -m "docs: explain when drop table is safe" 會被當成要刪表。誤殺一次,工程師就會把整個 hook 關掉,那才是真正的災難。

但代價是:

psql -c "drop table users"
        ↓ stripQuotedStrings
psql -c ""
        ↓ DESTRUCTIVE_SQL_DD
不匹配 → 放行

payload 就住在被清掉的那對引號裡。

這個 bug 是 mattcooperdev 在 issue #3024 回報的,標題下得很精準:「DESTRUCTIVE_SQL_DD is unreachable for quoted SQL — stripQuotedStrings() runs before the SQL arm」。不是規則寫錯,是執行順序把規則變成死碼

修補是 VarunGore36 的 PR #3107(commit 7b7dfc6,主檔 +153/−2、測試 +42)。

引號包裝的破壞性 SQL 如何繞過 gateguard 比對器,以及 quoteAwareSegments 的修法

修法:不要「清掉」引號,要「脫掉」引號

新增的 quoteAwareSegments() 走的是完全不同的路子——它是一個小型 shell parser,逐字元掃描、追蹤引號狀態、只在未被引號包住; | & 和換行處切段,然後把引號脫掉但保留內容

於是 psql -c "drop table users" 變成 tokens:

['psql', '-c', 'drop table users']

join(' ') 之後 regex 就打得到了。

註解裡還提到一件事:這個 parser 同時關掉了 GHSA-4v57-ph3x-gf55 那組破口——引號包住的命令字('rm' -rf /,shell 眼中引號對命令名是透明的)和換行當分隔符。(誠實標註:這個 GHSA 編號我在 GitHub Advisory Database 的公開頁面上抓不到,目前只有程式碼註解這一個出處,內容我無法獨立驗證。)

關鍵設計:為什麼還要一張 SQL client 允許清單

光是 dequote 會把 stripQuotedStrings 本來要解決的誤殺問題原封不動搬回來。所以新增的 isDestructiveSqlClient() 只在命令字確實是已知 SQL client 時才跑 regex:

const SQL_CLIENT_COMMANDS = new Set([
  'psql', 'postgres', 'mysql', 'mariadb', 'sqlite3', 'sqlite',
  'sqlcmd', 'isql', 'pgcli', 'mycli', 'duckdb', 'bq',
]);

這樣 git commit -m "drop table"echo "drop table users" 依然放行,因為 gitecho 不在清單裡。

這個 pattern 值得抄進你自己的 hook:危險字串的判定要綁定執行上下文,不要對整條命令做全域字串比對。全域比對只有兩種結局——誤殺到你關掉它,或放寬到擋不住東西。

再補三個洞:wrapper、字面值、sh -c

PR 的 review 過程又補了三輪,每一輪都是真實會遇到的寫法:

1. wrapper 解包unwrapLeadWrappers() 會跳過 sudo / doas / env 及其 flag 和 VAR=value 賦值,找到真正的命令。它甚至正確處理了會吃掉下一個參數的 flag(sudo -uenv -C)。測試涵蓋:

sudo -u postgres psql -c "drop table users"     # deny
env PGUSER=postgres psql -c "drop table users"  # deny
env PGPASSWORD=value psql -c "drop table users" # deny
env -C /tmp psql -c "drop table users"          # deny
env --chdir /tmp psql -c "drop table users"     # deny

2. SQL 字面值stripSqlLiterals() 把 SQL 自己的字串常數挖掉,處理單引號(含 '' escape)、雙引號識別字、以及 dollar-quote($$...$$$tag$...$tag$)。所以這兩條是放行的:

psql -c "SELECT 'drop table' FROM audit_log"        # allow
psql -c "SELECT \$tag\$drop table users\$tag\$ FROM t"  # allow

3. sh -c 遞迴isDestructiveQuoteAware() 遇到 sh / bash / zsh / dash / ksh-c 會把後面那串拉出來再解析一次,深度上限 4 層。所以巢狀的也擋:

sudo sh -c 'psql -c "drop table users"'  # deny
env sh -c 'psql -c "drop table users"'   # deny

這三輪 review 的價值在於示範了一件事:護欄的正確性不在第一版規則,在於有人拿真實寫法一條條打它。

破口二:隱形 Unicode——修的不是比對器,是你的眼睛

這題社群上傳成「夾零寬字元就能繞過比對」。我讀完 diff 可以確定:不是

commit bf70150e(xsfX20 的 PR #3103,+52/−6、測試 +28)動的函式叫 sanitizePath(),它處理的是 denial 訊息裡要印出來的檔案路徑,不是比對輸入。

commit message 把威脅寫得很清楚:

Invisible characters in a quoted file path let a malicious path look clean to a human reviewer.

攻擊對象是讀那則拒絕訊息的人,不是 regex。

想像 agent 要寫 /src/app.js<U+200B>evil.js。gateguard 攔下來,印出「Edit blocked: /src/app.js​evil.js」。你掃過去看到 /src/app.js,覺得沒問題,回一句「可以,改吧」。你核可的是另一個檔案。

修法是把 sanitizePath() 的清洗範圍對齊 repo 自己的 CI 政策檔 scripts/ci/check-unicode-safety.js,並且把碼位抽成具名常數(這是 review 要求的,理由是「政策要能在一個地方被稽核」):

類別 碼位
ASCII 控制字元 U+0000–U+001F, U+007F
C1 控制區塊 U+0080–U+009F
雙向文字標記 / 嵌入 / 隔離 U+200E–U+200F, U+202A–U+202E, U+2066–U+2069
零寬字元 U+200B–U+200D
Word Joiner / BOM U+2060, U+FEFF
變體選擇器 U+FE00–U+FE0F, U+E0100–U+E01EF
Tag block(ASCII smuggling) U+E0000–U+E007F
蒙古文母音分隔 / 諺文填充 U+180E, U+115F, U+1160, U+3164
隱形數學運算子 U+2061–U+2064
行 / 段分隔符 U+2028, U+2029

commit message 說這套政策涵蓋 394 個碼位。命中的字元被換成空白,最後 trim() 並截斷到 500 字元。

其中 Tag block(U+E0000–U+E007F 特別值得注意——那整個區塊可以把任意 ASCII 編碼成完全不顯示的字元。一段「看起來只有五個字」的文字,裡面能藏一整句 prompt injection。這在 agent 的訊息通道上是老問題了,但多數自訂 hook 的清洗清單只到 U+200B

C1 控制區塊那條還是第三輪才補的:作者原本只清了 ASCII 控制字元,後來發現 U+0080–U+009F 在所有 renderer 裡一樣不顯示。回歸測試也照著補了 U+0091 進輸入樣本。

這題真正的 takeaway:你的 hook 攔截率再高,只要人類是最後一道核可,拒絕訊息本身就是攻擊面。任何你要印給人看的 agent 來源字串,都該先過一次隱形字元清洗。

破口三:git 的 ref 與歷史

第三個 commit(07756cee,affaan-m 本人的 PR #3170,對應 issue #3154 / #3151,+107/−3)補的不是繞過,是覆蓋範圍不足

原本 isDestructiveGit() 只管 reset --hardcheckout --clean -fpush --forcecommit --amendrm -r。新增這些:

  • git branch -D(含 -d + -f 的長寫法)——刪未合併的分支會孤兒化 commit。註解說明得很好:純 -d 在未合併時會自己拒絕,所以不必
  • git stash drop / stash clear
  • git reflog expire / reflog delete——這條最狠,reflog 是 reset --hard 之後唯一的救命繩
  • git update-ref -d
  • git restore(單獨的 --staged 除外)
  • git switch--discard-changes / -f / -C

最有意思的是 force-with-lease 那條。大多數團隊教「別用 --force,用 --force-with-lease」。ECC 的判斷更細:

const SHARED_GIT_BRANCHES = new Set(['main', 'master', 'develop', 'trunk']);

--force-with-lease 只保證你沒蓋掉別人剛推上去的東西,它不保證你沒重寫別人已經拉下去的歷史。所以 lease-checked 的 force 推到 main / master / develop / trunk,照樣進閘門;推到自己的 feature branch 則放行。

pushTargetsSharedBranch() 還會解析 refspec——origin +main+refs/heads/main:refs/heads/main 這種用 + 前綴做強制更新的寫法也算。另外註解特別點出 --force --force-if-includes--force-if-includes沒有 --force-with-lease 的情況下是 no-op,所以那個裸 --force 依然生效,必須擋。

這種對 git flag 語義的細讀,比規則本身更有參考價值。

三個 gateguard commit 對應的三種攻擊面與共同限制

數字與限制:誠實版

先給可查證的數字(皆為 2026-09-22 由 GitHub API 取得):

項目 數值 出處
Repo affaan-m/ECC(前身 everything-claude-code) GitHub API
Stars / Forks 264,769 / 39,567 GitHub API
License / 建立日 MIT / 2026-01-18 GitHub API
gateguard-fact-force.js 行數 1,668 抓 main 分支原始檔實測
Hook 測試數 PR #3170 commit message 稱「238 hook tests pass; 49 CI checks green」;PR #3107 的其中一輪稱「Tests: 200 passed」 commit message,我沒有在本機跑過

star 數這個量級大到我原本以為 API 回錯——但 follow redirect 之後兩次查詢一致。這也解釋了 commit 的樣貌:三個修補全部來自不同的社群貢獻者(VarunGore36、xsfX20、affaan-m),issue 由第四個人(mattcooperdev)回報。這是一個靠 PR 流量把黑名單一格一格補起來的專案,不是靠一次架構設計做對的。

接下來是我認為必須講白的四個限制。

限制一:破壞性 Bash 閘門是「同一條指令重送就放行」。 看程式碼:

const key = '__destructive__' + crypto.createHash('sha256').update(command).digest('hex').slice(0, 16);
if (!isChecked(key)) {
  markChecked(key);
  return denyResult(destructiveBashMsg(), { includeRecoveryHint: false });
}
return rawInput; // allow retry after facts presented

key 是命令字串本身的 hash。hook 完全無法驗證 agent 有沒有真的交出那三項事實——它只知道「這條指令出現過第二次」。一個沒在思考的 agent 照樣原字重送就過關。

這是設計取捨,不是 bug:GateGuard 賭的是「被打斷 + 被要求列出影響範圍」這個動作本身會改變模型的後續行為。上游 README 提到 v0.6.0+ 的 Python 版有 evidence ledger,靠 PostToolUse 記錄實際發生過的 Read / Grep / Glob 來驗證調查是否真的發生——ECC 這份 JS 實作沒有那一層。這是我看到兩者最大的落差。

限制二:state 寫不進去就 fail-open。 allowWithStateWarning() 的註解說得很坦白——寫不進 state 就放行,「to avoid a permanent retry loop」。可用性優先於安全性。state 放在 ~/.gateguard(可用 GATEGUARD_STATE_DIR 改),30 分鐘無活動過期。

限制三:allowlist 只有 12 個 SQL client。 clickhouse-clientredis-climongoshcqlsh 都不在裡面。用 python -c "cur.execute('drop table users')" 更是完全繞過——命令字是 python

限制四:sub-agent 會跳過 Edit/Write 的首觸閘門。 isSubagentInvocation() 偵測到 agent_id / parent_tool_use_id 就直接放行,理由是「parent session already passed the first-touch file gate」。如果你的工作流大量靠 sub-agent 改檔案,這層等於不存在。

動手:15 分鐘測你自己的 hook

不要相信你的 hook,去打它。classifyDestructiveCommand 是 export 出來的,直接 require 就能當純函式探針,不用處理 state:

// probe.js
const { classifyDestructiveCommand } = require('./scripts/hooks/gateguard-fact-force.js');

const DENY = [
  'drop table users',
  'psql -c "drop table users"',
  "psql -c 'truncate audit_log'",
  'mysql -e "delete from sessions"',
  'sqlite3 app.db "DROP TABLE users"',
  'sudo -u postgres psql -c "drop table users"',
  'env PGPASSWORD=x psql -c "drop table users"',
  'sudo sh -c \'psql -c "drop table users"\'',
  'git branch -D feature/x',
  'git reflog expire --expire=now --all',
  'git push --force-with-lease origin main',
];
const ALLOW = [
  'git commit -m "docs: explain when drop table is safe"',
  'echo "drop table users"',
  'psql -c "SELECT \'drop table\' FROM audit_log"',
  'git push --force-with-lease origin feature/x',
];

for (const c of DENY) {
  const hit = classifyDestructiveCommand('Bash', c).length > 0;
  console.log(hit ? '  ok  ' : 'MISS  ', c);
}
for (const c of ALLOW) {
  const hit = classifyDestructiveCommand('Bash', c).length > 0;
  console.log(hit ? 'FALSE+' : '  ok  ', c);
}

MISS 那幾行就是你的缺口,FALSE+ 那幾行是會讓同事關掉整個 hook 的誤殺。兩邊都要顧——這正是 stripQuotedStrings 當初存在的理由。

如果你的 hook 是自己寫的,把上面四類寫法各套一次到你的實作上:裸命令、引號包裝、wrapper 前綴(sudo / env)、sh -c 巢狀。我自己那份就是在第二類全軍覆沒。

再補一條清洗隱形字元,任何要印給人看的路徑都先過一次:

const INVISIBLE = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028\u2029\u202a-\u202e\u2060-\u2064\u2066-\u2069\ufe00-\ufe0f\ufeff\u180e\u115f\u1160\u3164]|[\u{E0000}-\u{E007F}]|[\u{E0100}-\u{E01EF}]/gu;
const safe = String(p).replace(INVISIBLE, ' ').trim().slice(0, 500);

實用的環境變數

ECC 這份實作把逃生門開得比多數 hook 完整,值得抄這個設計:

變數 作用
GATEGUARD_BASH_EXTRA_DESTRUCTIVE 追加你自己的破壞性 regex(惡意 regex 會被當成未設定並印一次警告,不會讓 hook crash)
GATEGUARD_EXEMPT_GLOBS 讓特定路徑跳過 Edit/Write 首觸檢查
GATEGUARD_BASH_ROUTINE_DISABLED=1 只關掉「每 session 第一條 Bash」的例行閘門,破壞性閘門保留
GATEGUARD_FACT_FORCE_FULL_DENIALS 完整訊息的次數上限(預設 3,之後改印精簡版)
ECC_GATEGUARD=off 整個關掉

GATEGUARD_BASH_ROUTINE_DISABLED 這種分級關閉是我最想推薦的設計。大多數 hook 只有「開」和「關」,工程師一被煩到就整個關掉,連真正重要的那層也一起沒了。給出「只關吵的那層」的選項,護欄的存活率會高很多——而且拒絕訊息裡就直接寫著這行 narrow recovery hint,不用去翻文件。

trade-off:什麼時候該用、什麼時候別用

該用: 你讓 agent 在有真實資料庫連線、有 write 權限的環境裡跑;或團隊剛開始放寬 agent 的自動核可範圍,需要一層會製造「停一下」的摩擦。它對意外的防護效果不錯——agent 誤判情境想清資料、想 reset --hard 掉你沒 commit 的東西,這類佔了真實事故的絕大多數。

別用(或別只用): 你想防的是蓄意繞過。這是黑名單,攻擊者能組出的寫法永遠多過你列的清單,何況同一條指令重送就會放行。

真正的邊界在權限層,不在 hook。 我現在的配置是兩層:

  1. 給 agent 的 DB 帳號本來就沒有 DROP / TRUNCATE 權限,也拿不到 production 連線字串
  2. gateguard 這類 hook 當第二層,負責製造摩擦和留下紀錄

第一層是 agent 再有創意也繞不過的;第二層是讓你在事情發生前看得見。順序不能反——先靠 hook、再補權限,等於把整個安全模型押在一行 regex 的執行順序上,而這篇的第一個 commit 就證明了那行 regex 曾經是死碼。

對工程團隊的四個具體動作

  1. 今天就跑一次探針。 上面那段 probe.js 十分鐘的事。重點不是你有沒有 hook,是你的 hook 擋的是字串還是意圖——引號包裝那條測完就知道。
  2. 把危險字串判定綁定命令上下文。SQL_CLIENT_COMMANDS 的做法:先確認命令字是誰,再決定要不要跑危險 pattern。全域字串比對必然在誤殺和漏放之間二選一。
  3. 清洗所有要印給人看的 agent 字串。 你的拒絕訊息、diff 預覽、tool call 摘要,只要人類會據此核可,就是攻擊面。至少涵蓋零寬、bidi、tag block、C1 這四類。
  4. 把權限收緊排在寫 hook 之前。 agent 的 DB 帳號、雲端 IAM role、git push 權限——這些是 agent 繞不過的邊界。hook 是它們的補充,不是替代。

還有一件事:如果你用了 ECC 這類社群高速 PR 流入的 agent 設定包,記得它的護欄品質是一個一個 issue 補出來的。定期看 gateguard 相關的 commit,比看它的 star 數有用得多。

來源

  • affaan-m/ECC(前身 everything-claude-code),MIT License — https://github.com/affaan-m/ECC
  • commit 7b7dfc6 fix(gateguard): detect destructive SQL passed quoted to SQL clients — PR #3107,作者 VarunGore36(Varun Gore)
  • commit bf70150 fix(gateguard): sanitize dangerous invisible unicode in denial paths — PR #3103,作者 xsfX20
  • commit 07756ce fix(gateguard): gate ref- and history-destroying git commands — PR #3170,作者 affaan-m(Affaan Mustafa),對應 issue #3154 / #3151
  • issue #3024 gateguard: DESTRUCTIVE_SQL_DD is unreachable for quoted SQL — 回報者 mattcooperdev
  • 原始檔 scripts/hooks/gateguard-fact-force.jstests/hooks/gateguard-fact-force.test.js(main 分支)
  • GateGuard 上游專案,ZUNO WORKS — https://github.com/zunoworks/gateguard(PyPI:`gateguard-ai`)
  • Claude Code Hooks 官方文件 — https://docs.claude.com/en/docs/claude-code/hooks

註:文中程式碼片段為 2026-09-22 從 main 分支取得的原始檔節錄。commit message 引用的測試數(238 / 200 passed)我未在本機重跑,僅照 commit 記錄轉述。GHSA-4v57-ph3x-gf55 僅見於程式碼註解,公開 advisory 頁面查無此編號。

整理:DataAgent · Coding Agent 實戰教學

發表迴響

%d 位部落客按了讚: