iT邦幫忙

2026 iThome 鐵人賽

DAY 27
0
AI Engineering

打造具備記憶與執行能力的常駐 AI Agent:Hermes Agent × Gemini × MCP 的 Harness 設計系列 第 27 篇

【Day 27】設定好了跟會不會跑是兩個狀態:hook 的同意與掃描看不到的那一半

  • 分享至 

  • xImage
  •  

昨天量到的判定全部發生在指令送出之前,過了判定就直接執行,執行完留下什麼由別的機制決定。這一輪跑三個指令:hermes hooks 讓每一次工具呼叫寫成一行紀錄、hermes security audit 查已裝元件的已知漏洞、hermes egress 讓沙箱拿到的是代換過的憑證。三個都有宣稱的覆蓋範圍,實際跑一輪看範圍到哪裡。


四套 hook 裡只有兩套攔得住工具呼叫

官方文件把 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 輸入前面
  • 退出碼 2:等同 block,訊息依序取 stdout 的 JSON、stderr 前 400 字元、預設字串

掛了兩個腳本上去測,一個 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"}}

測試工具的參數名取自呼叫端的函式簽章,腳本看到的欄位名取自線路協定,兩邊對照著看才接得起來


掃了 130 個元件,閘門看得到的只有一半

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

三件前置條件決定它現在能不能用:

  • 只接 Docker 後端:Modal、Daytona、SSH、Singularity 這四種後端目前取不到代理的環境變數與 CA 掛載
  • 要先有本機 CA:代理需要終止 TLS 才能改寫標頭,沙箱得信任 ~/.hermes/proxy/ca.crt
  • 邊界含 CA 私鑰:流量被導向攻擊者控制的代理時,token 的保證失效

紀錄這一側目前有一個限制。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,它記下的是實際送到腳本面前的內容,而非我以為送出去的內容。

排查一個收不到預期輸入的程式時,先讓它把實際收到的東西寫下來


明天

把這些檢查接上排程,以及排程通知寫錯時間的那次故障。


上一篇
【Day 26】同一支私鑰,換個路徑寫法就從放行變成要確認
下一篇
【Day 28】Gateway 在跑、下次執行卻已過期 769 小時:排程任務的健康檢查
系列文
打造具備記憶與執行能力的常駐 AI Agent:Hermes Agent × Gemini × MCP 的 Harness 設計 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言