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

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)
}