diff --git a/hub/src/agent/roleTools.ts b/hub/src/agent/roleTools.ts index 969ac1e..9d5bc9f 100644 --- a/hub/src/agent/roleTools.ts +++ b/hub/src/agent/roleTools.ts @@ -77,8 +77,11 @@ const SUPPORTED_ROLE_TOOLS = new Set([ ...Object.keys(ROLE_TOOL_TO_CPH_HUB_MCP_TOOLS), ]); -export function claudeSdkToolConfigForRole(roleTools: readonly string[] | undefined): ClaudeSdkToolConfig { - if (roleTools === undefined) { +export function claudeSdkToolConfigForRole( + roleTools: readonly string[] | null | undefined, +): ClaudeSdkToolConfig { + // DB/runtime "unrestricted" is JSON null; treat the same as undefined. + if (roleTools === undefined || roleTools === null) { const mcpTools = CPH_HUB_MCP_TOOL_IDS.map(claudeMcpToolName); return { tools: [...DEFAULT_CLAUDE_BUILT_IN_TOOLS], @@ -103,8 +106,10 @@ export function claudeSdkToolConfigForRole(roleTools: readonly string[] | undefi return { tools: builtIns, allowedTools }; } -export function cphHubMcpToolsForRole(roleTools: readonly string[] | undefined): readonly CphHubMcpToolId[] { - if (roleTools === undefined) return [...CPH_HUB_MCP_TOOL_IDS]; +export function cphHubMcpToolsForRole( + roleTools: readonly string[] | null | undefined, +): readonly CphHubMcpToolId[] { + if (roleTools === undefined || roleTools === null) return [...CPH_HUB_MCP_TOOL_IDS]; const tools: CphHubMcpToolId[] = []; for (const roleTool of roleTools) { @@ -116,8 +121,11 @@ export function cphHubMcpToolsForRole(roleTools: readonly string[] | undefined): return tools; } -export function roleToolsAllow(roleTools: readonly string[] | undefined, roleTool: string): boolean { - if (roleTools === undefined) return true; +export function roleToolsAllow( + roleTools: readonly string[] | null | undefined, + roleTool: string, +): boolean { + if (roleTools === undefined || roleTools === null) return true; for (const configured of roleTools) { assertSupportedRoleTool(configured); if (configured === roleTool) return true; diff --git a/hub/src/agent/runner.ts b/hub/src/agent/runner.ts index cdc23b1..ad4beb3 100644 --- a/hub/src/agent/runner.ts +++ b/hub/src/agent/runner.ts @@ -140,7 +140,10 @@ export async function runAgent(req: RunRequest): Promise { let cleanupSecurity = async (): Promise => {}; try { await persistAgentMessage(req, "user", req.prompt); - const toolConfig = claudeSdkToolConfigForRole(req.tools); + // Role tools JSON null means unrestricted (omit), 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 === "") { throw new Error("Agent run requires the configured workspace root"); @@ -154,20 +157,25 @@ 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. + const skillExtras = hasSkills ? (["Skill"] as const) : ([] as const); + const toolsOption: QueryOptions["tools"] = unrestricted + ? { type: "preset", preset: "claude_code" } + : uniqueTools([...toolConfig.tools, "TodoWrite", ...skillExtras]); + const allowedToolsOption = uniqueTools([ + ...toolConfig.allowedTools, + "TodoWrite", + ...skillExtras, + ]); + const options: QueryOptions = { cwd: security.cwd, - // `skills` controls discovery/allowlisting, but an explicit `tools` - // list still has to expose the Skill dispatcher itself. - // TodoWrite is always available so multi-step runs can surface a live checklist - // on the Feishu card (Manus-style progress), even when a role whitelists tools. - tools: uniqueTools([ - ...toolConfig.tools, - "TodoWrite", - ...(hasSkills ? ["Skill"] : []), - ]), - allowedTools: uniqueTools([...toolConfig.allowedTools, "TodoWrite"]), + tools: toolsOption, + allowedTools: allowedToolsOption, 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 24e8394..e0cbd08 100644 --- a/hub/test/unit/runner.test.ts +++ b/hub/test/unit/runner.test.ts @@ -115,7 +115,7 @@ describe("runAgent", () => { settingSources: [], settings: { disableBundledSkills: true, todoFeatureEnabled: true }, skills: [], - tools: expect.arrayContaining(["TodoWrite"]), + tools: { type: "preset", preset: "claude_code" }, allowedTools: expect.arrayContaining(["TodoWrite"]), strictMcpConfig: true, sandbox: expect.objectContaining({