fix(tool-subagent): a partial toolFilter must not materialize an empty allow-list
ds-review-bot finding: forcing only the OUTER toolFilter key absent left the nested arrays materializing — a deny-only config gained allow: [], which means deny-EVERYTHING. The nested arrays now default to undefined too; an explicit allow: [] (grant-only children) still survives. Pinned by a capture-provider regression test.
This commit is contained in:
@@ -102,9 +102,14 @@ export const Config: z<Config> = z.object({
|
|||||||
// deny-everything, silently. Force the omitted key to stay absent (the same
|
// deny-everything, silently. Force the omitted key to stay absent (the same
|
||||||
// shape discipline as SystemPrompt's toolOrder); the cast is needed because
|
// shape discipline as SystemPrompt's toolOrder); the cast is needed because
|
||||||
// .default() expects the object type.
|
// .default() expects the object type.
|
||||||
|
// The NESTED arrays get the same treatment as the object itself: a partial
|
||||||
|
// filter ({deny: […]}) must not materialize allow: [] beside it — an empty
|
||||||
|
// allow-list means deny-EVERYTHING, so the materialized default would turn
|
||||||
|
// a deny-one config into deny-all. An EXPLICIT allow: [] (grant-only
|
||||||
|
// children) survives, since only the omitted key defaults to undefined.
|
||||||
toolFilter: z.object({
|
toolFilter: z.object({
|
||||||
allow: z.array(z.string()),
|
allow: z.array(z.string()).default(undefined as unknown as string[]),
|
||||||
deny: z.array(z.string()),
|
deny: z.array(z.string()).default(undefined as unknown as string[]),
|
||||||
}).default(undefined as unknown as { allow: string[]; deny: string[] }),
|
}).default(undefined as unknown as { allow: string[]; deny: string[] }),
|
||||||
maxDepth: z.number(),
|
maxDepth: z.number(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -484,4 +484,30 @@ describe('dsh-tool-subagent', () => {
|
|||||||
expect(seen?.toolFilter).toMatchObject({ deny: ['subagent'] })
|
expect(seen?.toolFilter).toMatchObject({ deny: ['subagent'] })
|
||||||
expect(seen?.maxDepth).toBe(2)
|
expect(seen?.maxDepth).toBe(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('a partial toolFilter (deny only) does not materialize an empty allow-list (deny-all trap)', async () => {
|
||||||
|
let seen: { toolFilter?: { allow?: string[]; deny?: string[] } } | undefined
|
||||||
|
const ctx = new Context()
|
||||||
|
await ctx.plugin(SystemPrompt)
|
||||||
|
await ctx.plugin(ToolRegistry)
|
||||||
|
await ctx.plugin(SubagentService)
|
||||||
|
ctx.subagents.registerProvider({
|
||||||
|
name: 'capture3',
|
||||||
|
capabilities: { outputSchema: false, depthLimit: false, toolFilter: true, persona: false },
|
||||||
|
inheritsParentContext: false,
|
||||||
|
start: (request) => {
|
||||||
|
seen = request
|
||||||
|
return {
|
||||||
|
id: AgentId('capture3-child'),
|
||||||
|
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
||||||
|
cancel() {},
|
||||||
|
dispose: async () => {},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await ctx.plugin(tool, { provider: 'capture3', toolFilter: { deny: ['subagent'] } })
|
||||||
|
await callSubagent(ctx, { description: 'd', prompt: 'p' })
|
||||||
|
expect(seen?.toolFilter).toEqual({ deny: ['subagent'] })
|
||||||
|
expect(seen?.toolFilter).not.toHaveProperty('allow')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user