From d4f11d3005a56abc709ebc8e715972593ebed96e Mon Sep 17 00:00:00 2001 From: Agustin Rivera <31522568+eleqtrizit@users.noreply.github.com> Date: Wed, 17 Jun 2026 08:24:25 -0700 Subject: [PATCH] fix(feishu): enforce account tool family gates (#93363) * fix(feishu): enforce account tool family gates * fix(feishu): cover perm contextual account gate --- .../feishu/src/docx.account-selection.test.ts | 70 ++++++++++++++++ extensions/feishu/src/docx.ts | 23 +++++- extensions/feishu/src/drive.ts | 1 + extensions/feishu/src/perm.ts | 1 + .../feishu/src/tool-account-routing.test.ts | 79 +++++++++++++++++++ extensions/feishu/src/tool-account.ts | 32 +++++++- extensions/feishu/src/wiki.ts | 1 + 7 files changed, 202 insertions(+), 5 deletions(-) diff --git a/extensions/feishu/src/docx.account-selection.test.ts b/extensions/feishu/src/docx.account-selection.test.ts index 5b4aac0a4d11..bf390fbf5ade 100644 --- a/extensions/feishu/src/docx.account-selection.test.ts +++ b/extensions/feishu/src/docx.account-selection.test.ts @@ -5,6 +5,14 @@ import { createToolFactoryHarness } from "./tool-factory-test-harness.js"; const createFeishuClientMock = vi.fn((creds: { appId?: string } | undefined) => ({ __appId: creds?.appId, + application: { + scope: { + list: vi.fn(async () => ({ + code: 0, + data: { scopes: [] }, + })), + }, + }, })); function feishuClientAppId(callIndex: number): string | undefined { @@ -61,6 +69,28 @@ describe("feishu_doc account selection", () => { } as OpenClawPluginApi["config"]; } + function createMixedToolConfig(): OpenClawPluginApi["config"] { + return { + channels: { + feishu: { + enabled: true, + accounts: { + a: { + appId: "app-a", + appSecret: "sec-a", // pragma: allowlist secret + tools: { doc: false, scopes: false }, + }, + b: { + appId: "app-b", + appSecret: "sec-b", // pragma: allowlist secret + tools: { doc: true, scopes: true }, + }, + }, + }, + }, + } as OpenClawPluginApi["config"]; + } + test("uses agentAccountId context when params omit accountId", async () => { const cfg = createDocEnabledConfig(); @@ -93,4 +123,44 @@ describe("feishu_doc account selection", () => { expect(feishuClientAppId(-1)).toBe("app-a"); }); + + test("rejects a disabled contextual account when another account enables docs", async () => { + const { api, resolveTool } = createToolFactoryHarness(createMixedToolConfig()); + registerFeishuDocTools(api); + + const docTool = resolveTool("feishu_doc", { agentAccountId: "a" }); + const result = await docTool.execute("call-disabled", { + action: "list_blocks", + doc_token: "d", + }); + + expect(createFeishuClientMock).not.toHaveBeenCalled(); + expect(result.details.error).toBe('Feishu Doc tools are disabled for account "a"'); + }); + + test("rejects an explicit disabled account override for docs", async () => { + const { api, resolveTool } = createToolFactoryHarness(createMixedToolConfig()); + registerFeishuDocTools(api); + + const docTool = resolveTool("feishu_doc", { agentAccountId: "b" }); + const result = await docTool.execute("call-disabled", { + action: "list_blocks", + doc_token: "d", + accountId: "a", + }); + + expect(createFeishuClientMock).not.toHaveBeenCalled(); + expect(result.details.error).toBe('Feishu Doc tools are disabled for account "a"'); + }); + + test("rejects a disabled contextual account when another account enables app scopes", async () => { + const { api, resolveTool } = createToolFactoryHarness(createMixedToolConfig()); + registerFeishuDocTools(api); + + const scopesTool = resolveTool("feishu_app_scopes", { agentAccountId: "a" }); + const result = await scopesTool.execute("call-disabled", {}); + + expect(createFeishuClientMock).not.toHaveBeenCalled(); + expect(result.details.error).toBe('Feishu App Scopes tools are disabled for account "a"'); + }); }); diff --git a/extensions/feishu/src/docx.ts b/extensions/feishu/src/docx.ts index 3ac3104143f8..54aec4a1ec53 100644 --- a/extensions/feishu/src/docx.ts +++ b/extensions/feishu/src/docx.ts @@ -1384,14 +1384,23 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) { type FeishuDocExecuteParams = FeishuDocParams & { accountId?: string }; const getClient = (params: { accountId?: string } | undefined, defaultAccountId?: string) => - createFeishuToolClient({ api, executeParams: params, defaultAccountId }); + createFeishuToolClient({ + api, + executeParams: params, + defaultAccountId, + requiredTool: { family: "doc", label: "Doc" }, + }); const getMediaMaxBytes = ( params: { accountId?: string } | undefined, defaultAccountId?: string, ) => - (resolveFeishuToolAccount({ api, executeParams: params, defaultAccountId }).config - ?.mediaMaxMb ?? 30) * + (resolveFeishuToolAccount({ + api, + executeParams: params, + defaultAccountId, + requiredTool: { family: "doc", label: "Doc" }, + }).config?.mediaMaxMb ?? 30) * 1024 * 1024; @@ -1584,7 +1593,13 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) { parameters: Type.Object({}), async execute() { try { - const result = await listAppScopes(getClient(undefined, ctx.agentAccountId)); + const result = await listAppScopes( + createFeishuToolClient({ + api, + defaultAccountId: ctx.agentAccountId, + requiredTool: { family: "scopes", label: "App Scopes" }, + }), + ); return json(result); } catch (err) { return json({ error: formatErrorMessage(err) }); diff --git a/extensions/feishu/src/drive.ts b/extensions/feishu/src/drive.ts index 4c5ae0b4cfe2..40ed6f2884ca 100644 --- a/extensions/feishu/src/drive.ts +++ b/extensions/feishu/src/drive.ts @@ -765,6 +765,7 @@ export function registerFeishuDriveTools(api: OpenClawPluginApi) { api, executeParams: p, defaultAccountId, + requiredTool: { family: "drive", label: "Drive" }, }); switch (p.action) { case "list": diff --git a/extensions/feishu/src/perm.ts b/extensions/feishu/src/perm.ts index 53698c8790b3..50b27f833c17 100644 --- a/extensions/feishu/src/perm.ts +++ b/extensions/feishu/src/perm.ts @@ -145,6 +145,7 @@ export function registerFeishuPermTools(api: OpenClawPluginApi) { api, executeParams: p, defaultAccountId, + requiredTool: { family: "perm", label: "Perm" }, }); switch (p.action) { case "list": diff --git a/extensions/feishu/src/tool-account-routing.test.ts b/extensions/feishu/src/tool-account-routing.test.ts index dcbf48417908..af0d3e196f82 100644 --- a/extensions/feishu/src/tool-account-routing.test.ts +++ b/extensions/feishu/src/tool-account-routing.test.ts @@ -119,6 +119,21 @@ describe("feishu tool account routing", () => { expect(lastClientAppId()).toBe("app-b"); }); + test("wiki tool implicit fallback selects an account with wiki enabled", async () => { + const { api, resolveTool } = createToolFactoryHarness( + createConfig({ + toolsA: { drive: true, wiki: false }, + toolsB: { wiki: true }, + }), + ); + registerFeishuWikiTools(api); + + const tool = resolveTool("feishu_wiki"); + await tool.execute("call", { action: "search" }); + + expect(lastClientAppId()).toBe("app-b"); + }); + test("wiki tool prefers the active contextual account over configured defaultAccount", async () => { const { api, resolveTool } = createToolFactoryHarness( createConfig({ @@ -190,6 +205,22 @@ describe("feishu tool account routing", () => { expect(lastClientAppId()).toBe("app-b"); }); + test("drive tool rejects a disabled contextual account when another account enables it", async () => { + const { api, resolveTool } = createToolFactoryHarness( + createConfig({ + toolsA: { drive: false }, + toolsB: { drive: true }, + }), + ); + registerFeishuDriveTools(api); + + const tool = resolveTool("feishu_drive", { agentAccountId: "a" }); + const result = await tool.execute("call", { action: "unknown_action" }); + + expect(createFeishuClientMock).not.toHaveBeenCalled(); + expect(result.details.error).toBe('Feishu Drive tools are disabled for account "a"'); + }); + test("perm tool registers when only second account enables it and routes to agentAccountId", async () => { const { api, resolveTool } = createToolFactoryHarness( createConfig({ @@ -205,6 +236,38 @@ describe("feishu tool account routing", () => { expect(lastClientAppId()).toBe("app-b"); }); + test("perm tool rejects a disabled contextual account when another account enables it", async () => { + const { api, resolveTool } = createToolFactoryHarness( + createConfig({ + toolsA: { perm: false }, + toolsB: { perm: true }, + }), + ); + registerFeishuPermTools(api); + + const tool = resolveTool("feishu_perm", { agentAccountId: "a" }); + const result = await tool.execute("call", { action: "unknown_action" }); + + expect(createFeishuClientMock).not.toHaveBeenCalled(); + expect(result.details.error).toBe('Feishu Perm tools are disabled for account "a"'); + }); + + test("perm tool rejects an explicit disabled account override", async () => { + const { api, resolveTool } = createToolFactoryHarness( + createConfig({ + toolsA: { perm: false }, + toolsB: { perm: true }, + }), + ); + registerFeishuPermTools(api); + + const tool = resolveTool("feishu_perm", { agentAccountId: "b" }); + const result = await tool.execute("call", { action: "unknown_action", accountId: "a" }); + + expect(createFeishuClientMock).not.toHaveBeenCalled(); + expect(result.details.error).toBe('Feishu Perm tools are disabled for account "a"'); + }); + test("bitable tool registers when only second account enables it and routes to agentAccountId", async () => { const { api, resolveTool } = createToolFactoryHarness( createConfig({ @@ -386,6 +449,22 @@ describe("feishu tool account routing", () => { expect(lastClientAppId()).toBe("app-a"); }); + test("wiki tool rejects an explicit disabled account override", async () => { + const { api, resolveTool } = createToolFactoryHarness( + createConfig({ + toolsA: { wiki: false }, + toolsB: { wiki: true }, + }), + ); + registerFeishuWikiTools(api); + + const tool = resolveTool("feishu_wiki", { agentAccountId: "b" }); + const result = await tool.execute("call", { action: "search", accountId: "a" }); + + expect(createFeishuClientMock).not.toHaveBeenCalled(); + expect(result.details.error).toBe('Feishu Wiki tools are disabled for account "a"'); + }); + test("does not silently fall back when the contextual account is real but uses non-env SecretRefs", async () => { const { api, resolveTool } = createToolFactoryHarness({ channels: { diff --git a/extensions/feishu/src/tool-account.ts b/extensions/feishu/src/tool-account.ts index e6d35aff8ba0..92d1e8f64263 100644 --- a/extensions/feishu/src/tool-account.ts +++ b/extensions/feishu/src/tool-account.ts @@ -12,11 +12,17 @@ import { resolveToolsConfig } from "./tools-config.js"; import type { FeishuToolsConfig, ResolvedFeishuAccount } from "./types.js"; type AccountAwareParams = { accountId?: string }; +type FeishuToolFamily = keyof FeishuToolsConfig; +type FeishuToolRequirement = { + family: FeishuToolFamily; + label: string; +}; function resolveImplicitToolAccountId(params: { api: Pick; executeParams?: AccountAwareParams; defaultAccountId?: string; + requiredTool?: FeishuToolRequirement; }): string | undefined { const explicitAccountId = normalizeOptionalString(params.executeParams?.accountId); if (explicitAccountId) { @@ -45,6 +51,19 @@ function resolveImplicitToolAccountId(params: { return configuredDefaultAccountId; } + if (params.requiredTool && params.api.config) { + for (const accountId of listFeishuAccountIds(params.api.config)) { + const account = resolveFeishuAccount({ cfg: params.api.config, accountId }); + if ( + account.enabled && + account.configured && + resolveToolsConfig(account.config.tools)[params.requiredTool.family] + ) { + return accountId; + } + } + } + return undefined; } @@ -52,20 +71,31 @@ export function resolveFeishuToolAccount(params: { api: Pick; executeParams?: AccountAwareParams; defaultAccountId?: string; + requiredTool?: FeishuToolRequirement; }): ResolvedFeishuAccount { if (!params.api.config) { throw new Error("Feishu config unavailable"); } - return resolveFeishuRuntimeAccount({ + const account = resolveFeishuRuntimeAccount({ cfg: params.api.config, accountId: resolveImplicitToolAccountId(params), }); + if ( + params.requiredTool && + !resolveToolsConfig(account.config.tools)[params.requiredTool.family] + ) { + throw new Error( + `Feishu ${params.requiredTool.label} tools are disabled for account "${account.accountId}"`, + ); + } + return account; } export function createFeishuToolClient(params: { api: Pick; executeParams?: AccountAwareParams; defaultAccountId?: string; + requiredTool?: FeishuToolRequirement; }): Lark.Client { return createFeishuClient(resolveFeishuToolAccount(params)); } diff --git a/extensions/feishu/src/wiki.ts b/extensions/feishu/src/wiki.ts index 6acd635aea13..03f0d3d055c7 100644 --- a/extensions/feishu/src/wiki.ts +++ b/extensions/feishu/src/wiki.ts @@ -238,6 +238,7 @@ export function registerFeishuWikiTools(api: OpenClawPluginApi) { api, executeParams: p, defaultAccountId, + requiredTool: { family: "wiki", label: "Wiki" }, }); switch (p.action) { case "spaces":