Plugin4Shell 防護的核心,不是看到 Marketplace 寫著版本或 SHA 就放心,而是確認「宣告要拿的版本」與「磁碟上實際載入的內容」真的是同一份。這篇用 Claude Code 與 Codex 做主線,帶你在 10 分鐘內留下工具版本、來源 selector、實際 HEAD/檔案樹雜湊、能力與權限四層收據;若任何一層對不起來,就先隔離,不讓 Agent 幫你猜。
本文不重現攻擊。AIR Security 在 2026 年 9 月 17 日公開研究 PoC,描述一類 Git checkout 身分錯置:設定檔期待某個 commit,工具卻可能解析到另一個 ref。這是值得修補的供應鏈缺口,但不等於每位 Claude Code/Codex 使用者都遭入侵。完成本文後,你會知道如何判定 PASS、HOLD、STOP,並能留下可回滾的稽核收據。
先說結論:Plugin4Shell 防護要對齊四層
- 工具版本:先更新 Claude Code/Codex 本體,再處理 Plugin;舊客戶端可能在更新時重走有問題的解析路徑。
- 來源宣告:記錄 Marketplace、repo、
ref、完整 40 字元 SHA 與 manifest 版本。ref和版本字串都不等於 immutable commit。 - 實際內容:有
.git才驗HEAD^{commit};被複製成 cache、沒有 Git metadata 時,改記 deterministic tree digest,不能硬猜 HEAD。 - 執行邊界:盤點 hooks、MCP、LSP、
bin/、網路與秘密可見範圍;來源一致不代表程式碼安全。
這裡的「10 分鐘」指例行盤點能完成第一輪分類,不是事件調查的完工時間。若結果落在 HOLD/STOP,後續要由資安或平台負責人保存完整證據、確認受影響時間窗與憑證可達範圍;不要為了追求倒數計時,把未知項目硬改成 PASS。

Plugin4Shell 防護先懂:pin、ref、HEAD 為什麼不同?
把 Plugin 想成機場行李:manifest 的版本是行李牌,Marketplace 的 SHA 是你預期送上的航班,HEAD 才是輸送帶最後送到眼前的箱子。AIR 的重點不是「SHA 太短」,而是 client checkout 後沒有再問一次:目前 HEAD 是否逐字等於預期物件?
OpenAI 的公開修補證據最完整。Codex PR #34644明確描述 Git 可能把要求的 commit SHA 解讀成遠端 default branch 名稱,修法是 checkout 後解析 HEAD 並拒絕不相等;這個 PR 收進Codex 0.146.0。這只能證明相關路徑的修補,不代表所有更舊版本都具備 Plugin 功能,也不代表升級會自動清洗既有 cache。
風險也有邊界。GitHub.com 的官方命名規則拒絕看似完整 40 字元 Git object ID 的 branch/tag,因此 AIR 對 Claude Code/Codex 所述的 SHA 形狀分支手法不適用於 GitHub.com 託管來源;其他 Git host、其他解析路徑仍要個別驗證。主機限制是額外防線,不是跳過 client 更新與本機內容驗證的理由。
2026 年 9 月版本快照:先升級宿主,再信紀錄
截至 2026 年 9 月 22 日,Claude Code 最新公開版本是 2.1.278。AIR 表示 Plugin4Shell 在 2.1.179 修正,但 Anthropic 的該版公開 changelog 沒有點名這項修補,所以本文把 2.1.179 明確視為研究方說法,不包裝成 Anthropic security advisory。更重要的是 2.1.277 才修正部分安裝沒有記錄 commit、以及 pinned-commit 更新後 installed_plugins.json 保留舊 commit 的問題;因此實務上應升到當下最新版,再把紀錄欄位當線索,而不是內容證明。
同日 Codex 最新 release 是 0.155.1;已知 checkout equality 修補至少包含在 0.146.0。0.155.1 release notes 沒有宣稱「Plugin4Shell 修補」,所以正確說法是「目前版本包含先前公開的 checkout 驗證」,不是替官方創造一則不存在的公告。如果你只想比較兩個 Agent 的定位,可先補讀 Claude Code vs Codex,再回來做同一套收據。
Plugin4Shell 防護 10 分鐘盤點:6 步留下收據
第 0 步:有異常跡象時,先保存再更新
如果已看到來源不明、digest 改變或可疑程序,先關閉所有 Agent session、阻斷 Plugin origin 的網路、複製 cache 與設定到唯讀/離線位置,再依組織事件流程處理。不要先啟動一般 session、跑 Marketplace update、package install、hook、MCP server 或 Plugin 自帶的 scanner;這些動作會改變證據,甚至執行待查內容。下列 CLI inventory 適合例行預防盤點;疑似事件要改查離線副本。
第 1 步:記錄 Claude Code/Codex 本體版本
umask 077
plugin_audit_dir="$(mktemp -d "${TMPDIR:-/tmp}/plugin-audit.XXXXXX")" || exit 1
claude --version | tee "$plugin_audit_dir/claude-version.txt"
codex --version | tee "$plugin_audit_dir/codex-version.txt"
痛點:Plugin 已更新,不代表負責 checkout 的宿主已修。解法:先依 Claude Code 安裝文件與 Codex CLI 文件更新本體,再重新記版本。若公司由 MDM/套件庫管理,收據也要記 package source,避免 PATH 前方仍指向舊 binary。
第 2 步:列出已安裝 Plugin 與 Marketplace
claude plugin list --json > "$plugin_audit_dir/claude-plugins.json"
claude plugin marketplace list > "$plugin_audit_dir/claude-marketplaces.txt"
codex plugin list --json > "$plugin_audit_dir/codex-plugins.json"
codex plugin marketplace list --json > "$plugin_audit_dir/codex-marketplaces.json"
痛點:「Installed」只是狀態,不保證本機一定有對應 payload,更不保證 payload 來自哪個 commit。解法:每筆至少保存 qualified ID(plugin@marketplace)、版本、enabled、Marketplace root 與 install policy。依照 Claude Plugin reference,可再用 claude plugin details plugin@marketplace盤點 components;Codex 則依 OpenAI Plugin 結構文件,從 root 的 plugin.json、skills/、mcp.json、hooks/往下查。
第 3 步:固定「預期來源」,別把 version 當 commit
在 Marketplace manifest 找到 repo、source、ref/sha與 version,另存一份未更新前的快照。Claude 的 individual Git plugin source 可以宣告完整 sha;Marketplace 本身的 ref仍可能是 branch/tag。Codex 的 Marketplace Git source 與 Plugin entry 也可能使用 ref或 sha。只有完整 SHA 能當預期 commit;main、v1與 1.2.3都只是 selector/版本語意。
Claude 的預設 Plugin root 是 ~/.claude/plugins,也可能被 CLAUDE_CONFIG_DIR或 CLAUDE_CODE_PLUGIN_CACHE_DIR搬走;Codex 的 local cache 通常在 ~/.codex/plugins/cache/<marketplace>/<plugin>/<version>/。請以 inventory 實際 root 為準,不要把教學路徑直接當成你電腦的證據。
第 4 步:在離線副本比對實際 HEAD
若副本保留 .git,用停用系統/全域 Git config、hook、fsmonitor、credential helper 與 file protocol 的只讀查詢。不要 checkout、reset、submodule update 或執行任何檔案:
plugin_copy='/Volumes/offline/plugin-copy'
expected_sha='REPLACE_WITH_40_LOWERCASE_HEX'
[[ "$expected_sha" =~ ^[0-9a-f]{40}$ ]] || exit 1
actual_sha="$(
GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null GIT_TERMINAL_PROMPT=0 \
git -C "$plugin_copy" \
-c core.hooksPath=/dev/null \
-c core.fsmonitor=false \
-c credential.helper= \
-c protocol.file.allow=never \
rev-parse --verify 'HEAD^{commit}'
)" || exit 1
[[ "$actual_sha" == "$expected_sha" ]] || {
printf 'STOP: expected %s, got %s\n' "$expected_sha" "$actual_sha"
exit 3
}
printf 'PASS: %s\n' "$actual_sha"
這一步只證明 repo identity,還沒證明 worktree 沒漂移。若安裝器把 Plugin 複製成沒有 .git 的 cache,就對所有檔案、相對路徑、mode 與 symlink target 建立 tree SHA-256,再和「同一來源、同一 selector、由乾淨環境取回」的副本比較。只有一個孤立 digest 只能當今天的 baseline;沒有權威 digest 或可重建副本時,結論應是 HOLD。
python3 - "$plugin_copy" "$plugin_audit_dir/tree-receipt.json" <<'PY'
import hashlib, json, os, stat, sys
from pathlib import Path
root = Path(sys.argv[1]).expanduser().resolve(strict=True)
out = Path(sys.argv[2])
tree = hashlib.sha256()
file_count = total_bytes = 0
def sha256_file(path):
digest = hashlib.sha256()
size = 0
with path.open('rb') as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b''):
digest.update(chunk); size += len(chunk)
return digest.hexdigest(), size
entries = sorted(
(p for p in root.rglob('*') if '.git' not in p.relative_to(root).parts),
key=lambda p: p.relative_to(root).as_posix().encode('utf-8')
)
for path in entries:
rel = path.relative_to(root).as_posix()
mode = stat.S_IMODE(path.lstat().st_mode)
if path.is_symlink():
kind = 'symlink'; data = os.readlink(path).encode('utf-8')
item_sha = hashlib.sha256(data).hexdigest(); size = len(data)
elif path.is_file():
kind = 'file'; item_sha, size = sha256_file(path)
file_count += 1; total_bytes += size
elif path.is_dir():
kind = 'dir'; item_sha = hashlib.sha256(b'').hexdigest(); size = 0
else:
continue
tree.update(f'{kind}\0{rel}\0{mode:o}\0{size}\0{item_sha}\n'.encode())
receipt = {'file_count': file_count, 'total_bytes': total_bytes,
'tree_sha256': tree.hexdigest()}
out.write_text(json.dumps(receipt, indent=2) + '\n', encoding='utf-8')
print(json.dumps(receipt, indent=2))
PY
第 5 步:盤點會執行什麼,不要執行它
find "$plugin_copy" -type f \( \
-name 'plugin.json' -o -name 'hooks.json' -o \
-name 'mcp.json' -o -name '.mcp.json' -o -name '.lsp.json' \
\) -print
find "$plugin_copy" -type f -perm -111 -print
find "$plugin_copy" -type l -print
Claude 官方文件提醒,command hook 以使用者權限執行;啟用 Plugin 也可啟動 bundled MCP/LSP,而 Claude sandbox 的保證範圍不能直接外推到每個 Plugin subprocess。Codex 的 hook 會以 exact definition hash 要求信任,但「已信任」仍代表 OS account 程式碼;MCP 連到外部服務時也有自己的身份與權限。可先看 AI Agent 密鑰安全,把可見 secret、可寫目錄與網路 destination 壓到任務所需最小值。
第 6 步:用 PASS/HOLD/STOP 決定下一步
- PASS:宿主已更新、來源可還原、實際 HEAD 或乾淨 tree digest 一致、能力與權限已審核,且新 session 仍指向同一份內容。
- HOLD:cache 沒有 Git metadata、找不到可信對照物、Plugin list 與本機 payload 對不上,或權限邊界尚未釐清。保持停用/隔離,先補證據。
- STOP:宿主低於修補版本、完整 SHA 不符、tree 漂移、來源不明,或出現未核准 hook/MCP/可執行檔。保存證據後移除並重建。
一張可稽核收據,應該讓別人能重做你的判斷
收據不是終端機截圖,而是一組能機器比較的欄位。第一組記 captured_at_utc、作業系統、binary 的絕對路徑、版本與安裝來源;同一台電腦可能同時留著 npm、Homebrew 與舊版 binary,只寫「最新版」無法重現。第二組保存更新前的 Marketplace manifest bytes/digest、qualified Plugin ID、repo URL、source type、ref、預期 SHA 與 version,並註明哪些欄位只是 metadata。
第三組才是解析結果:Git top-level、完整 actual HEAD、tree digest、檔案數與總 bytes。若 Plugin 在 Marketplace repo 的子目錄,Marketplace HEAD 只描述 catalog snapshot,不自動等於 Plugin source commit;若是遠端 bundle,version 與 repository URL 也不能替代 immutable provenance。把「可直接觀察」與「由官方文件/研究推論」分欄,未能證明的地方寫 unknown,不要留空讓下一個人誤以為已通過。
第四組記錄 fresh session 真正選到的 install path、enabled 狀態、hooks/MCP/LSP/executable 清單、sandbox/approval 模式,以及這些 component 能讀寫哪些資料、連到哪些服務。不要把 token、完整環境變數、connector ID 或秘密內容塞進收據;只記 secret 類別、來源與是否可達。最後加上處置人、PASS/HOLD/STOP、理由、回滾 artifact digest 與再次驗證時間,才能把「我檢查過」升級成「任何人都能重跑」。
無害 fixture 驗證:HEAD 相同,檔案仍可能漂移
為了驗證流程而不重現 exploit,我在一次性本機 Git repo 放入兩個純文字檔:一份 plugin.json與一份不會執行的 SKILL.md。測試全程沒有 SHA 形狀 branch、沒有網路、沒有啟動 Claude Code/Codex,也沒有執行 Plugin。乾淨狀態的 HEAD 是 e1c7e378…,tree digest 是 3951054b…。
接著只在 SKILL.md加一行無害文字:HEAD 仍是 e1c7e378…,但 receipt 回報 head-match-tree-dirty,tree digest 變成 ae6c1302…;移除那行後 digest 回到 3951054b…。可觀察結果很直接:HEAD equality 是必要條件,不是完整的 bytes attestation。正式環境要保存 JSON receipt 的 timestamp、binary 版本、expected/actual SHA、tree digest、component inventory 與處置結果。
怎麼安全更新、停用與回滾?
例行修補的順序是:升級宿主 → 保存舊收據 → 停用 Plugin → 更新 Marketplace/重新安裝到新目錄 → 產生新收據 → 關閉並重開 session → 才恢復秘密與網路。Claude 可用 claude plugin disable plugin@marketplace --scope user,必要時重新啟動;本文查核的 Codex 0.155.1 沒有同名的 plugin disable子命令,請在 /plugins切換,或在專案 .codex/config.toml設置對應 Plugin 的 enabled = false。
「disabled」不等於檔案被凍結:OpenAI 的 Plugin 文件明示 Marketplace refresh 仍可能安裝/更新停用 Plugin 的檔案;Claude 的 auto-update 文件則說明官方 Marketplace 預設可在啟動後背景更新,而已開啟 session 可能繼續使用舊版本,直到 reload 或下次啟動。若決定移除,Claude 使用 claude plugin uninstall plugin@marketplace,Codex 使用 codex plugin remove plugin@marketplace;先保留可稽核副本,再做這些會改變狀態的動作。
真正的回滾不是把來源 branch 往回指,而是保留前一個已審核 artifact/tree digest,以新目錄原子切換;重啟後再驗一次 inventory、HEAD/tree 與權限。若懷疑內容曾在高權限 session 中執行,依可達範圍撤銷憑證,不要不分青紅皂白旋轉所有秘密,也不要先清 log 再調查。
最常踩的 5 個坑
- 先更新再取證:Marketplace 與 cache 已變,事後無法重建當時狀態。
- 只看版本/資料夾名:它們是 metadata,不是實際 bytes 的證明。
- 只比 HEAD:tracked file 可能被改、cache 也可能根本沒有
.git。 - 在可疑樹上跑 scanner:安裝相依套件、import module 或啟動 MCP 都可能執行候選內容。
- 把 sandbox 當萬靈丹:hook、外部 MCP、LSP 與遠端服務各有執行/授權邊界;要逐項盤點。
Plugin4Shell 防護常見問題 FAQ
1. 我裝過 Claude Code/Codex Plugin,就一定受影響嗎?
不能這樣判定。還要同時看 client 版本、Plugin 類型、Git host/解析路徑、來源 repo 控制權、是否發生 install/update,以及 Plugin 是否有會被啟動的 component。先做四層收據,不用產品名稱代替調查。
2. Plugin 在 GitHub.com,是否就不用更新?
不是。GitHub.com 阻擋的是 AIR 所述 40-hex branch 變體的必要前提;其他供應鏈風險、其他 source type 與本機漂移仍存在。宿主更新與內容驗證照做。
3. 升到 Claude 2.1.179 或 Codex 0.146.0,舊 cache 就乾淨嗎?
不能這樣推論。修補未來 checkout 路徑,不等於重新驗證或清除既有檔案;而 Claude 2.1.179 的修補歸因仍來自 AIR。升到最新版後,仍要重建/比對 cache 並重啟 session。
4. Cache 沒有 .git,要怎麼驗?
產生 deterministic tree digest,與同一來源 selector 在乾淨環境取得的副本或官方 digest 比較。若沒有可信對照,只能保存 baseline 並標成 HOLD,不能杜撰 commit。
5. Manifest version 和 commit SHA 有什麼差別?
version 是作者/catalog 的發行語意;SHA 是 Git object identity。作者可忘記改 version,branch/tag 也可移動,所以要分欄記錄。
6. Disable 後就能立刻繼續工作嗎?
先關閉舊 session。Claude 已載入版本可能持續到 reload/重啟;Codex disable 也不保證 Marketplace refresh 不碰檔案。重啟後重新確認 selected path 與 receipt。
7. 發現 mismatch,要立刻旋轉所有 API Key 嗎?
先依事件流程判斷該 Plugin 在可疑時間窗能讀到哪些 credential、session log 與網路目的地,再撤銷可達秘密。全面旋轉可能增加混亂,範圍過窄又會漏掉真正暴露面。
8. 多久做一次 Plugin 稽核?
至少在首次安裝、宿主升級、Marketplace/Plugin 更新、來源擁有者或權限改變時產生新收據;團隊可再依風險設定週期。重點是能比較前後差異,不是累積一堆無法對照的截圖。
給新手的 6 個帶走重點
- 先升級宿主,才處理 Plugin。
- 版本、ref、SHA、HEAD、tree digest 是五個不同欄位。
- 有
.git驗 HEAD;沒有就驗可重建的檔案樹。 - 先保存證據,再 update/remove。
- Plugin 的 hooks/MCP/LSP/bin 決定真正 blast radius。
- 最好的修補結果不是「看起來最新版」,而是能交出可重跑、可比較、可回滾的收據。
接著閱讀
左右滑動查看更多推薦
結語:不要只問「有沒有更新」,要問「現在究竟跑哪一份」
Plugin4Shell 防護把一個常被忽略的問題變得具體:pin 是意圖,checkout 後的 HEAD/檔案樹才是結果。今天先挑一個已安裝 Plugin,留下四層 receipt;若拿不出可信來源或乾淨對照,就停在 HOLD。能安全暫停、重建、重新驗收,比任何「Official」「Latest」標籤更接近真正的供應鏈控制。






