feat(auth): deny subagent tools for ClawX desktop (#1147)

This commit is contained in:
Felix
2026-07-07 15:44:45 +08:00
committed by GitHub
parent e09dd289db
commit a0509b9b54
3 changed files with 127 additions and 6 deletions
+54
View File
@@ -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<void> {
return withConfigLock(async () => {
// Skip sanitization if the config file does not exist yet.
@@ -3027,6 +3050,22 @@ export async function sanitizeOpenClawConfig(): Promise<void> {
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<void> {
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;
+17 -6
View File
@@ -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<void> {
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<string, unknown>;
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<string, unknown>;
const gatewayTools = gateway.tools as Record<string, unknown>;
expect(gatewayTools.deny).toEqual(['skill_workshop']);
expect(gatewayTools.deny).toEqual(CLAWX_DESKTOP_TOOL_DENY);
const skills = result.skills as Record<string, unknown>;
const workshop = skills.workshop as Record<string, unknown>;
const autonomous = workshop.autonomous as Record<string, unknown>;
@@ -415,9 +426,9 @@ describe('sanitizeOpenClawConfig', () => {
// tools settings should now be enforced
const tools = result.tools as Record<string, unknown>;
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<string, unknown>;
expect((gateway.tools as Record<string, unknown>).deny).toEqual(['skill_workshop']);
expect((gateway.tools as Record<string, unknown>).deny).toEqual(CLAWX_DESKTOP_TOOL_DENY);
const skills = result.skills as Record<string, unknown>;
expect(((skills.workshop as Record<string, unknown>).autonomous as Record<string, unknown>).enabled).toBe(false);
expect((skills.entries as Record<string, Record<string, unknown>>)['skill-creator'].enabled).toBe(true);
@@ -437,9 +448,9 @@ describe('sanitizeOpenClawConfig', () => {
const result = await readOpenClawJson();
const tools = result.tools as Record<string, unknown>;
expect(tools.deny).toEqual(['browser', 'skill_workshop']);
expect(tools.deny).toEqual(['browser', ...CLAWX_DESKTOP_TOOL_DENY]);
const gateway = result.gateway as Record<string, unknown>;
expect((gateway.tools as Record<string, unknown>).deny).toEqual(['skill_workshop']);
expect((gateway.tools as Record<string, unknown>).deny).toEqual(CLAWX_DESKTOP_TOOL_DENY);
});
it('migrates legacy tools.web.search.kimi into moonshot plugin config', async () => {
+56
View File
@@ -51,6 +51,12 @@ function withClawXToolDefaults<T extends Record<string, unknown>>(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<string, unknown>) }
@@ -62,6 +68,12 @@ function withClawXToolDefaults<T extends Record<string, unknown>>(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<string, unknown> | 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<string, unknown>) }