fix(hub): stop unrestricted roles from loading multi-agent tools

The claude_code preset exposed Agent/SendMessage/Task. Background agents
abort with reason "background", which the SDK maps to Bash
toolDenialKind "cancelled" ("user doesn't want this action") and freezes
command execution mid-run.
This commit is contained in:
2026-07-25 13:40:27 +08:00
parent 9e26585e1f
commit 755704e2ae
2 changed files with 61 additions and 13 deletions
+24 -9
View File
@@ -140,9 +140,8 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
let cleanupSecurity = async (): Promise<void> => {};
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<RunResult> {
cleanupSecurity = security.cleanup;
const hasSkills = security.skillIds.length > 0;
type QueryOptions = NonNullable<Parameters<typeof query>[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