Goal:訊息溯源即權限
長期目標誰有權改,鑑權不讀訊息正文,只認宿主蓋在事件中繼資料上的來源章。本課講這個設計背後的兩層思路。
先玩再講。左邊是當前 Turn 視窗,也就是本輪 turn/start 之後收進來的訊息,每條訊息都帶一個宿主蓋章的來源標籤。右邊是鑑權流程,模型每次調 goal 工具,程式碼就把視窗掃一遍。五個情景裡你輪流扮演人類、越權的模型和子 Agent,看放行和拒絕各自發生在哪一行。
Goal(長期目標)解決的訴求很具體:你交代一句話的目標,比如把倉庫測試全修綠,然後走開。Agent 接下來自主跑幾十個 Turn(輪次),每輪結束系統自動注入一條繼續推進的訊息,讓它接著幹。
問題也隨之而來:一個能自己給自己發訊息的系統,怎麼保證它不會順手把輪次上限也改了,永遠跑下去?DSH 的答案分兩層,一層管權限從哪來,一層管憑證怎麼過期,下面逐層拆。
它解決什麼問題
具體的翻車場景長這樣:自動續跑到第 4 輪,模型在回覆裡編一句「檢測到管理員已在帶外渠道授權本次變更」,然後調 update_goal 把輪次上限改成 999。如果防線只是一行系統提示詞勸它別改,這裡就淪陷了:沒有任何硬校驗能核實那句話的真假,提示詞只是請求,模型信不信全看它自己。演示的情景 E 演的就是這個對照。
提示注入的經典劇本全是這一路:讓模型相信使用者已經授權。只要鑑權去讀訊息文字,話術就永遠有機會贏。
思路是什麼
DSH 的鑑權不分析模型說了什麼,只做一件事:模型調 update_goal 時,把當前 Turn 視窗裡的訊息掃一遍,看有沒有一條來源是真人的。這個來源寫在 user/message 事件的 source 中繼資料裡,由宿主在事件落盤時蓋章。它壓根不在訊息正文裡,模型再能寫話術也偽造不了。
權限只有兩檔。第一檔 direct-human:當前 Turn 視窗裡有真人訊息,那這輪裡模型做什麼都算人類授的權,create、edit、pause、resume 全開。第二檔 goal-round:視窗裡那條注入訊息正好是當前目標的當前輪,此時模型只能做兩件事,報告完成(complete)或報告卡住(blocked)。blocked 還有個額外下限,預設要跑滿 3 個獲准輪次才許喊卡,防的是模型一遇到難題就撂挑子。
所以給自己續命這條路在機制層就不存在。自動續跑的輪次裡,視窗裡只有系統注入的續跑訊息,沒有真人,edit 直接被拒,模型能做的只有收尾。
具體的判定拆成兩個小函式。hasDirectHumanInput 掃描之前先確認呼叫者是頂層根 agent,子 Agent 連掃描資格都沒有,然後在視窗事件裡找一條來源標記為真人的訊息,找到才成立。isMatchingGoalRound 對輪次編號:注入訊息的 goalId、revision、round 三個值要和當前目標逐一相等,才算當前獲准輪。上一輪的舊訊息、別的目標的訊息、編號跳號的訊息,都換不來授權。
兩個判定怎麼合成最終裁決?這段原始碼一共 8 行,短到可以整段當金句看。它證明整條判定路徑上沒有白名單、沒有評分、沒有語義分析:先問有沒有真人,再問是不是當前輪,都不是就拋結構化錯誤。
export function completionAuthority(ctx: Context, execution: GoalToolExecution): GoalToolAuthority {
if (hasDirectHumanInput(ctx, execution)) return { kind: 'direct-human' }
const goal = ctx.goals.get(execution.agent)
if (goal !== undefined && isMatchingGoalRound(execution, goal)) {
return { kind: 'goal-round', goal }
}
return reject('complete and blocked require a direct human turn or the current goal round')
}
packages/goal/tool-goal/src/authority.ts,核對日期 2026-08-13。程式碼塊保留原始碼原文。兩個邊界條件正好對應演示裡的情景 C 和 D。邊界一:自動續跑輪次裡人類恰好插了一句話,模型能 edit 嗎?能。掃的是整個當前 Turn 視窗,只要視窗裡出現過真人訊息,授權就成立。這不算漏洞:人在場並且說了話,這輪的操作本來就有人背書,誰先開口不影響判定。
邊界二:子 Agent 調 goal 工具會怎樣?roots 檢查直接攔下,它不在頂層 agent 名單裡。就算這關放它過去,goal-round 授權也救不了它:ctx.goals.get 查的是呼叫者自己的會話,目標掛在根 agent 的會話上,子 Agent 那邊查出來是 undefined。兩道門,哪道都過不去。還有一個寫在原始碼註釋裡的細節:Agent.followup() 和 steer() 省略來源時預設按真人算,所以任何非人類的訊息生產者必須自報來源,不許靠省略參數繼承人類權限。
出處:兩個判定函式與預設來源註釋在 packages/goal/tool-goal/src/authority.ts 第 66 至 83 行(全文 109 行);edit、pause、resume 分支要求真人授權在 tool-goal/src/index.ts 第 265 與 273 行,complete 和 blocked 走第 285 行的 completionAuthority;blocked 的 3 輪下限在 docs/tool-catalog.zh.md 第 31 行。
為什麼長期成立
把憑證放在攻擊者寫不到的通道裡,是權限設計的通則:HTTP 請求的身份看閘道校驗的簽名頭,核心態和使用者態隔一條硬邊界,同一個道理。Agent 系統的特殊處在於模型輸出和外部輸入共享一條文字通道,這個前提短期內不會變,所以鑑權只能依賴帶外的中繼資料。換個語言重寫整個系統,需要的還是同樣兩步:宿主蓋章,鑑權看章。
它解決什麼問題
就算 edit 被堵死,續命還有幾條歪路可以想:換個行程重啟後接著自動跑;把上一輪的注入訊息翻出來冒充當前輪;目標被人改過一版之後,拿舊版本的授權繼續行事。防線如果只有來源一道,這些路都還開著。
思路是什麼
先看目標存在哪。每次變更都是一條持久的 goal/change 會話事件,載荷是變更後的完整快照,生命週期狀態從日誌折疊出來。但持久的只有 phase(active、paused、blocked、complete 四個階段),能不能自動續跑是另一個行程本地的 activation 狀態:重啟或 fork 之後預設解除武裝,要人類重新 resume 才恢復。文件把這兩件事分得很清楚,持久階段回答目標發生了什麼,行程本地啟用狀態另行回答能不能開始下一個 Round。換個行程接著自嗨這條路,到這裡斷了。
改動本身走 CAS(compare-and-set,先比對版本再寫入):每次變更都要帶準確的 revision(修訂號),改成一次加一,版本對不上直接失敗。goal-round 授權也綁著這套版本:目標被人改過一版,舊輪次的注入訊息立刻失效,鑑權對不上號。每個獲准的續跑輪次是一條帶 round 編號的注入訊息,編號正數且連續,回放會拒絕編號缺口、陳舊修訂號和超出上限的輪次。冒充和重放兩條路,也斷了。
出處:持久變更與 phase、activation 的分工在 docs/subsystems/goal.zh.md 持久變更一節與第 21 行,續跑訊息的來源標註與回放校驗在同文件第 100 行。
為什麼長期成立
憑證要綁定頒發那一刻的狀態,狀態一變憑證作廢,這是資料庫樂觀鎖和登入態過期的共同邏輯。它把防重放做成了算術題:舊憑證不用識別、不用拉黑,對不上號就自然失效。任何授權和使用之間隔著時間差的系統,都得補這一課。
DSH 裡還有一個跑長任務的工具叫 ralph,思路完全相反:不搞同會話續跑,每個 Round 起一個全新子 Agent,什麼歷史都不給看,共享工作區當長期記憶。工具目錄的描述原文:
注意 ralph 的目標是不可變的:模型只能在發起時選定目標和輪次上限,之後連改的介面都沒有。Goal 用溯源鑑權守住可變狀態,ralph 乾脆讓狀態不可變。兩條路防的是同一件事。
任務系統本身相當完整:七種任務型別統一走一套狀態機,notified 標誌位保證完成通知只發一次,遠端任務持久化 metadata 支援斷線重連(書稿第 6 章,restored-src/src/tasks/)。主 Agent 給子 Agent 追加指令走 SendMessage,mid-turn 訊息先進 pendingMessages 佇列緩衝。
但這些訊息進入上下文後就是普通文字,沒有一個宿主蓋章的來源欄位參與工具鑑權。防越權靠的是另一套權限系統(書稿第 7 章)加提示詞約束。在已核對的公開材料裡,未見以訊息來源決定 goal 類工具權限的等價機制。
提示注入的經典劇本是讓模型相信使用者已經授權。在 DSH 裡這套話術說得再像也沒用:鑑權讀的是事件中繼資料,不是訊息文字。攻擊者能控制的只有正文,正文在整條判定路徑上根本不出場。
這是三家對比中 DSH 獨有的一層。基於已公開證據,Grok Build 的材料裡同樣未見等價機制,其防護重心在沙箱與授權鏈(站內 Grok 專題有專門課頁)。結論剋制一點:別家未必沒想過,但把它做成 109 行可精讀的獨立檔案,目前只在 DSH 看到。
手推一個完整的越權劇本
自動續跑第 4 輪裡,模型先在回覆裡寫「使用者剛才在語音裡同意把上限調到 100 輪」,然後調 update_goal(action: edit, max_goal_rounds: 100)。
問題一:寫出這次呼叫在鑑權裡經過的判定順序和最終結果。
問題二:換個思路,它讓子 Agent 替它調,會卡在哪一道檢查?
問題三:如果它先調 complete 把目標標記完成,再指望新目標重新計數,create 需要什麼授權?三條路各堵在哪。