fix(agent-core): enforce swarm worker tool allowlist and propagate abort

This commit is contained in:
Kaiyi 2026-05-29 15:33:41 +08:00
parent b0b61c27ca
commit d6a3d91c72
3 changed files with 42 additions and 8 deletions

View file

@ -1,6 +1,7 @@
import { mapWithConcurrency } from './concurrency';
import { parsePlan } from './parse';
import {
ALLOWED_WORKER_TOOLS,
DEFAULT_WORKER_TOOLS,
PLANNER_SYSTEM_PROMPT,
SYNTHESIZER_SYSTEM_PROMPT,
@ -73,7 +74,9 @@ export class SwarmCoordinator {
const out = await this.deps.spawnSubagent({
profileName: `swarm:${st.role}`,
systemPrompt: st.systemPrompt,
tools: st.toolAllowlist ?? DEFAULT_WORKER_TOOLS,
tools: (st.toolAllowlist ?? DEFAULT_WORKER_TOOLS).filter((t) =>
ALLOWED_WORKER_TOOLS.includes(t),
),
prompt: st.prompt,
description: st.role,
signal: this.deps.signal,
@ -82,6 +85,7 @@ export class SwarmCoordinator {
st.status = 'done';
this.progress(`${st.role}: done`);
} catch (err) {
if (this.deps.signal.aborted) throw err;
st.status = 'failed';
st.error = err instanceof Error ? err.message : String(err);
this.progress(`${st.role}: failed (${st.error})`);

View file

@ -1,16 +1,13 @@
import type { SwarmPlan } from './types';
/** Read-only default tool set for workers; planner may widen via toolAllowlist. */
export const DEFAULT_WORKER_TOOLS: string[] = ['Read', 'Grep', 'Glob', 'WebSearch', 'FetchURL'];
/** Read-only default tool set for workers; planner may widen via toolAllowlist within the allowlist. */
export const DEFAULT_WORKER_TOOLS: readonly string[] = ['Read', 'Grep', 'Glob', 'WebSearch', 'FetchURL'];
/** Tool names a worker is allowed to request (excludes Agent/Swarm dispatch tools). */
export const ALLOWED_WORKER_TOOLS: string[] = [
/** Tool names a worker is allowed to request. Read-only for Phase 1 (no Write/Edit/Bash, no dispatch tools). */
export const ALLOWED_WORKER_TOOLS: readonly string[] = [
'Read',
'Write',
'Edit',
'Grep',
'Glob',
'Bash',
'WebSearch',
'FetchURL',
'ReadMediaFile',

View file

@ -80,4 +80,37 @@ describe('SwarmCoordinator.run', () => {
expect(result).toBe('SYNTH');
expect(onProgress.mock.calls.some((c) => /failed/i.test(String(c[0])))).toBe(true);
});
it('strips disallowed tools (Agent/Bash) from a planner-supplied allowlist', async () => {
const planWithBadTools = JSON.stringify({
subtasks: [{ role: 'X', systemPrompt: 's', prompt: 'p', toolAllowlist: ['Agent', 'Read', 'Bash'] }],
});
const spawn = vi.fn(async (args) => {
if (args.profileName === 'swarm-planner') return { result: planWithBadTools };
if (args.profileName === 'swarm-synthesizer') return { result: 'S' };
return { result: 'w' };
});
const coordinator = new SwarmCoordinator({ spawnSubagent: spawn, signal: new AbortController().signal });
await coordinator.run('x');
const worker = (spawn as ReturnType<typeof vi.fn>).mock.calls
.map((c) => c[0])
.find((c) => c.profileName === 'swarm:X');
expect(worker?.tools).toEqual(['Read']);
});
it('propagates abort instead of swallowing it (no synthesis after cancel)', async () => {
const controller = new AbortController();
const PLAN = JSON.stringify({ subtasks: [{ role: 'A', systemPrompt: 's', prompt: 'p' }] });
const spawn = vi.fn(async (args) => {
if (args.profileName === 'swarm-planner') return { result: PLAN };
controller.abort();
const e = new Error('aborted');
e.name = 'AbortError';
throw e;
});
const coordinator = new SwarmCoordinator({ spawnSubagent: spawn, signal: controller.signal });
await expect(coordinator.run('x')).rejects.toThrow();
const profiles = (spawn as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0].profileName);
expect(profiles).not.toContain('swarm-synthesizer');
});
});