From a0509b9b5442d86fe0e48fa9fbcd7e5d0442e4b4 Mon Sep 17 00:00:00 2001 From: Felix <24791380+vcfgv@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:44:45 +0800 Subject: [PATCH] feat(auth): deny subagent tools for ClawX desktop (#1147) --- electron/utils/openclaw-auth.ts | 54 ++++++++++++++++++++++++++++ tests/unit/openclaw-auth.test.ts | 23 ++++++++---- tests/unit/sanitize-config.test.ts | 56 ++++++++++++++++++++++++++++++ 3 files changed, 127 insertions(+), 6 deletions(-) diff --git a/electron/utils/openclaw-auth.ts b/electron/utils/openclaw-auth.ts index 7a9afcda..a5d95e3a 100644 --- a/electron/utils/openclaw-auth.ts +++ b/electron/utils/openclaw-auth.ts @@ -2812,6 +2812,13 @@ export async function updateSingleAgentModelProvider( const SKILL_WORKSHOP_TOOL_DENY_ENTRY = 'skill_workshop'; const SKILL_CREATOR_SKILL_KEY = 'skill-creator'; +// ClawX desktop does not expose the subagent workflow. Deny the spawn entry +// point and its companion tools so the model cannot spawn child agent +// sessions (runtime="subagent") even under tools.profile="full". +// `sessions_spawn` is the only tool that creates a subagent; `sessions_yield` +// and `subagents` are the receive/list companions and are useless without it. +const SUBAGENT_TOOL_DENY_ENTRIES = ['sessions_spawn', 'sessions_yield', 'subagents'] as const; + function normalizeToolDenyList(value: unknown): string[] { return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') @@ -2828,6 +2835,22 @@ function ensureToolDenyIncludes( return { deny: [...deny, entry], modified: true }; } +function ensureToolDenyIncludesAll( + deny: string[], + entries: readonly string[], +): { deny: string[]; modified: boolean } { + let current = deny; + let modified = false; + for (const entry of entries) { + const result = ensureToolDenyIncludes(current, entry); + if (result.modified) { + current = result.deny; + modified = true; + } + } + return { deny: current, modified }; +} + export async function sanitizeOpenClawConfig(): Promise { return withConfigLock(async () => { // Skip sanitization if the config file does not exist yet. @@ -3027,6 +3050,22 @@ export async function sanitizeOpenClawConfig(): Promise { toolsModified = true; } + // ClawX desktop does not expose the subagent workflow. Deny the spawn + // entry point and its companions even under tools.profile="full" so the + // model cannot spawn child agent sessions (runtime="subagent"). + const subagentDenyResult = ensureToolDenyIncludesAll( + normalizeToolDenyList(toolsConfig.deny), + SUBAGENT_TOOL_DENY_ENTRIES, + ); + if (subagentDenyResult.modified) { + toolsConfig.deny = subagentDenyResult.deny; + toolsModified = true; + console.log(`[sanitize] Added subagent tool(s) to tools.deny for ClawX desktop: ${SUBAGENT_TOOL_DENY_ENTRIES.join(', ')}`); + } else if (!Array.isArray(toolsConfig.deny) || toolsConfig.deny.length !== subagentDenyResult.deny.length) { + toolsConfig.deny = subagentDenyResult.deny; + toolsModified = true; + } + // ── tools.exec approvals (OpenClaw 3.28+) ────────────────────── // ClawX is a local desktop app where the user is the trusted operator. // Exec approval prompts add unnecessary friction in this context, so we @@ -3088,6 +3127,21 @@ export async function sanitizeOpenClawConfig(): Promise { gatewayTools.deny = gatewayDenyResult.deny; gatewayModified = true; } + + // Mirror the subagent tool deny into gateway.tools.deny so gateway-side + // sessions cannot spawn subagents either. + const gatewaySubagentDenyResult = ensureToolDenyIncludesAll( + normalizeToolDenyList(gatewayTools.deny), + SUBAGENT_TOOL_DENY_ENTRIES, + ); + if (gatewaySubagentDenyResult.modified) { + gatewayTools.deny = gatewaySubagentDenyResult.deny; + gatewayModified = true; + console.log(`[sanitize] Added subagent tool(s) to gateway.tools.deny for ClawX desktop: ${SUBAGENT_TOOL_DENY_ENTRIES.join(', ')}`); + } else if (!Array.isArray(gatewayTools.deny) || gatewayTools.deny.length !== gatewaySubagentDenyResult.deny.length) { + gatewayTools.deny = gatewaySubagentDenyResult.deny; + gatewayModified = true; + } if (gatewayModified) { gateway.tools = gatewayTools; config.gateway = gateway; diff --git a/tests/unit/openclaw-auth.test.ts b/tests/unit/openclaw-auth.test.ts index d20aa3f8..484eaffc 100644 --- a/tests/unit/openclaw-auth.test.ts +++ b/tests/unit/openclaw-auth.test.ts @@ -45,6 +45,17 @@ vi.mock('@electron/utils/paths', async () => { }; }); +// ClawX desktop denies these tools in tools.deny / gateway.tools.deny during +// sanitizeOpenClawConfig(): skill_workshop (ClawX keeps direct skill-creator +// authoring) plus the subagent workflow (sessions_spawn / sessions_yield / +// subagents) so the model cannot spawn child agent sessions. +const CLAWX_DESKTOP_TOOL_DENY = [ + 'skill_workshop', + 'sessions_spawn', + 'sessions_yield', + 'subagents', +]; + async function writeOpenClawJson(config: unknown): Promise { const openclawDir = join(testHome, '.openclaw'); await mkdir(openclawDir, { recursive: true }); @@ -377,10 +388,10 @@ describe('sanitizeOpenClawConfig', () => { // Fresh install should get tools settings enforced const tools = result.tools as Record; expect(tools.profile).toBe('full'); - expect(tools.deny).toEqual(['skill_workshop']); + expect(tools.deny).toEqual(CLAWX_DESKTOP_TOOL_DENY); const gateway = result.gateway as Record; const gatewayTools = gateway.tools as Record; - expect(gatewayTools.deny).toEqual(['skill_workshop']); + expect(gatewayTools.deny).toEqual(CLAWX_DESKTOP_TOOL_DENY); const skills = result.skills as Record; const workshop = skills.workshop as Record; const autonomous = workshop.autonomous as Record; @@ -415,9 +426,9 @@ describe('sanitizeOpenClawConfig', () => { // tools settings should now be enforced const tools = result.tools as Record; expect(tools.profile).toBe('full'); - expect(tools.deny).toEqual(['skill_workshop']); + expect(tools.deny).toEqual(CLAWX_DESKTOP_TOOL_DENY); const gateway = result.gateway as Record; - expect((gateway.tools as Record).deny).toEqual(['skill_workshop']); + expect((gateway.tools as Record).deny).toEqual(CLAWX_DESKTOP_TOOL_DENY); const skills = result.skills as Record; expect(((skills.workshop as Record).autonomous as Record).enabled).toBe(false); expect((skills.entries as Record>)['skill-creator'].enabled).toBe(true); @@ -437,9 +448,9 @@ describe('sanitizeOpenClawConfig', () => { const result = await readOpenClawJson(); const tools = result.tools as Record; - expect(tools.deny).toEqual(['browser', 'skill_workshop']); + expect(tools.deny).toEqual(['browser', ...CLAWX_DESKTOP_TOOL_DENY]); const gateway = result.gateway as Record; - expect((gateway.tools as Record).deny).toEqual(['skill_workshop']); + expect((gateway.tools as Record).deny).toEqual(CLAWX_DESKTOP_TOOL_DENY); }); it('migrates legacy tools.web.search.kimi into moonshot plugin config', async () => { diff --git a/tests/unit/sanitize-config.test.ts b/tests/unit/sanitize-config.test.ts index 305fc6f4..c18c9306 100644 --- a/tests/unit/sanitize-config.test.ts +++ b/tests/unit/sanitize-config.test.ts @@ -51,6 +51,12 @@ function withClawXToolDefaults>(config: T): T tools.sessions = sessions; tools.exec = exec; tools.deny = deny.includes('skill_workshop') ? deny : [...deny, 'skill_workshop']; + // Mirror production: also deny the subagent workflow tools. + for (const entry of ['sessions_spawn', 'sessions_yield', 'subagents']) { + if (!(tools.deny as string[]).includes(entry)) { + (tools.deny as string[]).push(entry); + } + } const gateway = (config.gateway && typeof config.gateway === 'object' && !Array.isArray(config.gateway)) ? { ...(config.gateway as Record) } @@ -62,6 +68,12 @@ function withClawXToolDefaults>(config: T): T ? (gatewayTools.deny as unknown[]).filter((value): value is string => typeof value === 'string') : []; gatewayTools.deny = gatewayDeny.includes('skill_workshop') ? gatewayDeny : [...gatewayDeny, 'skill_workshop']; + // Mirror production: also deny the subagent workflow tools (gateway side). + for (const entry of ['sessions_spawn', 'sessions_yield', 'subagents']) { + if (!(gatewayTools.deny as string[]).includes(entry)) { + (gatewayTools.deny as string[]).push(entry); + } + } gateway.tools = gatewayTools; const skills = (config.skills && typeof config.skills === 'object' && !Array.isArray(config.skills)) @@ -414,6 +426,27 @@ async function sanitizeConfig( toolsModified = true; } + // Mirror production: deny the subagent workflow tools (sessions_spawn / + // sessions_yield / subagents) so the model cannot spawn child agent sessions. + const subagentEntries = ['sessions_spawn', 'sessions_yield', 'subagents']; + let subagentDeny = Array.isArray(toolsConfig.deny) + ? toolsConfig.deny.filter((value): value is string => typeof value === 'string') + : []; + let subagentDenyChanged = false; + for (const entry of subagentEntries) { + if (!subagentDeny.includes(entry)) { + subagentDeny = [...subagentDeny, entry]; + subagentDenyChanged = true; + } + } + if (subagentDenyChanged) { + toolsConfig.deny = subagentDeny; + toolsModified = true; + } else if (!Array.isArray(toolsConfig.deny) || toolsConfig.deny.length !== subagentDeny.length) { + toolsConfig.deny = subagentDeny; + toolsModified = true; + } + const execConfig = (toolsConfig.exec as Record | undefined) || {}; if (execConfig.security !== 'full' || execConfig.ask !== 'off') { execConfig.security = 'full'; @@ -459,6 +492,29 @@ async function sanitizeConfig( modified = true; } + // Mirror production: deny the subagent workflow tools on the gateway side too. + let gatewaySubagentDeny = Array.isArray(gatewayTools.deny) + ? gatewayTools.deny.filter((value): value is string => typeof value === 'string') + : []; + let gatewaySubagentChanged = false; + for (const entry of ['sessions_spawn', 'sessions_yield', 'subagents']) { + if (!gatewaySubagentDeny.includes(entry)) { + gatewaySubagentDeny = [...gatewaySubagentDeny, entry]; + gatewaySubagentChanged = true; + } + } + if (gatewaySubagentChanged) { + gatewayTools.deny = gatewaySubagentDeny; + gateway.tools = gatewayTools; + config.gateway = gateway; + modified = true; + } else if (!Array.isArray(gatewayTools.deny) || gatewayTools.deny.length !== gatewaySubagentDeny.length) { + gatewayTools.deny = gatewaySubagentDeny; + gateway.tools = gatewayTools; + config.gateway = gateway; + modified = true; + } + let skillsConfig = ( config.skills && typeof config.skills === 'object' && !Array.isArray(config.skills) ? { ...(config.skills as Record) }