diff --git a/hub/src/agent/runner.ts b/hub/src/agent/runner.ts index e59b91a..76f25f8 100644 --- a/hub/src/agent/runner.ts +++ b/hub/src/agent/runner.ts @@ -140,9 +140,8 @@ export async function runAgent(req: RunRequest): Promise { let cleanupSecurity = async (): Promise => {}; try { await persistAgentMessage(req, "user", req.prompt); - // Role tools JSON null means unrestricted (omit), not "deny all". + // Role tools JSON null means the default single-agent tool set, not "deny all". const roleToolIds = req.tools === null ? undefined : req.tools; - const unrestricted = roleToolIds === undefined; const toolConfig = claudeSdkToolConfigForRole(roleToolIds); const workspaceRoot = req.project.workspaceRoot?.trim(); if (workspaceRoot === undefined || workspaceRoot === "") { @@ -158,25 +157,41 @@ export async function runAgent(req: RunRequest): Promise { cleanupSecurity = security.cleanup; const hasSkills = security.skillIds.length > 0; type QueryOptions = NonNullable[0]["options"]>; - // When unrestricted, pass the SDK default toolset (`--tools default`) instead of - // an explicit subset. Native claude uses that path to register bundled tools like - // TodoWrite; listing names alone can omit them from the model's function list. - // allowedTools still carries explicit MCP names + TodoWrite for permission. + // Always use an explicit tool list — never the claude_code preset. + // The preset registers Agent/SendMessage/Task multi-agent machinery. + // Concurrent background agents abort with reason "background", and the + // Claude Agent SDK maps that to toolDenialKind "cancelled" with: + // "The user doesn't want to take this action right now..." + // which freezes Bash mid-run while Read/Glob continue to work. + // Hub "unrestricted" means the default single-agent built-ins + MCP, not + // the full interactive Claude product surface. const skillExtras = hasSkills ? (["Skill"] as const) : ([] as const); - const toolsOption: QueryOptions["tools"] = unrestricted - ? { type: "preset", preset: "claude_code" } - : uniqueTools([...toolConfig.tools, "TodoWrite", ...skillExtras]); + const toolsOption: QueryOptions["tools"] = uniqueTools([ + ...toolConfig.tools, + "TodoWrite", + ...skillExtras, + ]); const allowedToolsOption = uniqueTools([ ...toolConfig.allowedTools, "TodoWrite", "mcp__cph_hub__todo_write", ...skillExtras, ]); + // Hard deny multi-agent orchestration even if a future preset/skills path + // reintroduces them — bypassPermissions would otherwise auto-allow them. + const disallowedToolsOption = [ + "Agent", + "SendMessage", + "TeamCreate", + "Task", + "ScheduleWakeup", + ] as const; const options: QueryOptions = { cwd: security.cwd, tools: toolsOption, allowedTools: allowedToolsOption, + disallowedTools: [...disallowedToolsOption], maxTurns: cap, includePartialMessages: true, // ADR-0018: bypass interactive prompts (headless server); the sandbox diff --git a/hub/test/unit/runner.test.ts b/hub/test/unit/runner.test.ts index c51762c..df324f6 100644 --- a/hub/test/unit/runner.test.ts +++ b/hub/test/unit/runner.test.ts @@ -114,10 +114,9 @@ describe("runAgent", () => { allowDangerouslySkipPermissions: true, settingSources: [], settings: { disableBundledSkills: true, todoFeatureEnabled: true }, - skills: [], - tools: { type: "preset", preset: "claude_code" }, - allowedTools: expect.arrayContaining(["TodoWrite"]), - strictMcpConfig: true, + tools: expect.arrayContaining(["Read", "Write", "Bash", "Glob", "Grep", "TodoWrite"]), + allowedTools: expect.arrayContaining(["TodoWrite", "mcp__cph_hub__todo_write"]), + disallowedTools: expect.arrayContaining(["Agent", "SendMessage", "Task", "TeamCreate", "ScheduleWakeup"]), sandbox: expect.objectContaining({ enabled: true, failIfUnavailable: true, @@ -132,6 +131,38 @@ describe("runAgent", () => { }); }); + it("never exposes multi-agent orchestration tools on unrestricted roles", async () => { + queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1"))); + + await runAgent({ + prompt: "继续", + model: undefined, + project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace }, + systemPrompt: undefined, + tools: null, + runId: "run-1", + sessionId: "hub-session-1", + prisma: stubPrisma, + }); + + const call = queryMock.mock.calls[0]?.[0] as { + options?: { tools?: unknown; disallowedTools?: string[] }; + } | undefined; + expect(call?.options?.tools).toEqual([ + "Read", + "Write", + "Bash", + "Glob", + "Grep", + "WebFetch", + "WebSearch", + "TodoWrite", + ]); + for (const blocked of ["Agent", "SendMessage", "Task", "TeamCreate", "ScheduleWakeup"]) { + expect(call?.options?.disallowedTools).toContain(blocked); + } + }); + it("does not send resume for a fresh Hub session", async () => { queryMock.mockReturnValue(messages(assistantMessage("fresh"), resultMessage("sdk-session-1"))); @@ -194,6 +225,7 @@ describe("runAgent", () => { "mcp__cph_hub__todo_write", ], settings: expect.objectContaining({ todoFeatureEnabled: true }), + disallowedTools: expect.arrayContaining(["Agent", "SendMessage"]), }, }); }); @@ -217,6 +249,7 @@ describe("runAgent", () => { tools: ["TodoWrite"], allowedTools: ["TodoWrite", "mcp__cph_hub__todo_write"], settings: expect.objectContaining({ todoFeatureEnabled: true }), + disallowedTools: expect.arrayContaining(["Agent", "SendMessage"]), }, }); });