Repository navigation
[BUG] PTC 模式下 hide-official 未调用 tools.restrict 隐藏所选官方工具:模型在 tools:sdk (ToolArgsMap) 与提示词中仍能看到 read/glob/grep,但调用即被 refusal 拦截 #415
Description
背景与现象
在 #365 / PR #366 之后,dsh-ops/hide-official 对 run_code(PTC)作用域采取了**“不隐藏(不调用 tools.restrict),但在执行守卫 refusal 中一律拒绝所选官方工具”**的策略(见 src/official-tool-hiding.ts:140-179 与 test/presentation-readiness.test.mjs:52 的 'a collapsed scope hides nothing')。
这在实际运行中会导致一个直接面向模型的矛盾现象:在 PTC 模式下,即便启用了 dsh-ops/hide-official 并配置了 replaceTools: ['read', 'glob', 'grep'],模型在系统提示词里依然能完整看到官方 read、glob、grep 的工具说明和 run_code 的 TypeScript SDK 声明(ToolArgsMap / ToolOutputMap),但只要模型照着提示词在 run_code 中调用 await tools.read(...),就会立刻撞上 READINESS_BLOCKED 报错,白烧一轮工具调用与上下文 Token。
为什么模型在 PTC 里还能看到被替换的官方工具?(根因拆解)
1. 宿主 dsh-tools 的 PTC 提示词与 SDK 声明是从 view(scope).visible 生成的,而不是从顶层 result.tools 生成的
PR #366 的注释(src/official-tool-hiding.ts:164-169)假设:既然 PTC 作用域组装出的顶层 Wire 工具数组 result.tools 只剩 ['run_code'],那么官方 read/glob/grep 就没有暴露给模型,因此不需要下达 entry.context.tools.restrict({ deny })。
但宿主 @deepseek-ai/dsh-tools(packages/core/tools/src/index.ts)在 PTC 模式下的真实行为分两路:
- 顶层 Wire 工具栏(
wireSchemas,第 1019-1037 行):折叠为只剩[{ name: 'run_code' }]。 - 系统提示词中的 SDK 声明(
sdkSection/sdkSchemas,第 899-916、1273-1287 行)与官方工具提示词段落:遍历的是this.view(scope).visible.values()(即该作用域下所有未被tools.restrict({ deny })屏蔽的工具)!
因为在 PTC 模式下:
dsh-ops/ptc(src/ptc/index.ts:164)调用claimPtcSurface()撤下了NativeProvider,改发 PTC 专属定义;official-tool-hiding.ts:52-58的readiness()只认nativeProviders(ctx, agent),在 PTC 下恒为false;prepare()(第 75 行)返回{ deny: [], failure: ... },reconcile()(第 100 行)直接clear(entry)跳过了entry.context.tools.restrict({ deny: plan.deny })。
结果:官方 read、glob、grep 在底层的 view(scope).visible 中完全没有被屏蔽,宿主 sdkSchemas() 把它们原封不动地印进了系统提示词的 interface ToolArgsMap / ToolOutputMap 里,同时官方工具自己的提示词规则(如 Use the read tool...、Use the glob tool...、Use the grep tool...)也照常渲染。模型看着说明书去调 await tools.read(...),却在 official-tool-hiding.ts:145-146 被 refusal 守卫以 READINESS_BLOCKED: official tool read is selected for replacement 拦下。
连锁引发的另外两个问题
2. run_code 执行期间若触发 reconcile() 重算,连未选中的正常工具子调用也会被 entry.error 误杀
在 src/official-tool-hiding.ts:134-147 中:
const refusal: ToolGuard = exec => {
if (stopped) return blocked('component disposed')
if (!exec?.agent?.ctx) return blocked('calling agent scope unavailable')
attach(exec.agent)
reconcile() // Authority is live, not a startup snapshot.
const entry = entries.get(exec.agent)
const transport = entry?.collapsed === true && exec.name === RUN_CODE
if (entry?.error && !transport) return entry.error
return hideable(exec.name)
? blocked(`official tool ${exec.name} is selected for replacement`) : undefined
}system-prompt/assemble结束时(第 174 行)虽然因为collapsed === true把entry.error清成了undefined,但这只是单次修改。- 一旦在
assemble之后发生过任何使snapshot()变化的事件(例如并行会话的工具变化),当run_code内部发起任何合法的 SDK 子调用(如await tools.ops_inspect_local_file(...)或await tools.write(...))时:reconcile()重新执行prepare(agent, entry),将entry.error重新置为READINESS_BLOCKED ... missing OPS surface;- 由于子调用的
exec.name是具体工具名而非'run_code',transport为false; if (entry?.error && !transport) return entry.error直接命中,导致run_code内所有子调用全部被拒。
3. 与 DSH 0.2.1 的 system-prompt/assemble 契约(#413 / PR #414)冲突
在 DSH 0.2.1(9d0c97b9d2)下,system-prompt/assemble 瀑布不再携带 result.tools(result.tools 为 undefined):
official-tool-hiding.ts:163-169中wire为[],导致collapsed恒为false,第 173 行if (!collapsed) throw new Error(entry.error)在 PTC 作用域下必抛READINESS_BLOCKED;- 即便如 PR fix(hide-official): verify the assembled surface without a tool array #414 在第 178 行回退到
replaceTools.filter(tool => tools.get(tool, context.scope) !== undefined),由于 PTC 作用域下并未对replaceTools下达tools.restrict,tools.get('read', context.scope)依然非空,会在第 184 行立即触发assembled surface still contains hidden official tools。
最小复现
在 test/lib/official-tool-hiding.mjs 的 a run_code-presented scope is not gated by native readiness 用例(第 193 行)中查看组装出的 tools:sdk 提示词段落:
const { ctx } = await bootHost({ toolsMode: 'ptc' })
ctx.provide('ptcRuntime', { language: 'typescript' })
for (const name of ['read', 'glob', 'grep', 'foreign']) ctx.tools.register(tool(name))
await mount(ctx, { replaceTools: ['read', 'glob', 'grep'] })
const agent = await mint(ctx, 'ptc-scope')
const assembly = await assemble(ctx, agent)
const sdkText = assembly.sections.find(s => s.name === 'tools:sdk')?.text ?? ''
// 1. 模型看到的 SDK 声明里仍然包含 read / glob / grep:
console.log(sdkText.includes('read:'), sdkText.includes('glob:'), sdkText.includes('grep:')) // true, true, true
// 2. 但模型在 run_code 内调用它们时被 refusal 拒绝:
const nestedRefused = await ctx.waterfall('tools/pre-execute', { agent, name: 'read', parent: {} }, () => ({ kind: 'allow' }))
console.log(nestedRefused.kind) // 'deny'建议修复方向
既然出于凭据围栏(credential fence)考虑,所选的官方 read/glob/grep 在 PTC 作用域内也不允许被执行(PR #366 review comment),那么可见性层与执行层应当保持一致(凡是拒绝执行的官方工具,在 PTC 的 view(scope).visible 与 tools:sdk 中也应当通过 tools.restrict({ deny }) 隐藏):
- 在 PTC 作用域同样校验对应 OPS PTC 工具的就绪状态并下达
tools.restrict({ deny }):- 当作用域处于 PTC 模式时,就绪条件检查对应的
ops_inspect_local_file/ops_glob/ops_grep是否已在当前 PTC 作用域激活(或要求配置dsh-ops/ptc的alwaysLoaded,与 Native 模式下要求配置dsh-ops/discovery的alwaysLoaded对齐); - 一旦就绪,正常执行
entry.context.tools.restrict({ deny: plan.deny }),使宿主sdkSchemas()不再把已替换的read/glob/grep渲染进ToolArgsMap,同时兼容 DSH 0.2.1 下基于tools.get(tool, scope)的泄漏检查([BUG] DSH 0.2.1:hide-official 的组装校验仍假设载荷带 tools,启用该行后每个作用域都被 READINESS_BLOCKED 拦下 #413 / PR fix(hide-official): verify the assembled surface without a tool array #414)。
- 当作用域处于 PTC 模式时,就绪条件检查对应的
- 或者如果替换契约确实只针对 Native 呈现生效:
- 若不在 PTC 作用域对
glob/grep强制要求alwaysLoaded,则至少应对已有 resident 替代品(如read -> ops_inspect_local_file)或已加载的 PTC 替代品执行tools.restrict({ deny })从tools:sdk隐藏;同时用hasPtcInstruction(同 [BUG] dsh-ops 0.4.1 在 DSH 0.2.1-rc.3 下每轮 prompt 装配崩溃:isPtc 读取 undefined.tools.length #387)替代Array.isArray(result?.tools)识别 PTC 折叠作用域,避免在 DSH 0.2.1 及reconcile()重跑时误杀子调用。
- 若不在 PTC 作用域对
Activity