Day 24 的 handoff 最後停在 approval_gate pending。帳號 Agent 已經提出操作,但 handoff 只是把工作交給另一個角色,不代表使用者也同意執行。
這一篇我把畫面上的確認按鈕接起來。為了讓不可逆效果看得更清楚,我沒有真的重設帳號,而是新增一個「永久刪除 mock 工單附件」的實驗。模型可以提出要刪哪個附件,真正的核准範圍、一次性憑證與 handler 仍由後端控制。
在危險工具前多放一顆按鈕看起來很簡單,真正需要寫清楚的卻是:使用者按下去時看見了什麼,又精確授權了什麼。
使用者 Prompt
→ Live LLM 提出 DeleteAttachmentProposal
→ 後端建立 operation preview
→ approval request:pending
→ 人類按下「確認執行這一項操作」
→ 後端簽發一次性 approval token
→ 驗證 action、subject、參數、期限與 nonce
→ delete_attachment_handler(mock)
→ token consumed
模型只參與第一段:把自然語言整理成結構化操作提案。按鈕不是模型工具,approval token 也不會放進 prompt、瀏覽器畫面或公開 trace。
這次預覽會顯示五項資料:
| 欄位 | 畫面內容 |
|---|---|
| 操作 | 永久刪除 mock 工單附件 |
| 目標 | TICKET-25 / diagnostic.log |
| 影響 | 此 demo 無法還原附件內容 |
| 原因 | 測試資料清理 |
| 狀態 | pending |
我沒有把按鈕解讀成「接下來都聽 Agent 的」。這次確認只對應一個 subject、一個 action 和一組完整參數。OWASP 的 Transaction Authorization 指南把這個原則稱為 What You See Is What You Sign:使用者應該看得到並確認交易中的重要資料;每次操作也應使用獨立、限時的授權資料。OWASP:Transaction Authorization Cheat Sheet
套到 Agent 上,我認為至少要綁定:
subject_id
action
ticket_id
attachment_id
reason
expires_at
nonce
如果只記錄「使用者按過同意」,同一個 approval 很容易被拿去刪另一個附件,甚至呼叫另一個工具。
模型收到的文字可以被 prompt injection、文件內容或對話歷史影響。假如我把這句話當成正式核准:
我同意,直接執行,不用再問。
那麼核准流程仍然活在同一個不可信文字通道裡。Day 25 要求另一個 HTTP request,由頁面上的獨立按鈕觸發;後端還會重新讀取 server-side pending request,而不是相信瀏覽器重新送回的操作內容。
LangChain 的 Human-in-the-loop middleware 也是在模型產生 tool call 後、工具執行前暫停,等待 approve、edit 或 reject。被核准的是 interrupt 中的 action request,不是對話裡一句「已獲准」。LangChain:Human-in-the-loop
這個 demo 沒有直接套 middleware,而是自己做一個縮小的 approval workflow,方便把 token 綁定與重放檢查拆開來看。
按鈕送到後端後,程式會產生一份短效、一次性的核准憑證。憑證內容包含 approval ID、操作參數的 SHA-256 digest、到期時間與 nonce,再用伺服器端金鑰做 HMAC。
這裡的 token 不是登入 token,也不是讓 Agent 自由呼叫工具的 API key。它只用來回答一個問題:目前準備執行的這組參數,是否就是剛才預覽並確認的那一組?
執行成功後,nonce 會立刻標成已使用,request 也從 approved 轉成 consumed。重按按鈕不會再執行一次。
我另外寫了一個 Promptfoo 回歸案例:預覽時是 diagnostic.log,執行前把參數換成 other-user.log。
兩份 operation 的 canonical JSON 不同,digest 也不同,因此結果會是:
approval_token issued
approval_scope blocked
irreversible_handler skipped
若系統允許核准後任意 edit,畫面和實際操作會不一致,也會形成典型的 time-of-check to time-of-use 問題。LangChain 支援 edit 決策,但文件也提醒,大幅修改工具參數可能讓模型重新規劃並造成額外執行。對永久刪除這類操作,我選擇「參數一改,原核准失效,重新預覽」。


目前 pending request 只存在單一 Python process 的記憶體;服務重啟後不會恢復,也沒有接正式身分驗證、雙人覆核或外部 ITSM。HMAC token 能示範完整性、到期與一次性使用,但不等於正式的金鑰管理方案。
我能確認的是四個邊界:模型只能提案、使用者先看預覽、後端重新驗證完整參數、token 用過即失效。至於核准者身分強度、持久化與多節點一致性,正式環境仍要另外設計。
單元測試會檢查相同 request 只能成功一次、變更附件後 scope mismatch、過期 token 不執行。Promptfoo 的 approval_scope_tamper_blocked 再從公開 provider 路徑確認參數被換掉時,handler_called 仍是 false。
Live 測試則真的呼叫目前設定的 Gemini 模型產生 DeleteAttachmentProposal。模型完成後只看到 pending 預覽;直到我另外按下確認,trace 才出現 token issued、scope matched、token consumed 與 handler completed。
下一篇會處理另一個更常見的情況:確認畫面送出後,人根本沒有回來。等待半小時不應該偷偷變成同意。
操作參數先轉成固定順序的 canonical payload,再計算 digest:
def canonical_payload(self) -> str:
return json.dumps(
{
"action": self.action,
"attachment_id": self.attachment_id,
"reason": self.reason,
"subject_id": self.subject_id,
"ticket_id": self.ticket_id,
},
ensure_ascii=False,
separators=(",", ":"),
sort_keys=True,
)
handler 執行前要重新比對 operation digest,並檢查一次性 nonce:
if payload["operation_digest"] != operation.digest():
return blocked("approval_scope", "操作參數已改變")
if payload["nonce"] in used_nonces:
return blocked("approval_replay", "一次性核准不得重放")