fix(tools): restrict what a scope inherits, not just the global layer

A restriction was compiled against the global tool layer alone: only
global-layer tools were tested against `admits()`, and every chain-layer
tool was overlaid unfiltered afterward. That read the exempt set as "the
global layer" when what it means is "what this scope registers itself" —
two descriptions of the same set only while every model-facing tool sat in
the host composition.

Moving those rows onto the agent plane separated them. A preset's tools are
an ANCESTOR contribution to a joined agent, so a subagent's `toolFilter`
stopped constraining anything it was given; and with the global layer empty
`restrict()` rejected every name it received as unknown, failing the child
outright. With the same tools in the global layer the filter still admits
and applies normally, which is what makes this a regression of the move
rather than a standing limitation.

`view()` now filters everything a scope inherits — the global layer and
every ancestor layer on its chain — and exempts only the layer the scope
owns. That exemption is load-bearing rather than incidental: the delegation
runtime registers a child's `report` and structured-output tools into the
child's own layer, and a filter naming the capabilities the child may use
must not strip the machinery it answers through. Tool order, and with it
prefix-cache reuse, is unchanged: inherited names keep their global-then-
ancestor position and own-layer names still come last.

The diagnostic said "unknown global tool" while listing what is really the
inherited surface; it now names the surface it checks and says why an
own-layer name is not restrictable.

Fixes #2185
This commit is contained in:
Yichen Jiang
2026-08-10 20:34:45 +08:00
parent 6301320a63
commit 43f3324a7b
14 changed files with 170 additions and 53 deletions
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.md
2026-08-10-child-agents-join-their-parent-preset.md: d9aa0dc43c1338d3198f5335da6ad238730d58a1
2026-08-10-child-agents-join-their-parent-preset.zh.md: dd85c642ff7e6e2934e805c2efaccdc6dda63f15
2026-08-10-child-agents-join-their-parent-preset.md: 4534004ad54df69822872b9595a29443fc3a990b
2026-08-10-child-agents-join-their-parent-preset.zh.md: bdf9928bea4b75e2915c8adf5c15f8a01c6583e4
@@ -22,6 +22,8 @@ This is a bind, not a mount, and both differences are load-bearing. The child ge
`dsh-subagent` reaches the roster through `ctx.get('agentPresets')` with a type-only import and an optional peer dependency — the documented opportunistic-consumption pattern it already uses for `sandboxPolicy` and `approval`.
Giving the child its parent's tools exposed a second defect the same agent-plane move introduced: `ToolRegistry` exempted SCOPED registrations from a restriction and filtered only the global layer, so once every model-facing row became an ancestor contribution, a child's `toolFilter` stopped constraining anything — and, with the global layer empty, `restrict()` rejected every name it was given as unknown, failing the child outright. The exempt set is the tools a scope registers ITSELF, not the tools that happen to live in the global layer; reading it the second way held only while those two sets coincided. `view()` now filters everything a scope inherits — the global layer and every ancestor layer — and exempts only its own. The own-layer exemption is load-bearing rather than incidental: the delegation runtime registers a child's `report` and structured-output tools into the child's own layer, and a filter naming the capabilities the child may use must not strip the machinery it answers through.
## Alternatives considered
**Re-mount the parent's preset by id in the child's setup.** Rejected on both semantics and mechanics. It re-reads the roster and re-stats the composition file, so an edit since the parent started forks the child onto a different generation, and a preset deleted since fails the child while its parent runs on. `mount()` is also asynchronous, which the synchronous creation windows cannot accept without restructuring both drivers.
@@ -32,22 +34,26 @@ This is a bind, not a mount, and both differences are load-bearing. The child ge
**Let `dsh-subagent` import `resolveSessionPreset` and mount by the resolved id.** Rejected because it makes the preset roster a hard module edge for a package that must work without one, and it lands back on the remount semantics above.
**Filter every layer on the chain, including the scope's own.** Rejected because it makes a per-child capability filter delete that child's reporting and structured-output tools, which the delegation runtime registers into the child's own layer — an `allow` naming the capabilities a child may use would leave it unable to answer at all.
**Leave the durable header alone and fix only the live join.** Rejected because the live child and the same child read cold would then disagree about which composition produced its history — the same class of defect, moved rather than fixed.
## Testing
`packages/preset/agent-presets/tests/mount.spec.ts` covers the join against real fixture compositions: the child sees its parent's tools and prompt sections, no second generation is mounted, the join survives the parent's disposal (a background child outliving its parent), the reported id matches, a parent without a preset joins nothing, and an unscoped context is refused.
`packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts` asserts the model-visible result through `startInProcessRun()` on a host composition carrying no model-facing rows: the schemas in the child's own request, its parent's prompt section, the recorded header preset, and a parent that switched preset while blank — to a DIFFERENT preset, so the assertion distinguishes reading the parent's live scope chain from reading its creation header.
`packages/core/tools/tests/scoped.spec.ts` covers the restriction rule directly: a child's filter removes a tool it inherited from an ancestor scope, the child's own registrations survive its own filter, and an ancestor's restriction still reaches every scope nested inside it.
`packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts` asserts the model-visible result through `startInProcessRun()` on a host composition carrying no model-facing rows: the schemas in the child's own request, its parent's prompt section, the recorded header preset, a `toolFilter` applied over the inherited preset tools, and a parent that switched preset while blank — to a DIFFERENT preset, so the assertion distinguishes reading the parent's live scope chain from reading its creation header.
The assembled-transcript layer is the shipped Web composition's e2e rather than a keyless snapshot. Every runnable example this repo ships composes no preset roster, so the defect is not observable in the snapshot harness at all: a snapshot scenario would first need an example that mounts a roster AND delegates. The Web e2e boots the real `base` + `web-app` patch layers with both shipped presets, which is the assembled evidence the testing policy asks for; the Web browser lane's subagent goldens carry the visible consequence, since a child that records its preset now shows the preset badge its parent shows.
## Consequences
Delegation now costs a scope-parent bind per child and nothing else — no extra plugin instances, no roster read, no failure mode. A child's capabilities are exactly its parent's — the per-child `toolFilter` does not narrow them, for the separately tracked reason below; a per-subagent preset ("agent types") remains unbuilt and would be a new request field rather than a change to this join.
Delegation now costs a scope-parent bind per child and nothing else — no extra plugin instances, no roster read, no failure mode. A child's capabilities are exactly its parent's, minus whatever its own `toolFilter` removes; a per-subagent preset ("agent types") remains unbuilt and would be a new request field rather than a change to this join.
`applyChildComposition()` changed shape, so any future out-of-tree in-process driver must supply the parent. That is the intended cost: the previous signature let a caller compose a capability-less child and get no error.
A cold-resumed continuable child joins its parent's CURRENT composition rather than the one its own header records. The window is narrow — the parent must create the child, stay blank, switch preset, and only then wake it, since a resident child never re-joins and a one-shot child never resumes — and the alternative is worse: resolving the child's own recorded id would re-read the roster and hand back the preset-deleted failure mode this join exists to avoid. The child's header still records what it started under, so the divergence is observable rather than silent.
`toolFilter` does not constrain a joined child, because `ToolRegistry` compiles restrictions against global-layer names only and overlays chain-layer tools unfiltered. That is not new here — with the roster composed, `tools.restrict()` already rejected every name as an unknown global tool, so a child carrying a filter failed to start both before and after this change — but it is a regression from the agent-plane move rather than a standing limitation: with the same tools registered in the global layer, the filter admits and applies normally. It matters more now that the child has its parent's full tool set to be restricted from. It is tracked separately; this change neither introduces nor repairs it.
`ToolRegistry` now reads a restriction's exempt set as "what this scope registers itself" rather than "the global layer", which changes one documented behavior beyond delegation: a tool an ANCESTOR scope contributes is now subject to a descendant's filter, where before only global-layer tools were. Nothing else on the chain loses its exemption — a scope's own registrations stay outside its own filter, which is the property the delegation runtime depends on.
@@ -22,6 +22,8 @@ Status: implemented
`dsh-subagent` 以类型级导入加可选 peer 依赖的方式,通过 `ctx.get('agentPresets')` 触达 roster——这正是它对 `sandboxPolicy``approval` 已在使用的、有明确文档的机会性消费模式。
把父方的工具交给子 agent 之后,暴露出同一次 agent 平面搬迁引入的第二个缺陷:`ToolRegistry` 把**作用域级**注册排除在限制之外、只过滤全局层,因此当所有面向模型的行都变成祖先贡献之后,子 agent 的 `toolFilter` 就不再约束任何东西——而且全局层为空时,`restrict()` 会把收到的每个名字都判为未知并直接让子 agent 创建失败。豁免集合应当是作用域**自己注册**的工具,而不是恰好位于全局层的工具;后一种读法只在这两个集合重合时才成立。`view()` 现在过滤作用域继承来的一切——全局层与每个祖先层——只豁免它自己那层。这条自身层豁免是承重的而非顺带的:委派运行时把子 agent 的 `report` 与结构化输出工具注册进子 agent 自己那层,而一个只点名子 agent 可用能力的过滤器绝不能把它回报所依赖的机制一并剥掉。
## Alternatives considered
**在子 agent 的 setup 里按 id 重新挂载父方的 preset。** 语义与机制两方面都不成立而被否决。它会重读 roster 并重新 stat 组装文件,因此父方启动后的一次编辑就会把子 agent 分叉到另一个代际,而此后被删除的 preset 会让子 agent 失败、父方却照常运行。`mount()` 还是异步的,同步的创建窗口无法在不重构两个驱动的前提下接受它。
@@ -32,22 +34,26 @@ Status: implemented
**让 `dsh-subagent` 导入 `resolveSessionPreset` 并按解析出的 id 挂载。** 否决,因为这会给一个必须在没有 roster 时也能工作的包引入硬模块边,而且最终仍落回上述的重新挂载语义。
**过滤链上的每一层,包括作用域自身那层。** 否决,因为那会让逐子 agent 的能力过滤器把该子 agent 的回报与结构化输出工具一并删掉——它们由委派运行时注册进子 agent 自己那层——于是一个点名"子 agent 可用哪些能力"的 `allow` 会让它彻底无法回报。
**只修活着的加入,不动持久化 header。** 否决,因为那样活着的子 agent 与冷读同一个子 agent 会对"哪份组装产出了这段历史"给出不同答案——同一类缺陷,只是被搬了个地方而不是被修掉。
## Testing
`packages/preset/agent-presets/tests/mount.spec.ts` 用真实 fixture 组装覆盖该加入:子 agent 看到父方的工具与提示段、不会挂载出第二个代际、加入在父方 dispose 后依然成立(活得比父方久的后台子 agent)、上报的 id 一致、没有 preset 的父方不产生加入、以及无 scope 的上下文被拒绝。
`packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts` 在一个不含任何面向模型行的宿主组装上,通过 `startInProcessRun()` 断言模型可见的结果:子 agent 自身请求中的 schema、父方的提示段、记录下来的 header preset,以及在空白期切换过 preset 的父方——切换到**另一个** preset,这样断言才能区分"读父方活 scope 链"与"读父方创建 header"
`packages/core/tools/tests/scoped.spec.ts` 直接覆盖该限制规则:子 agent 的过滤器能移除它从祖先作用域继承来的工具、子 agent 自身的注册在自己的过滤器下存活、祖先的限制仍作用于其内嵌套的每个作用域
`packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts` 在一个不含任何面向模型行的宿主组装上,通过 `startInProcessRun()` 断言模型可见的结果:子 agent 自身请求中的 schema、父方的提示段、记录下来的 header preset、施加在继承来的 preset 工具之上的 `toolFilter`,以及在空白期切换过 preset 的父方——切换到**另一个** preset,这样断言才能区分"读父方活 scope 链"与"读父方创建 header"。
组装记录这一层用的是真实 shipped Web 组装的 e2e,而不是无密钥快照。本仓库所有可运行 example 都不组装 preset roster,因此该缺陷在快照 harness 里根本不可观察:要做快照场景,得先有一个既挂载 roster 又发起委派的 example。Web e2e 启动的是真实的 `base` + `web-app` 补丁层与两个 shipped preset,这正是测试政策要求的组装证据;Web 浏览器 lane 的 subagent golden 承载了可见后果——记录了 preset 的子 agent 现在会显示与其父方相同的 preset 徽标。
## Consequences
委派现在的成本是每个子 agent 一次 scope 认父,再无其他——没有额外的插件实例、没有 roster 读取、没有新的失败模式。子 agent 的能力恰好等于父方的能力——逐子 agent `toolFilter` 并不能收窄它,原因见下方另行跟踪的那条;逐 subagent 的 preset"agent 类型")仍未构建,那会是一个新的请求字段,而不是对这次加入的改动。
委派现在的成本是每个子 agent 一次 scope 认父,再无其他——没有额外的插件实例、没有 roster 读取、没有新的失败模式。子 agent 的能力恰好等于父方的能力,减去它自己`toolFilter` 所移除的部分;逐 subagent 的 preset"agent 类型")仍未构建,那会是一个新的请求字段,而不是对这次加入的改动。
`applyChildComposition()` 的形态变了,因此将来任何仓库外的进程内驱动都必须提供父方。这是刻意付出的代价:此前的签名允许调用方组装出一个毫无能力的子 agent 而不报任何错。
冷恢复的可继续子 agent 加入的是父方**当前**的组装,而不是它自己 header 所记录的那份。窗口很窄——父方必须先建子、保持空白、切换 preset,之后才唤醒它;驻留中的子 agent 不会重新加入,一次性子 agent 也不会恢复——而替代方案更糟:按子 agent 自己记录的 id 解析会重读 roster,把这次认父刻意规避掉的"preset 已删除"失败模式又请回来。子 agent 的 header 仍记录它启动时的那份,因此这处分歧是可观察的而非静默的。
`toolFilter` 约束不住已加入组装的子 agent,因为 `ToolRegistry` 只按全局层的名字编译限制,随后把 scope 链上的工具无过滤地叠加进来。这不是本次改动带来的——在组装了 roster 的部署里,`tools.restrict()` 本就把每个名字都判为未知全局工具,因此带过滤器的子 agent 在本次改动前后同样起不来——但它是搬到 agent 平面所引入的回归,而非长期存在的限制:同样这批工具注册在全局层时,过滤器能正常校验并生效。现在子 agent 有了父方的全套工具需要被限制,它变得更要紧。该问题另行跟踪;本次改动既未引入也未修复它
`ToolRegistry` 现在把限制的豁免集合读作"该作用域自己注册的东西"而不是"全局层",这在委派之外改变了一处既有行为:**祖先**作用域贡献的工具现在会受后代过滤器约束,而此前只有全局层的工具会。链上其余部分的豁免不变——作用域自身的注册仍在自己的过滤器之外,这正是委派运行时所依赖的性质
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/tools.md
tools.md: f8d86704a2237219530c8c23b46a68383458e1cf
tools.zh.md: 87269e5532b0cdfb0a38501d7b98c9986665df1d
tools.md: 6ff2d967c5631d096dd78236ffd0383ddb2b0493
tools.zh.md: 82ade5d8d4117387138296cf54fbc8e88ad335e7
+8 -7
View File
@@ -150,19 +150,20 @@ type InferArgs<S> = InferProperties<S, []>
Registration is a trusted same-process contract. The registry borrows the typed definition as readonly input, requires `output`, validates its raw schema, and checks semantic requirements such as a positive finite `timeoutMs`; `schemas()` constructs the model-facing projection when building a request, so execution and presentation share one resolved definition without leaking callbacks onto the wire.
## `ToolRestriction` — one scope's live global filter
## `ToolRestriction` — one scope's live filter over what it inherits
`ToolRestriction` applies only to the live deployment-global tool layer. The registry compiles readonly names into private sets, intersects multiple restrictions, then overlays scope-local tools. A deny-only filter admits later unlisted globals, while an allow-list excludes them.
`ToolRestriction` applies to the tools a scope inherits: the deployment-global layer plus every ancestor scope on its chain. The registry compiles readonly names into private sets, intersects multiple restrictions, then overlays the scope's OWN registrations, which stay exempt so a delegated child keeps the tools it answers through. A deny-only filter admits later unlisted inherited tools, while an allow-list excludes them.
```ts type-equiv
/**
* Per-scope filter over global tools. Restrictions intersect and do not affect
* scoped registrations or the reserved Code Mode transport.
* Per-scope filter over the tools a scope INHERITS — the global layer and
* every ancestor layer on its chain. Restrictions intersect, and do not affect
* the scope's own registrations or the reserved Code Mode transport.
*/
interface ToolRestriction {
/** Global tool names that stay visible; everything else is removed. */
/** Inherited tool names that stay visible; every other inherited one is removed. */
readonly allow?: readonly string[]
/** Global tool names removed from visibility. */
/** Inherited tool names removed from visibility. */
readonly deny?: readonly string[]
}
```
@@ -565,7 +566,7 @@ async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>
Types: [ScopeKey](scope.md)
Source: [`packages/core/tools/src/index.ts:760`](../../packages/core/tools/src/index.ts)
Source: [`packages/core/tools/src/index.ts:761`](../../packages/core/tools/src/index.ts)
<a id="tools-events"></a>
+8 -7
View File
@@ -150,19 +150,20 @@ type InferArgs<S> = InferProperties<S, []>
注册是一项受信任的同进程约定。注册表以 readonly 输入借用已类型化定义,要求它声明 `output`,校验其原始 schema,并检查 `timeoutMs` 必须为正有限值等语义要求;`schemas()` 在构建请求时生成面向模型的投影,使执行和展示共享同一份已解析定义,而不会将回调泄漏到协议上。
## `ToolRestriction` — 单个作用域的实时全局过滤器
## `ToolRestriction` — 单个作用域对其继承内容的实时过滤器
`ToolRestriction` 作用于实时的部署全局工具层。注册表将 readonly 名称编译为私有集合,对多个限制取交集,再叠加作用域本地工具。仅 deny 的过滤器允许后续未列出的全局工具通过,而 allow 列表则排除它们。
`ToolRestriction` 作用于该作用域继承来的工具:部署全局层,加上其链上的每个祖先作用域。注册表将 readonly 名称编译为私有集合,对多个限制取交集,再叠加作用域**自身**的注册——后者不受约束,因此被委派的子 agent 会保留其回报所依赖的工具。仅 deny 的过滤器允许后续未列出的继承工具通过,而 allow 列表则排除它们。
```ts type-equiv
/**
* Per-scope filter over global tools. Restrictions intersect and do not affect
* scoped registrations or the reserved Code Mode transport.
* Per-scope filter over the tools a scope INHERITS — the global layer and
* every ancestor layer on its chain. Restrictions intersect, and do not affect
* the scope's own registrations or the reserved Code Mode transport.
*/
interface ToolRestriction {
/** Global tool names that stay visible; everything else is removed. */
/** Inherited tool names that stay visible; every other inherited one is removed. */
readonly allow?: readonly string[]
/** Global tool names removed from visibility. */
/** Inherited tool names removed from visibility. */
readonly deny?: readonly string[]
}
```
@@ -565,7 +566,7 @@ async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>
Types: [ScopeKey](scope.md)
Source: [`packages/core/tools/src/index.ts:760`](../../packages/core/tools/src/index.ts)
Source: [`packages/core/tools/src/index.ts:761`](../../packages/core/tools/src/index.ts)
<a id="tools-events"></a>
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/core/tools/README.md
README.md: 2c9833c3505c765283559590c8bc28b3c2077e2e
README.zh.md: d7766b432c5a319d214da80e3df438489519be92
README.md: 21851ca887147364c76612bae2e6a00ebdccec39
README.zh.md: aec3b434e52f473001505bbea5212d5e247eb46f
+1 -1
View File
@@ -19,7 +19,7 @@ tools:
- `ctx.tools.register(definition: ToolDefinition): () => void` Register a trusted typed same-process definition with a mandatory canonical `output` declaration. The layer is the calling context's scope: a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, shadowing a same-named global tool there. Duplicate names within one layer throw; non-native modes also reject the reserved `run_code` transport name. Missing or unsupported output declarations and a non-positive or non-finite `timeoutMs` fail at registration. The optional synchronous `finalizeContent` callback is snapshotted when a call starts and may replace only final model-facing content after every pipeline outcome is normalized, including an error discovered while materializing another result field. Disposed with the calling fiber.
- `ctx.tools.presentAs(mode: ToolPresentationMode): () => void` selects this agent's model-facing presentation, shadowing the `mode` config for that agent alone; it throws from a plain context (a process-wide presentation is the config field) and from a second declaration in the same scope. A code mode also registers that agent's own `tools:sdk` section. The catalog is unchanged — `schemas(agent)` still reports the agent's capabilities; only the assembly's tools collapse. Disposed with the calling fiber.
- `ctx.tools.restrict(filter)` applies an agent-scoped allow/deny mask to global tools and throws from a plain context. The filter is snapshotted at registration; multiple masks intersect and scope-local tools merge afterwards. Deny masks admit later unnamed globals, while allow masks exclude later names. Unknown, local, or reserved names and empty filters reject. This is live visibility composition, not an authority boundary; see the [scope security non-goal](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals).
- `ctx.tools.restrict(filter)` applies an agent-scoped allow/deny mask to the tools that scope INHERITS — the global layer and every ancestor scope on its chain — and throws from a plain context. The scope's OWN registrations are exempt and merge afterwards, which is what keeps a delegated child's reporting and structured-output tools alive under a filter naming only the capabilities it may use. The filter is snapshotted at registration; multiple masks intersect, and a mask on an ancestor reaches every scope nested inside it. Deny masks admit later unnamed inherited tools, while allow masks exclude later names. Unknown, own-layer, or reserved names and empty filters reject. This is live visibility composition, not an authority boundary; see the [scope security non-goal](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals).
- `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined` Resolution as one scope sees it (shadowing applied; a restricted-away global reads as absent) — presenters pass the calling agent so the card matches what executed.
- `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]` Schemas of everything the scope can see (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog Agent Note](../../../.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md)).
- `ctx.tools.guard(guard: ToolGuard): () => void` Register a monotonic synchronous execution guard after `tools/pre-execute`: returning a reason denies the call, while `undefined` leaves it unchanged. A plain-context guard applies globally; an `agent.ctx` guard applies only to that agent. Later waterfall listeners cannot turn a guard denial back into permission. Disposed with the calling fiber.
+1 -1
View File
@@ -19,7 +19,7 @@ tools:
- `ctx.tools.register(definition: ToolDefinition): () => void`:注册一个受信任、带类型的同进程定义,其中必须包含规范的 `output` 声明。所在层由调用上下文的作用域决定:普通插件上下文会全局注册;agent 的 `agent.ctx` 只为该 agent 注册,并在此处遮蔽同名全局工具。同一层内名称重复会抛出;非原生模式还会拒绝保留的 `run_code` 传输名称。缺失或不受支持的输出声明,以及非正数或非有限的 `timeoutMs`,都会使注册失败。可选的同步 `finalizeContent` 回调会在调用开始时创建快照;在所有流水线结果规范化之后,它只能替换最终面向模型的内容,包括实体化其他结果字段时发现的错误。随调用 fiber dispose(资源释放)。
- `ctx.tools.presentAs(mode: ToolPresentationMode): () => void`:为本 agent 选择面向模型的呈现方式,仅对该 agent 遮蔽 `mode` 配置;从普通上下文调用会抛出(进程级呈现方式是那个配置字段),同一 scope 内第二次声明也会抛出。code 类模式还会为该 agent 注册它自己的 `tools:sdk` 段。清单本身不变——`schemas(agent)` 报告的仍是该 agent 的能力,坍缩的只是 assembly 里的工具。随调用方 fiber 一同释放。
- `ctx.tools.restrict(filter)`:对全局工具应用 agent 作用域的允许/拒绝掩码;从普通上下文调用会抛出。筛选器在注册时创建快照;多个掩码取交集,随后再合并作用域本地工具。拒绝掩码会接纳后来出现且未点名的全局工具,而允许掩码会排除后来出现的名称。未知、本地或保留名称以及空筛选器都会被拒绝。这是实时可见性组合,不是权限边界;参见[作用域安全非目标](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals)。
- `ctx.tools.restrict(filter)`:对该作用域**继承来的**工具——全局层以及其链上的每个祖先作用域——应用 agent 作用域的允许/拒绝掩码;从普通上下文调用会抛出。作用域**自身**的注册不受掩码约束,并在其后合并进来,这正是让被委派子 agent 的回报与结构化输出工具能在只点名其可用能力的筛选器下存活的机制。筛选器在注册时创建快照;多个掩码取交集,祖先上的掩码作用于其内嵌套的每个作用域。拒绝掩码会接纳后来出现且未点名的继承工具,而允许掩码会排除后来出现的名称。未知、自身层或保留名称以及空筛选器都会被拒绝。这是实时可见性组合,不是权限边界;参见[作用域安全非目标](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals)。
- `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined`:按某个作用域所见的结果解析(应用遮蔽;被限制掉的全局工具视为不存在)。呈现器会传入发起调用的 agent,使卡片与实际执行内容一致。
- `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]`:返回该作用域可见的所有 schema(不含 `execute` 函数)。已交付工具的 schema 收录在 [docs/tool-catalog.md](../../../docs/tool-catalog.md) 中;该目录通过启动每个工具插件并采集此方法的结果生成(参见[工具 schema 目录 Agent Note](../../../.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md))。
- `ctx.tools.guard(guard: ToolGuard): () => void`:在 `tools/pre-execute` 之后注册单调同步执行守卫:返回理由会拒绝调用,返回 `undefined` 则保持原决定。普通上下文守卫全局生效;`agent.ctx` 守卫只对该 agent 生效。后续 waterfall(瀑布式事件)监听器无法将守卫的拒绝重新变为允许。随调用 fiber dispose。
+42 -17
View File
@@ -647,13 +647,14 @@ export interface Config {
}
/**
* Per-scope filter over global tools. Restrictions intersect and do not affect
* scoped registrations or the reserved Code Mode transport.
* Per-scope filter over the tools a scope INHERITS — the global layer and
* every ancestor layer on its chain. Restrictions intersect, and do not affect
* the scope's own registrations or the reserved Code Mode transport.
*/
export interface ToolRestriction {
/** Global tool names that stay visible; everything else is removed. */
/** Inherited tool names that stay visible; every other inherited one is removed. */
readonly allow?: readonly string[]
/** Global tool names removed from visibility. */
/** Inherited tool names removed from visibility. */
readonly deny?: readonly string[]
}
@@ -669,7 +670,7 @@ interface ToolView {
readonly visible: ReadonlyMap<string, ToolDefinition>
/** Pre-restriction capability names used by prompt-order validation. */
readonly knownNames: ReadonlySet<string>
/** Current global names that a scoped restriction may name. */
/** Current inherited names a scoped restriction may name; its own are exempt. */
readonly restrictableNames: ReadonlySet<string>
}
@@ -707,7 +708,7 @@ class ToolLayer implements ScopeLayer {
&& this.mode === undefined
}
/** Whether every compiled restriction in this layer admits a global tool name. */
/** Whether every compiled restriction in this layer admits an inherited tool name. */
admits(name: string): boolean {
for (const filter of this.restrictions.values()) {
if ((filter.allow !== undefined && !filter.allow.has(name))
@@ -1029,7 +1030,7 @@ export class ToolRegistry extends Service {
const known = this.view(scope).restrictableNames
const unknown = [...allow ?? [], ...deny ?? []].filter(name => !known.has(name))
if (unknown.length > 0) {
throw new Error(`tools.restrict() names unknown global tool${unknown.length > 1 ? 's' : ''} ${unknown.map(n => `"${n}"`).join(', ')}; known global tools: ${[...known].sort().join(', ') || '(none)'}`)
throw new Error(`tools.restrict() names unknown inherited tool${unknown.length > 1 ? 's' : ''} ${unknown.map(n => `"${n}"`).join(', ')}; a restriction filters what this scope inherits, never what it registers itself. Restrictable tools: ${[...known].sort().join(', ') || '(none)'}`)
}
return this.layers.effect(
this.ctx,
@@ -1070,30 +1071,54 @@ export class ToolRegistry extends Service {
/**
* Resolve every registry fact one scope needs in one layer traversal. The
* visible map applies global restrictions, scoped shadowing, and the reserved
* presentation transport; the other sets retain the pre-restriction facts
* needed by restriction and prompt-order validation.
* visible map applies restrictions to the INHERITED surface, then the
* scope's own registrations and the reserved presentation transport; the
* other sets retain the pre-restriction facts needed by restriction and
* prompt-order validation.
*
* A restriction filters what a scope inherits — the global layer and every
* ancestor layer on its chain — and never what its OWN layer registers.
* That exemption is what a per-child capability filter has to keep intact:
* the delegation runtime registers a child's reporting and structured-output
* tools into the child's own layer, and a filter naming the capabilities the
* child may use must not strip the machinery it answers through.
*
* Reading the exempt set as "the global layer" instead of "not mine" held
* only while every model-facing tool sat in the host composition. Once
* presets moved them onto the agent plane they became an ANCESTOR
* contribution, so a child's filter silently stopped constraining anything
* it was given.
* @param scope - the viewing scope (the agent), or undefined for the global view.
* @returns the complete derived view for that scope.
*/
private view(scope?: ScopeKey): ToolView {
// Scope-chain layers, farthest ancestor first, the exact scope last.
const layers = this.layers.chainLayers(scope)
// Chain-blind on purpose: this is the ONE layer whose registrations the
// scope owns rather than inherits, and it is absent until the scope
// contributes something.
const own = this.layers.peek(scope)
// Inherited surface, nearest ancestor last: a nearer scope's same-name
// entry shadows a farther one, and the global layer is the farthest.
const inherited = new Map<string, ToolDefinition>(this.layers.global.tools.entries())
for (const layer of layers) {
if (layer === own) continue
for (const [name, definition] of layer.tools.entries()) inherited.set(name, definition)
}
const visible = new Map<string, ToolDefinition>()
const knownNames = new Set<string>()
const restrictableNames = new Set<string>()
for (const [name, definition] of this.layers.global.tools.entries()) {
for (const [name, definition] of inherited) {
knownNames.add(name)
restrictableNames.add(name)
// Restrictions intersect across the whole chain: any scope on it may
// mask a global-surface name for everything nested inside it.
// mask an inherited name for everything nested inside it.
if (layers.every(layer => layer.admits(name))) visible.set(name, definition)
}
// Chain layers second, nearest last: same-name entries REPLACE (shadow)
// the global and farther-scope ones, and scope-local registrations are
// never part of the global filter above.
for (const layer of layers) {
for (const [name, definition] of layer.tools.entries()) {
// The scope's own registrations last, shadowing an inherited name and
// outside the filter above.
if (own !== undefined) {
for (const [name, definition] of own.tools.entries()) {
knownNames.add(name)
visible.set(name, definition)
}
+69 -6
View File
@@ -1,7 +1,7 @@
import { describe, expect, expectTypeOf, it, vi } from 'vitest'
import { Context } from 'cordis'
import type { Events } from 'cordis'
import { createScope } from '@deepseek-ai/dsh-scope'
import { bindScopeParent, createScope } from '@deepseek-ai/dsh-scope'
import type { Scope } from '@deepseek-ai/dsh-scope'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
@@ -181,21 +181,84 @@ describe('restrict()', () => {
expect(ctx.tools.schemas(key).map(t => t.name)).toEqual(['b'])
})
it('fails loud on an unscoped call, an empty filter, and non-global names', async () => {
it('fails loud on an unscoped call, an empty filter, and names it does not inherit', async () => {
const ctx = await mount()
const { scope } = await mintAgentScope(ctx, 'a')
ctx.tools.register(tool('real'))
scope.ctx.tools.register(tool('local'))
expect(() => ctx.tools.restrict({ deny: ['real'] })).toThrow(/requires a scoped context/)
expect(() => scope.ctx.tools.restrict({})).toThrow(/no-op/)
expect(() => scope.ctx.tools.restrict({ allow: ['local'] })).toThrow(/unknown global tool "local"/)
expect(() => scope.ctx.tools.restrict({ allow: ['reall'] })).toThrow(/unknown global tool "reall"; known global tools: real/)
expect(() => scope.ctx.tools.restrict({ deny: ['ghost', 'wraith'] })).toThrow(/unknown global tools "ghost", "wraith"/)
// A scope's own registration is exempt from its own filter, so naming it
// is a caller error rather than a silent no-op.
expect(() => scope.ctx.tools.restrict({ allow: ['local'] })).toThrow(/unknown inherited tool "local"/)
expect(() => scope.ctx.tools.restrict({ allow: ['reall'] })).toThrow(/unknown inherited tool "reall".*Restrictable tools: real/s)
expect(() => scope.ctx.tools.restrict({ deny: ['ghost', 'wraith'] })).toThrow(/unknown inherited tools "ghost", "wraith"/)
const emptyCtx = await mount()
const { scope: emptyScope } = await mintAgentScope(emptyCtx, 'empty')
expect(() => emptyScope.ctx.tools.restrict({ deny: ['ghost'] }))
.toThrow(/known global tools: \(none\)/)
.toThrow(/Restrictable tools: \(none\)/)
})
})
describe('restrict() over an inherited scope layer', () => {
/** Mint a child scope parented to `parent`, as a subagent's creation window does. */
async function mintChild(ctx: Context, parentKey: Agent, name: string): Promise<{ scope: Scope; key: Agent }> {
const key = { id: name as SessionId } as Agent
bindScopeParent(key, parentKey)
let scope!: Scope
await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, key) },
{ inject: ['tools', 'systemPrompt'] }))
return { scope, key }
}
it('filters tools the child inherits from an ancestor scope, not only global ones', async () => {
// The shape every preset deployment has: no model-facing row in the global
// layer, all of them contributed by an ancestor scope the child joined.
const ctx = await mount()
const parent = await mintAgentScope(ctx, 'parent')
parent.scope.ctx.tools.register(tool('bash'))
parent.scope.ctx.tools.register(tool('read'))
const child = await mintChild(ctx, parent.key, 'child')
expect(ctx.tools.schemas(child.key).map(t => t.name).sort()).toEqual(['bash', 'read'])
child.scope.ctx.tools.restrict({ deny: ['bash'] })
// Reading the exempt set as "the global layer" left this unfiltered, and
// the name unrestrictable in the first place.
expect(ctx.tools.schemas(child.key).map(t => t.name)).toEqual(['read'])
expect(await run(ctx, 'bash', child.key)).toBe('Error: unknown tool "bash"')
// The ancestor keeps its whole surface: a child's filter is its own.
expect(ctx.tools.schemas(parent.key).map(t => t.name).sort()).toEqual(['bash', 'read'])
})
it('keeps the child\'s own registrations outside its own filter', async () => {
// The delegation runtime registers a child's reporting and structured
// output tools into the child's own layer; an `allow` naming only the
// capabilities the child may use must not strip them.
const ctx = await mount()
const parent = await mintAgentScope(ctx, 'parent')
parent.scope.ctx.tools.register(tool('bash'))
parent.scope.ctx.tools.register(tool('read'))
const child = await mintChild(ctx, parent.key, 'child')
child.scope.ctx.tools.register(tool('report'))
child.scope.ctx.tools.restrict({ allow: ['read'] })
expect(ctx.tools.schemas(child.key).map(t => t.name).sort()).toEqual(['read', 'report'])
expect(await run(ctx, 'report', child.key)).toBe('ran:report')
})
it('lets an ancestor\'s restriction reach every scope nested inside it', async () => {
const ctx = await mount()
ctx.tools.register(tool('web'))
const parent = await mintAgentScope(ctx, 'parent')
parent.scope.ctx.tools.register(tool('bash'))
const child = await mintChild(ctx, parent.key, 'child')
parent.scope.ctx.tools.restrict({ deny: ['web'] })
expect(ctx.tools.schemas(child.key).map(t => t.name)).toEqual(['bash'])
expect(ctx.tools.schemas(parent.key).map(t => t.name)).toEqual(['bash'])
})
})
@@ -103,6 +103,21 @@ describe('a child agent composed in-process', () => {
await run.dispose()
})
it('honours a tool filter over the preset tools it inherited', async () => {
const { ctx, parent } = await setupPresetHost()
const run = await startInProcessRun(
{ ...spawnRequest(parent), toolFilter: { deny: ['preset_only'] } },
{},
)
await run.result
// The capability filter is the only thing bounding a delegated child, and
// every tool it can name now arrives from the preset rather than the host.
expect(ctx.tools.schemas(run.localAgent).map(schema => schema.name)).toEqual([])
await run.dispose()
})
it('follows a parent that switched preset while blank', async () => {
const { ctx, parent } = await setupPresetHost()
// A DIFFERENT preset, so the assertion below distinguishes reading the
@@ -298,7 +298,7 @@ describe('startInProcessRun', () => {
await expect(startInProcessRun({
...request(parent),
toolFilter: { deny: ['unknown-tool'] },
}, {})).rejects.toThrow('unknown global tool')
}, {})).rejects.toThrow('unknown inherited tool')
expect(ctx.agents.list()).toHaveLength(beforeAgents)
expect(ctx.sessions.list()).toHaveLength(beforeSessions)
})
@@ -436,7 +436,7 @@ describe('dsh-subagent-spawn', () => {
prompt: [{ type: 'text', text: 'do X' }],
parent,
toolFilter: { deny: ['no_such_tool'] },
})).rejects.toThrow(/unknown global tool "no_such_tool"/)
})).rejects.toThrow(/unknown inherited tool "no_such_tool"/)
expect(ctx.agents.list().length).toBe(before)
})
})