Architecture - 2026-10-09 - 4 分鐘閱讀

一個呼叫方,兩位核准人:讀者在我的核准程式裡找到的繞過

讀者指出 aine-control-plane 的參考 HTTP 伺服器讓一個呼叫方能自封 approver 角色,再送兩個名字湊滿兩人核准。這篇寫修補做了什麼、付出什麼代價、還有哪些沒修,以及試點裡「繞過人工核准」這條停止條件為什麼靠它才成立。

Decision ProvenanceGovernanceAgent AccountabilityAI-Native Engineering

上一篇最後加了一段更正。在 aine-control-plane 的 HTTP 路徑上,我說來自身分驗證層的那個欄位其實取自 X-AINE-Actor header。這個 header 沒有任何東西驗證。我為此開了 issue #8。計畫是先標明每個 actor 值的來源,再去數標記對不上的列。

10 月 2 日,讀者 @_firelinks 在那篇底下回覆,建議我調換順序。那則留言指出,同一個 header 除了決定紀錄上寫誰的名字,也決定誰有資格核准。對方沿著程式碼把路徑走了一遍,說明一個呼叫方怎麼過掉「需要兩人核准」的條件,也附了一個測試寫法。對方還推測這件事目前沒造成影響,大概是因為:伺服器預設只綁 127.0.0.1。等到有人為了讓團隊共用而把它放到 proxy 後面,這個前提就不在了。

順序的部分對方說得對。那則留言其實已經同時點出角色這條路和門檻這條路。我讀程式碼多確認到的是駁回也以同樣方式開著。修補前任何帶 header 的呼叫方送一筆駁回,核准狀態就會變成 rejected。

兩條繞過的路

aine-control-plane 的核准請求可以帶 required_roles 和 required_approvals。一張需要兩位具 approver 角色的人核准的請求,應該要等到兩個這樣的人都核准後才通過。在參考 HTTP 伺服器上,這兩項檢查讀的都是呼叫方自己給的值。

角色。 伺服器從 X-AINE-Roles header 組出 actor 的角色,decide_approval 拿這些角色去比 required_roles。呼叫方只要送 X-AINE-Roles: approver,就有了這個角色。

人數。 一張請求的核准決定裡,不重複的 actor_id 數量達到 required_approvals,就算通過。actor_id 取自 X-AINE-Actor。同一個呼叫方用兩個不同名字各送一次核准,就湊出了兩個不同的 actor。

兩者合起來,一個程序可以先建立一張需要兩人核准的請求。接著它假扮兩個人,各核准一次。紀錄上看到的,是一張由兩人門檻核准通過的請求。

規模要講清楚。aine-control-plane 是參考實作,SECURITY.md 本來就寫明它不提供身分驗證,也要求使用方在對外開放前自己提供已驗證的 actor。這件事沒有 CVE,我也不知道有任何部署被拿來這樣用。缺陷在於參考伺服器明明無法查核身分,卻照樣接受核准決定。文件也沒寫到「核准決定可以由未驗證的 header 身分做出」這一點。

修補

修補分兩個 PR,10 月 9 日合併。

PR #9 涵蓋 issue #8 的第 1、2 步:標記,加上每日的來源歸屬計數(GET /v1/audit/actor-attribution)。紀錄現在帶 actor_source,參考 HTTP transport 會填 header。有驗證身分的使用方則填別的值,例如 token。

PR #10 用上這個標記。ApprovalWorkflow.decide() 在查任何資料之前先看來源。來源缺漏、空白或是 header,就回 approval_identity_unverified,什麼都不記。核准與駁回都適用。在核心前面自己驗證身分、並填上來源的使用方,行為照舊。

這個 PR 在 tests/test_approval_identity.py 加了四個測試。其中三個透過真正的 HTTP 伺服器發請求,分別是自封、門檻和駁回。第四個直接呼叫 workflow,測來源缺漏或為 header 會被拒絕,以及已驗證的來源仍能運作。其中一個就是 @_firelinks 提的寫法:建一張 required_approvals: 2 的請求,用不同的 X-AINE-Actor 值和 approver 角色送兩次核准。預期結果是它停在 pending。另外兩個分別測呼叫方自封 approver,以及駁回同樣被擋。寫這篇時我把四個測試放到修補前一個 commit 上跑,四個全部失敗。換到合併後的 commit,四個全過。

@_firelinks 原本建議的做法沒那麼硬:header 來源的決定照樣記下來,只是不計入 required_approvals。PR #10 選擇直接拒絕,什麼都不存。原因是:記下來的未驗證決定,仍會被 _status() 和不按來源過濾的稽核讀取端讀到,要把它們濾掉得改 schema。所以目前先在門口拒絕。

代價與還沒修的部分

代價很直接。參考伺服器沒辦法再自己做核准決定。照原樣跑它的人仍可以建立和讀取核准請求。要做決定的話,前面就得有一層程式驗證呼叫者身分並填上來源。我認為參考伺服器停在這裡是對的,因為它本來就回答不了核准要問的那個問題。

兩個 PR 合併後,還有四件事沒處理。

  • PR #10 之前記下的決定沒有存來源,狀態計算仍會把它們算進去。要排除它們得改 schema。
  • 建立核准請求仍接受 header actor。任何人都還是能提出請求,被擋下的只有做決定這一步。
  • @_firelinks 也提到,目前沒有東西擋「請求者自己核准」。在只需要一人核准的情況下這很關鍵。這項檢查不需要 token,可以單獨上線,但還沒做。
  • issue #8 仍開著。最後一步是拿每次呼叫的 token ID 去跟身分提供者的發行紀錄 join,要等真正的 token 驗證做出來。

試點的停止條件為什麼靠它

在第一個 AI 試點怎麼挑那篇,我列了四條停止條件。第三條是:「繞過人工核准。 輸出沒經過核准就送出或執行。」

這條條件預設核准紀錄可以信,能如實說出有沒有人核准過。上面那種繞過不會觸發它。紀錄上會有一筆由兩位具名核准人通過的核准,角色也符合規定。每個欄位看起來都正確。照這條條件去查的人,找不到該停的理由。

跟核准有關的停止條件,可信程度取決於系統分不分得出是誰核准的。真要靠這條條件之前,不管用的是哪套核准工具,有兩件事可以先查:

  1. 做決定的當下,核准人的身分從哪裡來?如果答案是呼叫方自己送的值,這筆紀錄就是呼叫方的自述。
  2. 同一個呼叫方能不能產生兩筆核准?在同一個 session 用兩個名字試一次,看請求會不會往前走。

只要第一題答「呼叫方給的」或第二題試成功了,這條停止條件抓不到自己核准自己的輸出(完全沒有核准紀錄時,它仍會觸發)。這種情況下,試點要先在核准前面放一層已驗證的身分。有了它,這條條件才有意義。

謝謝 @_firelinks 把程式碼讀得夠細找到這個問題,也謝謝對方建議先處理核准。

正在做類似的東西?

我協助團隊交付 AI 原生系統——架構、可治理的自主性,以及支撐它們的證據紀律。一次對話就能看出合不合適。

洽談合作