iT邦幫忙

2026 iThome 鐵人賽

DAY 25
0

Day 24 的 handoff 最後停在 approval_gate pending。帳號 Agent 已經提出操作,但 handoff 只是把工作交給另一個角色,不代表使用者也同意執行。

這一篇我把畫面上的確認按鈕接起來。為了讓不可逆效果看得更清楚,我沒有真的重設帳號,而是新增一個「永久刪除 mock 工單附件」的實驗。模型可以提出要刪哪個附件,真正的核准範圍、一次性憑證與 handler 仍由後端控制。

在危險工具前多放一顆按鈕看起來很簡單,真正需要寫清楚的卻是:使用者按下去時看見了什麼,又精確授權了什麼。

今天真正呼叫 LLM 的位置

使用者 Prompt
  → Live LLM 提出 DeleteAttachmentProposal
  → 後端建立 operation preview
  → approval request:pending
  → 人類按下「確認執行這一項操作」
  → 後端簽發一次性 approval token
  → 驗證 action、subject、參數、期限與 nonce
  → delete_attachment_handler(mock)
  → token consumed

模型只參與第一段:把自然語言整理成結構化操作提案。按鈕不是模型工具,approval token 也不會放進 prompt、瀏覽器畫面或公開 trace。

一顆「確認」不能代表整個 session 都獲准

這次預覽會顯示五項資料:

欄位 畫面內容
操作 永久刪除 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 裡的「我同意」不算核准事件

模型收到的文字可以被 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 token?

按鈕送到後端後,程式會產生一份短效、一次性的核准憑證。憑證內容包含 approval ID、操作參數的 SHA-256 digest、到期時間與 nonce,再用伺服器端金鑰做 HMAC。

這裡的 token 不是登入 token,也不是讓 Agent 自由呼叫工具的 API key。它只用來回答一個問題:目前準備執行的這組參數,是否就是剛才預覽並確認的那一組?

執行成功後,nonce 會立刻標成已使用,request 也從 approved 轉成 consumed。重按按鈕不會再執行一次。

如果 Agent 在核准後換了附件呢?

我另外寫了一個 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 決策,但文件也提醒,大幅修改工具參數可能讓模型重新規劃並造成額外執行。對永久刪除這類操作,我選擇「參數一改,原核准失效,重新預覽」。

image
image

這個 Demo 沒有做到什麼

目前 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", "一次性核准不得重放")

本日程式碼

day-25-approval-scope


上一篇
Day 24|一個 Agent 把任務交出去後,權限到底去哪了?
系列文
從 LLM、Agent 到 Guardrails:30 天打造可控、安全、可驗證的 AI Agent 共 25 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言