fix(feishu): enforce account tool family gates (#93363)

* fix(feishu): enforce account tool family gates

* fix(feishu): cover perm contextual account gate
This commit is contained in:
Agustin Rivera
2026-06-17 08:24:25 -07:00
committed by GitHub
parent 62563c2cfc
commit d4f11d3005
7 changed files with 202 additions and 5 deletions

View File

@@ -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"');
});
});

View File

@@ -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) });

View File

@@ -765,6 +765,7 @@ export function registerFeishuDriveTools(api: OpenClawPluginApi) {
api,
executeParams: p,
defaultAccountId,
requiredTool: { family: "drive", label: "Drive" },
});
switch (p.action) {
case "list":

View File

@@ -145,6 +145,7 @@ export function registerFeishuPermTools(api: OpenClawPluginApi) {
api,
executeParams: p,
defaultAccountId,
requiredTool: { family: "perm", label: "Perm" },
});
switch (p.action) {
case "list":

View File

@@ -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: {

View File

@@ -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<OpenClawPluginApi, "config">;
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<OpenClawPluginApi, "config">;
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<OpenClawPluginApi, "config">;
executeParams?: AccountAwareParams;
defaultAccountId?: string;
requiredTool?: FeishuToolRequirement;
}): Lark.Client {
return createFeishuClient(resolveFeishuToolAccount(params));
}

View File

@@ -238,6 +238,7 @@ export function registerFeishuWikiTools(api: OpenClawPluginApi) {
api,
executeParams: p,
defaultAccountId,
requiredTool: { family: "wiki", label: "Wiki" },
});
switch (p.action) {
case "spaces":