昨天量到的判定全部發生在指令送出之前,過了判定就直接執行,執行完留下什麼由別的機制決定。這一輪跑三個指令:hermes hooks 讓每一次工具呼叫寫成一行紀錄、hermes security audit 查已裝元件的已知漏洞、hermes egress 讓沙箱拿到的是代換過的憑證。三個都有宣稱的覆蓋範圍,實際跑一輪看範圍到哪裡。
官方文件把 hook 分成四套,差別寫在同一張表裡:
| 系統 | 註冊方式 | 執行位置 | 攔得住工具呼叫 |
|---|---|---|---|
| Gateway hooks | ~/.hermes/hooks/ 下的 HOOK.yaml 加 handler.py |
僅 Gateway | 否 |
| Plugin hooks | plugin 裡呼叫 ctx.register_hook() |
CLI 與 Gateway | 是 |
| Shell hooks | config.yaml 的 hooks: 區塊指向腳本 |
CLI 與 Gateway | 是 |
| Outbound webhooks | config.yaml 的 hooks.outbound: |
CLI 與 Gateway | 否 |
hermes hooks 管的是第三套。它的優點是語言不限,腳本只要讀 stdin 的 JSON、往 stdout 吐 JSON 就行,並且跑在獨立的子行程裡。
線路協定的形狀是固定的。stdin 收到的酬載帶 hook_event_name、tool_name、tool_input、session_id、cwd 與 extra,stdout 回的 JSON 決定後續動作:
{"action": "block", "message": "..."}:擋下這次工具呼叫{"action": "modify", "args": {...}}:改寫參數之後才送出{"context": "..."}:把內容接到下一輪 LLM 輸入前面掛了兩個腳本上去測,一個 post_tool_call 把每次呼叫寫成一行 JSON,一個 pre_tool_call 在終端機指令碰到 .env 時退出碼 2。pre_tool_call 那個加了 fail_closed: true,因為預設行為是失敗放行,守門用途要反過來。
hermes hooks list 認得出兩個 hook,狀態欄寫的是另一回事:
[pre_tool_call]
- ...block_env_read.py matcher='terminal' (timeout=10s, ✗ not allowlisted)
hermes hooks doctor 把這件事講得更直接:
✓ script exists and is executable
✗ not allowlisted — hook will NOT fire at runtime
ℹ skipped JSON smoke test — not allowlisted yet.
2 issue(s) found. Fix before relying on these hooks.
組態裡宣告過與執行期會執行是兩個獨立的狀態。 中間隔著一道首次使用同意,以 (事件, 指令) 為單位記在 ~/.hermes/shell-hooks-allowlist.json。這個設計的理由在於 shell hook 是任意可執行檔,寫進組態的那個人與同意它跑的那個人要分開確認。
取得同意的時機落在 agent 啟動,hermes hooks 這三個子指令本身都不授與。實際跑一次帶 --accept-hooks 的 agent 之後,檔案才長出來:
{
"approved_at": "2026-10-04T05:53:10.961548Z",
"command": "...python.exe .../block_env_read.py",
"event": "pre_tool_call",
"script_mtime_at_approval": "2026-10-04T05:50:52.310834Z"
}
同意之後 doctor 轉成四個全過,包含一次合成酬載的實跑:
✓ allowlisted (approved 2026-10-04T05:53:10.961548Z)
✓ script unchanged since approval
✓ produced valid JSON on synthetic payload (exit=0, 0.125s)
紀錄裡那個 script_mtime_at_approval 有實際作用。往腳本末尾加一行註解再跑一次 doctor:
⚠ script modified since approval (was ...05:50:52, now ...13:17:51)
— review changes, then `hermes hooks revoke` + re-approve to refresh
同意的對象是當時那份內容,而非那個路徑。 腳本被改過之後 doctor 會指出來,提示的處置是撤銷後重新核准。
hermes hooks test 的行為與這條線分開,三個子指令對同意狀態的態度各不相同:
| 子指令 | 讀同意狀態 | 寫同意狀態 | 實際執行腳本 |
|---|---|---|---|
list |
是 | 否 | 否 |
doctor |
是 | 否 | 核准後才跑煙霧測試 |
test |
否 | 否 | 是 |
test 在尚未核准的狀態下照樣把腳本跑起來並印出結果,核准與否影響的是執行期會不會跑。測試通過與執行期生效各自獨立,看一個推不出另一個。
hooks test 可以用 --payload-file 換掉合成酬載的內容。第一次照線路協定寫 {"tool_input": {"command": "cat .env"}},結果是退出碼 0、擋不下來。
原因在 hermes_cli/hooks.py 的預設酬載表,pre_tool_call 那一筆用的鍵是 args:
"args": {"command": "echo hello"},
合成酬載走的是 invoke_hook() 的參數名,tool_input 是這些參數序列化成 JSON 送到腳本 stdin 時才出現的名字。payload.update(custom) 確實會覆蓋,覆蓋的對象得是 args。改成 {"args": {"command": "cat .env"}} 之後就對了:
| 酬載 | 退出碼 | dispatcher 收到的 |
|---|---|---|
{"args": {"command": "cat .env"}} |
2 | {"action": "block", "message": "policy: .env is out of bounds for terminal commands"} |
{"args": {"command": "ls -la"}} |
0 | 無 |
通過的那一次在記錄檔裡留下一行:
{"ts": "2026-10-04T13:52:10", "event": "post_tool_call", "tool": "terminal",
"session": "test-session", "input": {"command": "echo hello"}}
測試工具的參數名取自呼叫端的函式簽章,腳本看到的欄位名取自線路協定,兩邊對照著看才接得起來
hermes security audit 向 OSV.dev 查已安裝元件的已知漏洞。這台機器上跑出來:
| 項目 | 數值 |
|---|---|
| 掃描元件數 | 130 |
| 命中數 | 57 |
| 涉及套件數 | 8 |
| 相異漏洞編號 | 56(GHSA 29、PYSEC 27) |
命中數按嚴重度分布是 CRITICAL 2、HIGH 12、MODERATE 16、UNKNOWN 27。命中數高於相異漏洞編號,因為同一個編號同時影響兩個套件時會各算一次。26 筆集中在同一個套件的同一個版本。
退出碼的判定規則如下,--fail-on 預設 critical,有 2 筆 CRITICAL,因此退出碼是 1。嚴重度排序寫在 security_audit.py:
SEVERITY_ORDER = {
"UNKNOWN": 0, "LOW": 1, "MODERATE": 2, "MEDIUM": 2, "HIGH": 3, "CRITICAL": 4,
}
UNKNOWN 排在 LOW 底下,而 --fail-on 最低只開放 low。那 27 筆 UNKNOWN 在任何一個可選的門檻下都不會觸發閘門,佔命中數的將近一半。它們進得了報表,進不了自動化流程的判斷。
範圍這一側用 --skip-venv 量了一次:
{"total_components_scanned": 0, "finding_count": 0, "findings": []}
130 個元件全部來自 Hermes 的 venv,plugin 與 MCP 這兩類在這台機器上是零。MCP 那一類掃的是 config.yaml 裡釘版本的 npx/uvx server,而前幾天自建的那個 MCP 以 HTTP URL 掛載、跑在容器裡,整個落在掃描範圍之外。
hermes egress 管的是 iron-proxy,沙箱拿到的是不透明的代理 token,真正的 API 金鑰留在主機,由代理在請求送出前換回去。威脅模型寫得很清楚,針對的是沙箱被 prompt injection 之後讀走環境變數裡的金鑰。
hermes egress status 在未啟用的狀態下印出十個欄位:
Enabled no
Binary (missing)
Tunnel port 9090
Credential src env
Docker enforce yes
三件前置條件決定它現在能不能用:
~/.hermes/proxy/ca.crt
紀錄這一側目前有一個限制。iron-proxy v0.39 一個 daemon 只支援單一 bind,並且把 daemon 日誌與逐筆請求紀錄併在同一條輸出流,上游補上 log.audit_path 之後才分得開。要的是逐筆請求紀錄時,現在拿到的是混著 daemon 訊息的那一條流。
三個指令各自覆蓋一段範圍,範圍以外的元件與流量不在任何一份報表裡
| 概念 | 來源 | 年份 |
|---|---|---|
四套 hook 的分工、線路協定、退出碼 2 與 fail_closed 語意 |
Hermes Agent 文件 website/docs/user-guide/features/hooks.md,本機檢視 |
2026 |
合成酬載的 args 鍵與 payload.update() 的覆蓋順序 |
hermes_cli/hooks.py 的 _DEFAULT_PAYLOADS 與 _cmd_test() |
2026 |
同意紀錄的欄位、mtime 漂移提示、doctor 的四項檢查 |
本機實測,Hermes Agent v0.21.0,2026-10-04 | 2026 |
掃描元件數、命中分布、--skip-venv 的結果與退出碼 |
本機實測,hermes security audit --json,2026-10-04 |
2026 |
SEVERITY_ORDER 的排序與閘門判定 |
hermes_cli/security_audit.py |
2026 |
| iron-proxy 的威脅模型、後端限制與單一 bind 的紀錄限制 | Hermes Agent 文件 website/docs/user-guide/egress/iron-proxy.md |
2026 |
pre_tool_call 那個腳本第一次測的時候退出碼 0。直接用管線把同一份 JSON 餵給腳本,退出碼 2、訊息正常,腳本本身是對的。
接著把 post_tool_call 那個記錄用的 hook 也掛上去,讓它把收到的酬載寫進檔案。檔案裡那一行寫的是 {"command": "echo hello"},而測試時給的是 cat .env。到這裡才看得出來給出去的鍵名沒有被採用。
翻 hooks.py 的預設酬載表,pre_tool_call 那筆用的是 args,文件寫的 tool_input 則是腳本收到的那一端的名字,兩個名字指同一樣東西,分別屬於呼叫端與線路。查出這件事的工具是另一個為了別的目的掛上去的 hook,它記下的是實際送到腳本面前的內容,而非我以為送出去的內容。
排查一個收不到預期輸入的程式時,先讓它把實際收到的東西寫下來
把這些檢查接上排程,以及排程通知寫錯時間的那次故障。