DeepSeek Harness · 会话与循环

Goal:消息溯源即权限

长期目标谁有权改,鉴权不读消息正文,只认宿主盖在事件元数据上的来源章。本课讲这个设计背后的两层思路。

课程目标读完你能说清三件事:DSH 怎么让 Agent 在同一个会话里自主跑几十轮;改目标的权限为什么跟着消息来源走,模型没法给自己续命;以及防提示注入的正确层次为什么是机制,提示词只是劝阻。
交互演示 · 溯源鉴权攻防台

先玩再讲。左边是当前 Turn 窗口,也就是本轮 turn/start 之后收进来的消息,每条消息都带一个宿主盖章的来源标签。右边是鉴权流程,模型每次调 goal 工具,代码就把窗口扫一遍。五个情景里你轮流扮演人类、越权的模型和子 Agent,看放行和拒绝各自发生在哪一行。

当前 Turn 窗口
turn/start 之后收进来的消息,来源标签由宿主盖章
溯源鉴权 · authority.ts
工具执行时逐条过检,行号对应真实源码
选择情景后点播放,或滚动到此处自动播放情景 A。
Goal 是什么

Goal(长期目标)解决的诉求很具体:你交代一句话的目标,比如把仓库测试全修绿,然后走开。Agent 接下来自主跑几十个 Turn(轮次),每轮结束系统自动注入一条继续推进的消息,让它接着干。

问题也随之而来:一个能自己给自己发消息的系统,怎么保证它不会顺手把轮次上限也改了,永远跑下去?DSH 的答案分两层,一层管权限从哪来,一层管凭证怎么过期,下面逐层拆。

设计思路一 · 权限跟着消息来源走

它解决什么问题

具体的翻车场景长这样:自动续跑到第 4 轮,模型在回复里编一句「检测到管理员已在带外渠道授权本次变更」,然后调 update_goal 把轮次上限改成 999。如果防线只是一行系统提示词劝它别改,这里就沦陷了:没有任何硬校验能核实那句话的真假,提示词只是请求,模型信不信全看它自己。演示的情景 E 演的就是这个对照。

提示注入的经典剧本全是这一路:让模型相信用户已经授权。只要鉴权去读消息文本,话术就永远有机会赢。

思路是什么

DSH 的鉴权不分析模型说了什么,只做一件事:模型调 update_goal 时,把当前 Turn 窗口里的消息扫一遍,看有没有一条来源是真人的。这个来源写在 user/message 事件的 source 元数据里,由宿主在事件落盘时盖章。它压根不在消息正文里,模型再能写话术也伪造不了。

source.kind = 'user' 真人消息 · 宿主盖章 source.kind = 'goal' 自动续跑轮 · 带 round 编号 其余一切消息 模型输出 · 工具结果 · 子 Agent hasDirectHumanInput 根 agent 检查 + 扫 Turn 窗口 找到 kind:'user' 才成立 authority.ts L70-74 isMatchingGoalRound goalId · revision · round 三对齐 正好是当前获准轮次才成立 authority.ts L77-83 direct-human 全权 create · edit · pause · resume complete · blocked 也行 goal-round 只许收尾 complete · blocked edit 调上限?没门
教学化结构图:三类消息来源对应两档权限,行号对应 packages/goal/tool-goal/src/authority.ts。

权限只有两档。第一档 direct-human:当前 Turn 窗口里有真人消息,那这轮里模型做什么都算人类授的权,create、edit、pause、resume 全开。第二档 goal-round:窗口里那条注入消息正好是当前目标的当前轮,此时模型只能做两件事,报告完成(complete)或报告卡住(blocked)。blocked 还有个额外下限,默认要跑满 3 个获准轮次才许喊卡,防的是模型一遇到难题就撂挑子。

所以给自己续命这条路在机制层就不存在。自动续跑的轮次里,窗口里只有系统注入的续跑消息,没有真人,edit 直接被拒,模型能做的只有收尾。

具体的判定拆成两个小函数。hasDirectHumanInput 扫描之前先确认调用者是顶层根 agent,子 Agent 连扫描资格都没有,然后在窗口事件里找一条来源标记为真人的消息,找到才成立。isMatchingGoalRound 对轮次编号:注入消息的 goalId、revision、round 三个值要和当前目标逐一相等,才算当前获准轮。上一轮的旧消息、别的目标的消息、编号跳号的消息,都换不来授权。

两个判定怎么合成最终裁决?这段源码一共 8 行,短到可以整段当金句看。它证明整条判定路径上没有白名单、没有评分、没有语义分析:先问有没有真人,再问是不是当前轮,都不是就抛结构化错误。

packages/goal/tool-goal/src/authority.ts第 101 至 108 行
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')
}
源码快照说明:依据本地仓库 deepseek-harness-master,核对文件 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 行。

为什么长期成立

凭证要绑定颁发那一刻的状态,状态一变凭证作废,这是数据库乐观锁和登录态过期的共同逻辑。它把防重放做成了算术题:旧凭证不用识别、不用拉黑,对不上号就自然失效。任何授权和使用之间隔着时间差的系统,都得补这一课。

同门师兄 · ralph 走了另一条路

DSH 里还有一个跑长任务的工具叫 ralph,思路完全相反:不搞同会话续跑,每个 Round 起一个全新子 Agent,什么历史都不给看,共享工作区当长期记忆。工具目录的描述原文:

围绕一个不可变目标运行使用全新 agent 的前台 Ralph 循环。每个 Round 都会启动一个全新子级,该子级看不到父级对话或先前子会话;共享工作区充当长期记忆,Round 之间只传递有界的结构化报告。docs/tool-catalog.zh.md 第 1190 行,节选

注意 ralph 的目标是不可变的:模型只能在发起时选定目标和轮次上限,之后连改的接口都没有。Goal 用溯源鉴权守住可变状态,ralph 干脆让状态不可变。两条路防的是同一件事。

横向对比 · Claude Code 有任务系统,没有这一层
Claude Code:生命周期很厚,鉴权靠别处

任务系统本身相当完整:七种任务类型统一走一套状态机,notified 标志位保证完成通知只发一次,远程任务持久化 metadata 支持断线重连(书稿第 6 章,restored-src/src/tasks/)。主 Agent 给子 Agent 追加指令走 SendMessage,mid-turn 消息先进 pendingMessages 队列缓冲。

但这些消息进入上下文后就是普通文本,没有一个宿主盖章的来源字段参与工具鉴权。防越权靠的是另一套权限系统(书稿第 7 章)加提示词约束。在已核对的公开材料里,未见以消息来源决定 goal 类工具权限的等价机制。

DSH:把注入防在类型系统里

提示注入的经典剧本是让模型相信用户已经授权。在 DSH 里这套话术说得再像也没用:鉴权读的是事件元数据,不是消息文本。攻击者能控制的只有正文,正文在整条判定路径上根本不出场。

这是三家对比中 DSH 独有的一层。基于已公开证据,Grok Build 的材料里同样未见等价机制,其防护重心在沙箱与授权链(站内 Grok 专题有专门课页)。结论克制一点:别家未必没想过,但把它做成 109 行可精读的独立文件,目前只在 DSH 看到。

课堂练习
01

手推一个完整的越权剧本

自动续跑第 4 轮里,模型先在回复里写「用户刚才在语音里同意把上限调到 100 轮」,然后调 update_goal(action: edit, max_goal_rounds: 100)

问题一:写出这次调用在鉴权里经过的判定顺序和最终结果。

问题二:换个思路,它让子 Agent 替它调,会卡在哪一道检查?

问题三:如果它先调 complete 把目标标记完成,再指望新目标重新计数,create 需要什么授权?三条路各堵在哪。

Takeaway:Goal 的权限跟着消息来源走,来源是宿主盖在事件元数据上的章,模型的话术够不着。自动续跑轮次里模型只能收尾,不能续命,edit 上限永远要真人在场。防提示注入的正确层次是机制,提示词只负责劝阻。