[Bug] 沙箱升级"死选项":广告的 sandbox_permissions 目标等于当前模式时必然抛错,且切换模式无法自愈(会话无法写盘) #7154
Replies: 5 comments
最小复现(在
|
|
/**
import { describe, expect, it } from 'vitest' describe('the strictly-wider ladder', () => { it('the target enum is the closed set every session could escalate TO (read-only is the floor)', () => { describe('validateEscalationArgs', () => { it('rejects one field without the other, and a blank justification', () => { describe('the model-facing markers', () => { it('the hint marker names the family subject', () => { describe('approveEscalation', () => { it('grants: returns the requested mode, asking through the approver with the audit reason', async () => { it('a non-widening request fails closed with its own text and never asks', async () => { it('a missing approval service and an agent-less call each fail closed with distinct text', async () => { it('maps each non-grant outcome to its distinct verbatim text (subject in the rejection)', async () => { it('an outcome outside the closed union trips the exhaustiveness guard (defensive)', async () => { |
|
/**
import { assertNever } from '@deepseek-ai/dsh-util-values' /**
/**
/**
/**
/**
/**
/**
/**
/** One escalation request, as {@link approveEscalation} judges it. / /**
|
已确认在
|
追加:源码运行(
|
| 加载方 | 解析依据 | 实际模块 | 得到的 symbol |
|---|---|---|---|
CLI 进程自身(tsx 跑 src,遵循 tsconfig paths) |
tsconfig 别名 | packages/core/tools/src/index.ts |
Symbol_A |
profile 中已构建的插件 bundle(import { TOOL_RUNTIME_SCHEDULER } from "@deepseek-ai/dsh-tools") |
package exports |
packages/core/tools/lib/index.js |
Symbol_B |
Symbol()(而非 Symbol.for())每次求值都产生互不相等的 symbol,于是 Symbol_A !== Symbol_B,ctx.tools[Symbol_B] 为 undefined,读 .prepare 抛错。
决定性证据:把服务入口从源码换成构建产物后,工具立即恢复正常:
-ExecStart=/usr/local/bin/pnpm dsh web --host 127.0.0.1 --port 3080 --no-open
+ExecStart=/usr/local/bin/node /opt/deepseek-harness/apps/cli/lib/bin.js web --host 127.0.0.1 --port 3080 --no-open全链路统一走 lib/ 与 package exports 后,同名包只有一个模块实例,symbol 必然相等。
为什么极难诊断(已排除项)
排查过程中逐项排除,全部为否定结果:
pnpm clean && pnpm install && pnpm build完整重建 → 仍失败(排除构建产物问题)- 禁用 profile 中全部第三方插件 → 仍失败(排除插件)
- 用
--from-default-profile web新建 profile → 组合配置树与旧 profile 逐行完全一致(163 行,diff 为空)→ 排除 profile 配置 agent-loop/lib/index.js:13是import { … } from "@deepseek-ai/dsh-tools",bundle 内无Symbol定义 → 排除打包器内联readlink -f去重后该包的物理副本只有一份 → 排除多份物理副本- 生产代码没有任何
dsh-tools/src导入(仅测试与 JSDoc)→ 单看代码无法发现
唯一剩下的差异就是服务入口以源码方式运行,这是本机与常规安装之间唯一的环境不对称。
复现与诊断命令
# 1) 确认 tsconfig 别名把包名指向 src/
grep -rn '"@deepseek-ai/dsh-tools"' tsconfig*.json
# 2) 确认服务入口是否以源码运行(关键线索)
grep -n '^ExecStart' /etc/systemd/system/deepseek-harness.service
grep -n '"dsh"' package.json
# 3) 在有别名的情况下按源码入口启动,然后调用任意工具
pnpm dsh web --host 127.0.0.1 --port 3080 --no-open建议的修复方向
-
服务键改用
Symbol.for()(最直接):export const TOOL_RUNTIME_SCHEDULER: unique symbol = Symbol.for('@deepseek-ai/dsh-tools.scheduler') as unknown as unique symbol
全局注册表保证重复模块实例也命中同一 symbol。这类"以 symbol 为键的服务查表"在打包/多入口环境里本质上都受同一风险影响。
-
生产入口不跑源码:
dsh脚本改指构建产物,源码入口另设 dev-only 命令;或在文档中明确"服务部署请勿使用 tsx 直跑源码"。 -
启动期一致性断言:加载完成后校验关键服务键存在,缺失则在启动阶段大声失败,而不是等到第一次工具调用才以
reading 'prepare'静默失败。 -
查表失败时给出可诊断的错误,例如提示"检测到同名包被加载多次,可能是 tsconfig paths 与 package exports 解析不一致"。
附:原报告问题的修复已确认生效
顺带确认本讨论原问题(同模式升级被拒)在 0.1.6-alpha.2(commit 61c548e200)中已修复,实测:
requestedMode === effectiveMode(workspace-write与danger-full-access两种)→ 免审批直接通过,审批通道未被调用- 更窄请求(
danger-full-access会话请求workspace-write)→ 仍被拒绝,原有契约未被破坏
另外,上一条评论里提到的源码构建跳过 landlock-run 问题在 alpha.2 中依然存在(package.json:54 仍为 --host-addon-only),需手动执行 pnpm tsx native/system/scripts/build.ts 补齐。
Uh oh!
There was an error while loading. Please reload this page.
[Bug] 沙箱升级"死选项":广告的
sandbox_permissions目标等于当前模式时必然抛错,且切换模式无法自愈(会话无法写盘)Summary
当某个工具调用带有
sandbox_permissions,且其值等于该调用当前的有效模式时,approveEscalation必然抛错:由于"严格更宽"检查(
escalation.ts:162)发生在任何执行之前,write/edit/bash调用在报错时完全没有触碰磁盘。更严重的是这个失败无法自愈,因为:
WIDER_MODES(escalation.ts:28-31)只有read-only与workspace-write两个键,没有danger-full-access;因此一旦有效模式是danger-full-access,枚举ESCALATION_TARGETS = ['workspace-write','danger-full-access']中的两个值全部非法。workspace-write切到danger-full-access后,模型把请求值从"workspace-write"改成"danger-full-access",于是换哪个模式都同样报错——用户手上没有任何一个模式是安全的。:162早于:165),所以approval policy设为ask还是never都拦不住这个错。实际后果:一个已生成完整分析报告的长会话,连续 14 次
write全部失败,结果无法落盘。Reproduction
最小可复现(无需模型,纯逻辑)
真实会话复现(有完整日志)
Linux 主机,默认 preset
workspace-write,approval policy = ask。让模型调用
write,写入会话工作区内的一个普通 markdown 文件,但带上sandbox_permissions: "workspace-write"+justification。把会话切到
danger-full-access,approval policy = never,让模型重试。模型把请求值改为
"danger-full-access"重试。重复 3-4:模型在两种模式间反复重试共 14 次,无一成功;文件始终不存在。
Current behavior
write/edit/bash携带sandbox_permissions且值等于当前有效模式时,必然抛错,且在执行前失败,磁盘无任何变化。workspace-write⇄danger-full-access)无法绕过;approval policy的ask/never也无法绕过。repeat-tool-reminder: write × 3),形成死循环,消耗 token 且不产出。Expected behavior
至少满足其一(推荐 1 + 2 + 3):
danger-full-access)时,不再向模型注入升级提示(escalationHintMarker),并在策略上下文中明确说明"无需也无法升级"。danger-full-access请求收窄到workspace-write),错误应说明"当前已是最高权限,无需升级,请直接重试且不要传sandbox_permissions",而不是只说"不是严格更宽"。Environment
0.1.6-alpha.1dsh-v0.1.6-alpha.1/0a15e36e7f82b6ed45af6fa9759f29b40dcd965d(2026-09-15)/opt/deepseek-harness),非 npm 全局安装11.7.0/ Nodev26.7.06.8.0-138-generic/ x86_64bwrap0.9.0(已装,探测full)+landlock-run(自编译,探测partial)commandcode/gpt-5.6-sol(另一会话deepseek/deepseek-v4.1-flash亦命中同类模式)workspace-write(packages/bundle/base/cordis.patch.yml:211,DSH_PERMISSION_MODE未设置)workspace-write(approvalask)→danger-full-access(approvalnever)session-bf520538-…(本地标识,已截断)Root cause
缺陷本质是**"暴露/教学"与"校验"使用了两个不同的真相来源**。
sandbox_permissions+justification、enum 取值、描述文案ctx.shell.sandboxMode/ctx.fs.sandboxMode(部署默认模式)effectiveModesandboxPolicy.resolve({ session })(逐会话:session override ?? default)代码证据
packages/sandbox/sandbox/src/escalation.tsWIDER_MODES只有read-only/workspace-write两个键,没有danger-full-accesspackages/shell/tool-bash/src/index.tsconst escalationModes = defaultMode === undefined ? [] : ESCALATION_TARGETS(广告只看"执行器是否挂载")const effectiveMode = (standingPolicy as SandboxExecutionPolicy).mode(校验看逐会话真值)packages/shell/tool-pwsh/src/index.tspackages/fs/tool-fs/src/sandbox.tswrite/edit家族同样受影响packages/shell/bash-sandbox/src/index.tsthis.mode = ctx.sandboxPolicy.defaultMode—— 无任何运行期探测packages/shell/shell/src/index.tssandboxMode返回undefined(仅当挂的是非沙箱执行器)packages/bundle/base/cordis.patch.ymldisabled: !!js process.platform === 'win32'—— 仅按平台门控mode: !!js process.env.DSH_PERMISSION_MODE ?? 'workspace-write'暴露面比想象中大
因为
bash-sandbox的挂载只按平台门控(Linux 恒挂),与 bwrap/Landlock 是否可用无关,所以:ctx.shell.sandboxMode在每一个 Linux 会话中都有定义;sandbox_permissions与那一整段"被拒绝就升级"的指导文案(tool-bash/src/index.ts:81-91)在每一个 Linux 会话中都被广告,包括danger-full-access的会话。也就是说:这不是边缘组合,而是"任何模型只要决定声明一次升级目标,就可能踩中"的普遍暴露面。
与设计意图的关系
抛错本身是有意的,且已被测试钉住:
packages/shell/tool-bash/tests/tools.spec.ts:630packages/sandbox/sandbox/tests/escalation.spec.ts:88设计记录
.agents/notes/implemented/feature/2026-07-06-sandbox.md:89明确写道:escalation.ts:33-41的注释表明作者已考虑过取舍,但只覆盖了默认模式的两种情形,遗漏了第三种:部署默认 confining + 会话被覆盖为danger-full-access。为保住"默认 full access、会话却是窄模式"的会话不失去杠杆,他们选择全量广告枚举;代价就是这种情形下广告出永远无法成功的选项。本报告不主张推翻该设计,只主张:等价/不可达的请求应容错降级,而不是硬失败。
Why this went unnoticed(供优先级判断的补充背景)
这个缺陷此前被一层偶然的遮蔽掩盖,值得团队了解其失效路径:
landlock-run未编译)SANDBOX_UNAVAILABLE,只能退回danger-full-accessworkspace-write真的能跑,且是出厂默认时间线(UTC):后端修复
2026-09-18 13:01:29→ 会话开始2026-09-19 07:38:12→ 首次失败07:43:27。结论:缺陷一直存在且一直可达,只是"后端不可用"这一异常状态把所有会话都赶离了触发它的模式。修复沙箱后端反而移除了这层偶然的遮蔽。
Evidence(原始日志摘录)
来自
~/.dsh/sessions/<workspace>/<session-id>/session.v3.jsonl.zstd,tool/result原文:模式切换事件:
注意
seq 117:此时模式已是danger-full-access,而模型仍请求"workspace-write"——说明模型侧的目标与当前模式之间存在漂移期,两种错法都会出现。14 次写入调用的目标文件始终是同一个,且全部位于会话工作区内(
/root/<workspace>/…),本不需要任何升级。影响(为什么建议 P1)
受影响会话已产出完整报告(616 行 / 13195 字符),但一次都没写进磁盘。该内容之所以还能找回,仅仅是因为 harness 把每次工具调用的
arguments持久化进了会话日志——属于运气,不是设计保障。若无此持久化,就是不可逆的工作成果丢失。Test gap
现有测试只覆盖了"从
read-only请求一个真正不更宽的目标",缺少下列用例:danger-full-access时,请求"workspace-write";danger-full-access时,请求"danger-full-access"(枚举内两个值全灭);requestedMode === effectiveMode(同模式请求)应被视为 no-op;write/edit)下的表现;danger-full-access"这一组合(本缺陷的精确触发条件)。Suggested fix
按"性价比"排序,建议 1 单独即可解除所有卡死会话:
1. 执行层容错(最小改动,收益最大)
packages/sandbox/sandbox/src/escalation.ts:在严格更宽检查前先处理等价情形。这样
workspace-write → "workspace-write"与danger-full-access → "danger-full-access"都会变为正常执行,本报告中的所有失败路径立即消失。真正收窄的请求仍然抛错(保持 fail-closed 语义)。2. 天花板处不再注入提示
packages/shell/tool-bash/src/render.ts:48、packages/shell/tool-pwsh/src/render.ts:66,以及两处renderProcessRead(tool-bash/src/render.ts:89、tool-pwsh/src/render.ts:106):3. 策略上下文按会话补一句(动态、零成本)
packages/sandbox/sandbox-policy/src/index.ts:48-49的danger-full-access分支本来就是每轮动态渲染的(text是函数),加一句即可:4. (可选)描述文案消歧
packages/shell/tool-bash/src/index.ts:81-91的升级指导可以补一句"当会话已是danger-full-access时不要设置sandbox_permissions",与建议 3 形成双保险。Recovery note(供其他遇到该问题的用户)
被卡住会话的产出可以从会话日志恢复,因为每次
write的content参数都被持久化了:另外,
read/glob/grep等只读工具不受影响(本会话中全部成功)。因此即使用户不恢复日志,也可以让卡住的会话改走"只读核验 + 在对话里给结论"的路径先把成果取出来。English abstract
Title: Sandbox escalation dead option —
sandbox_permissionsequal to the call's effective mode always throws, and switching modes cannot recover.When a tool call carries
sandbox_permissionswhose value equals the call's current effective mode,approveEscalation(packages/sandbox/sandbox/src/escalation.ts:162) throws before anything executes, sowrite/edit/bashnever touch disk. BecauseWIDER_MODES(escalation.ts:28-31) has nodanger-full-accesskey, once the effective mode isdanger-full-accessboth values ofESCALATION_TARGETSare invalid. The model's requested target tracks the current mode, so switching the session betweenworkspace-writeanddanger-full-accessonly changes which of the two error strings appears — there is no mode the user can switch to. The strict-widening check precedes the approval-channel check (:162before:165), so the approval policy cannot intervene either.The advertisement side is computed from the deployment default (
tool-bash/src/index.ts:192,tool-pwsh/src/index.ts:197,tool-fs/src/sandbox.ts:45) while validation uses the per-session mode (tool-bash/src/index.ts:221,tool-fs/src/sandbox.ts:98). Sincebash-sandboxis mounted by a platform-only gate (packages/bundle/base/cordis.patch.yml:216), the dead option is advertised in every Linux session, includingdanger-full-accessones.Observed: 14 consecutive failed
writecalls in one session; a completed 616-line report could not be persisted. Nothing escaped confinement — this is an availability/data-loss bug, not a security hole.Proposed fix: treat
requestedMode === effectiveModeas a no-op inapproveEscalation(return the current mode and execute); additionally suppressescalationHintMarkerwhen the mode is alreadydanger-full-access; and add a sentence to the per-turndanger-full-accesspolicy context telling the model not to passsandbox_permissions.All reactions