Skip to content

Commit d4f11d3

Browse files
authored
fix(feishu): enforce account tool family gates (#93363)
* fix(feishu): enforce account tool family gates * fix(feishu): cover perm contextual account gate
1 parent 62563c2 commit d4f11d3

7 files changed

Lines changed: 202 additions & 5 deletions

File tree

extensions/feishu/src/docx.account-selection.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@ import { createToolFactoryHarness } from "./tool-factory-test-harness.js";
55

66
const createFeishuClientMock = vi.fn((creds: { appId?: string } | undefined) => ({
77
__appId: creds?.appId,
8+
application: {
9+
scope: {
10+
list: vi.fn(async () => ({
11+
code: 0,
12+
data: { scopes: [] },
13+
})),
14+
},
15+
},
816
}));
917

1018
function feishuClientAppId(callIndex: number): string | undefined {
@@ -61,6 +69,28 @@ describe("feishu_doc account selection", () => {
6169
} as OpenClawPluginApi["config"];
6270
}
6371

72+
function createMixedToolConfig(): OpenClawPluginApi["config"] {
73+
return {
74+
channels: {
75+
feishu: {
76+
enabled: true,
77+
accounts: {
78+
a: {
79+
appId: "app-a",
80+
appSecret: "sec-a", // pragma: allowlist secret
81+
tools: { doc: false, scopes: false },
82+
},
83+
b: {
84+
appId: "app-b",
85+
appSecret: "sec-b", // pragma: allowlist secret
86+
tools: { doc: true, scopes: true },
87+
},
88+
},
89+
},
90+
},
91+
} as OpenClawPluginApi["config"];
92+
}
93+
6494
test("uses agentAccountId context when params omit accountId", async () => {
6595
const cfg = createDocEnabledConfig();
6696

@@ -93,4 +123,44 @@ describe("feishu_doc account selection", () => {
93123

94124
expect(feishuClientAppId(-1)).toBe("app-a");
95125
});
126+
127+
test("rejects a disabled contextual account when another account enables docs", async () => {
128+
const { api, resolveTool } = createToolFactoryHarness(createMixedToolConfig());
129+
registerFeishuDocTools(api);
130+
131+
const docTool = resolveTool("feishu_doc", { agentAccountId: "a" });
132+
const result = await docTool.execute("call-disabled", {
133+
action: "list_blocks",
134+
doc_token: "d",
135+
});
136+
137+
expect(createFeishuClientMock).not.toHaveBeenCalled();
138+
expect(result.details.error).toBe('Feishu Doc tools are disabled for account "a"');
139+
});
140+
141+
test("rejects an explicit disabled account override for docs", async () => {
142+
const { api, resolveTool } = createToolFactoryHarness(createMixedToolConfig());
143+
registerFeishuDocTools(api);
144+
145+
const docTool = resolveTool("feishu_doc", { agentAccountId: "b" });
146+
const result = await docTool.execute("call-disabled", {
147+
action: "list_blocks",
148+
doc_token: "d",
149+
accountId: "a",
150+
});
151+
152+
expect(createFeishuClientMock).not.toHaveBeenCalled();
153+
expect(result.details.error).toBe('Feishu Doc tools are disabled for account "a"');
154+
});
155+
156+
test("rejects a disabled contextual account when another account enables app scopes", async () => {
157+
const { api, resolveTool } = createToolFactoryHarness(createMixedToolConfig());
158+
registerFeishuDocTools(api);
159+
160+
const scopesTool = resolveTool("feishu_app_scopes", { agentAccountId: "a" });
161+
const result = await scopesTool.execute("call-disabled", {});
162+
163+
expect(createFeishuClientMock).not.toHaveBeenCalled();
164+
expect(result.details.error).toBe('Feishu App Scopes tools are disabled for account "a"');
165+
});
96166
});

extensions/feishu/src/docx.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1384,14 +1384,23 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) {
13841384
type FeishuDocExecuteParams = FeishuDocParams & { accountId?: string };
13851385

13861386
const getClient = (params: { accountId?: string } | undefined, defaultAccountId?: string) =>
1387-
createFeishuToolClient({ api, executeParams: params, defaultAccountId });
1387+
createFeishuToolClient({
1388+
api,
1389+
executeParams: params,
1390+
defaultAccountId,
1391+
requiredTool: { family: "doc", label: "Doc" },
1392+
});
13881393

13891394
const getMediaMaxBytes = (
13901395
params: { accountId?: string } | undefined,
13911396
defaultAccountId?: string,
13921397
) =>
1393-
(resolveFeishuToolAccount({ api, executeParams: params, defaultAccountId }).config
1394-
?.mediaMaxMb ?? 30) *
1398+
(resolveFeishuToolAccount({
1399+
api,
1400+
executeParams: params,
1401+
defaultAccountId,
1402+
requiredTool: { family: "doc", label: "Doc" },
1403+
}).config?.mediaMaxMb ?? 30) *
13951404
1024 *
13961405
1024;
13971406

@@ -1584,7 +1593,13 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) {
15841593
parameters: Type.Object({}),
15851594
async execute() {
15861595
try {
1587-
const result = await listAppScopes(getClient(undefined, ctx.agentAccountId));
1596+
const result = await listAppScopes(
1597+
createFeishuToolClient({
1598+
api,
1599+
defaultAccountId: ctx.agentAccountId,
1600+
requiredTool: { family: "scopes", label: "App Scopes" },
1601+
}),
1602+
);
15881603
return json(result);
15891604
} catch (err) {
15901605
return json({ error: formatErrorMessage(err) });

extensions/feishu/src/drive.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -765,6 +765,7 @@ export function registerFeishuDriveTools(api: OpenClawPluginApi) {
765765
api,
766766
executeParams: p,
767767
defaultAccountId,
768+
requiredTool: { family: "drive", label: "Drive" },
768769
});
769770
switch (p.action) {
770771
case "list":

extensions/feishu/src/perm.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ export function registerFeishuPermTools(api: OpenClawPluginApi) {
145145
api,
146146
executeParams: p,
147147
defaultAccountId,
148+
requiredTool: { family: "perm", label: "Perm" },
148149
});
149150
switch (p.action) {
150151
case "list":

extensions/feishu/src/tool-account-routing.test.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,21 @@ describe("feishu tool account routing", () => {
119119
expect(lastClientAppId()).toBe("app-b");
120120
});
121121

122+
test("wiki tool implicit fallback selects an account with wiki enabled", async () => {
123+
const { api, resolveTool } = createToolFactoryHarness(
124+
createConfig({
125+
toolsA: { drive: true, wiki: false },
126+
toolsB: { wiki: true },
127+
}),
128+
);
129+
registerFeishuWikiTools(api);
130+
131+
const tool = resolveTool("feishu_wiki");
132+
await tool.execute("call", { action: "search" });
133+
134+
expect(lastClientAppId()).toBe("app-b");
135+
});
136+
122137
test("wiki tool prefers the active contextual account over configured defaultAccount", async () => {
123138
const { api, resolveTool } = createToolFactoryHarness(
124139
createConfig({
@@ -190,6 +205,22 @@ describe("feishu tool account routing", () => {
190205
expect(lastClientAppId()).toBe("app-b");
191206
});
192207

208+
test("drive tool rejects a disabled contextual account when another account enables it", async () => {
209+
const { api, resolveTool } = createToolFactoryHarness(
210+
createConfig({
211+
toolsA: { drive: false },
212+
toolsB: { drive: true },
213+
}),
214+
);
215+
registerFeishuDriveTools(api);
216+
217+
const tool = resolveTool("feishu_drive", { agentAccountId: "a" });
218+
const result = await tool.execute("call", { action: "unknown_action" });
219+
220+
expect(createFeishuClientMock).not.toHaveBeenCalled();
221+
expect(result.details.error).toBe('Feishu Drive tools are disabled for account "a"');
222+
});
223+
193224
test("perm tool registers when only second account enables it and routes to agentAccountId", async () => {
194225
const { api, resolveTool } = createToolFactoryHarness(
195226
createConfig({
@@ -205,6 +236,38 @@ describe("feishu tool account routing", () => {
205236
expect(lastClientAppId()).toBe("app-b");
206237
});
207238

239+
test("perm tool rejects a disabled contextual account when another account enables it", async () => {
240+
const { api, resolveTool } = createToolFactoryHarness(
241+
createConfig({
242+
toolsA: { perm: false },
243+
toolsB: { perm: true },
244+
}),
245+
);
246+
registerFeishuPermTools(api);
247+
248+
const tool = resolveTool("feishu_perm", { agentAccountId: "a" });
249+
const result = await tool.execute("call", { action: "unknown_action" });
250+
251+
expect(createFeishuClientMock).not.toHaveBeenCalled();
252+
expect(result.details.error).toBe('Feishu Perm tools are disabled for account "a"');
253+
});
254+
255+
test("perm tool rejects an explicit disabled account override", async () => {
256+
const { api, resolveTool } = createToolFactoryHarness(
257+
createConfig({
258+
toolsA: { perm: false },
259+
toolsB: { perm: true },
260+
}),
261+
);
262+
registerFeishuPermTools(api);
263+
264+
const tool = resolveTool("feishu_perm", { agentAccountId: "b" });
265+
const result = await tool.execute("call", { action: "unknown_action", accountId: "a" });
266+
267+
expect(createFeishuClientMock).not.toHaveBeenCalled();
268+
expect(result.details.error).toBe('Feishu Perm tools are disabled for account "a"');
269+
});
270+
208271
test("bitable tool registers when only second account enables it and routes to agentAccountId", async () => {
209272
const { api, resolveTool } = createToolFactoryHarness(
210273
createConfig({
@@ -386,6 +449,22 @@ describe("feishu tool account routing", () => {
386449
expect(lastClientAppId()).toBe("app-a");
387450
});
388451

452+
test("wiki tool rejects an explicit disabled account override", async () => {
453+
const { api, resolveTool } = createToolFactoryHarness(
454+
createConfig({
455+
toolsA: { wiki: false },
456+
toolsB: { wiki: true },
457+
}),
458+
);
459+
registerFeishuWikiTools(api);
460+
461+
const tool = resolveTool("feishu_wiki", { agentAccountId: "b" });
462+
const result = await tool.execute("call", { action: "search", accountId: "a" });
463+
464+
expect(createFeishuClientMock).not.toHaveBeenCalled();
465+
expect(result.details.error).toBe('Feishu Wiki tools are disabled for account "a"');
466+
});
467+
389468
test("does not silently fall back when the contextual account is real but uses non-env SecretRefs", async () => {
390469
const { api, resolveTool } = createToolFactoryHarness({
391470
channels: {

extensions/feishu/src/tool-account.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,17 @@ import { resolveToolsConfig } from "./tools-config.js";
1212
import type { FeishuToolsConfig, ResolvedFeishuAccount } from "./types.js";
1313

1414
type AccountAwareParams = { accountId?: string };
15+
type FeishuToolFamily = keyof FeishuToolsConfig;
16+
type FeishuToolRequirement = {
17+
family: FeishuToolFamily;
18+
label: string;
19+
};
1520

1621
function resolveImplicitToolAccountId(params: {
1722
api: Pick<OpenClawPluginApi, "config">;
1823
executeParams?: AccountAwareParams;
1924
defaultAccountId?: string;
25+
requiredTool?: FeishuToolRequirement;
2026
}): string | undefined {
2127
const explicitAccountId = normalizeOptionalString(params.executeParams?.accountId);
2228
if (explicitAccountId) {
@@ -45,27 +51,51 @@ function resolveImplicitToolAccountId(params: {
4551
return configuredDefaultAccountId;
4652
}
4753

54+
if (params.requiredTool && params.api.config) {
55+
for (const accountId of listFeishuAccountIds(params.api.config)) {
56+
const account = resolveFeishuAccount({ cfg: params.api.config, accountId });
57+
if (
58+
account.enabled &&
59+
account.configured &&
60+
resolveToolsConfig(account.config.tools)[params.requiredTool.family]
61+
) {
62+
return accountId;
63+
}
64+
}
65+
}
66+
4867
return undefined;
4968
}
5069

5170
export function resolveFeishuToolAccount(params: {
5271
api: Pick<OpenClawPluginApi, "config">;
5372
executeParams?: AccountAwareParams;
5473
defaultAccountId?: string;
74+
requiredTool?: FeishuToolRequirement;
5575
}): ResolvedFeishuAccount {
5676
if (!params.api.config) {
5777
throw new Error("Feishu config unavailable");
5878
}
59-
return resolveFeishuRuntimeAccount({
79+
const account = resolveFeishuRuntimeAccount({
6080
cfg: params.api.config,
6181
accountId: resolveImplicitToolAccountId(params),
6282
});
83+
if (
84+
params.requiredTool &&
85+
!resolveToolsConfig(account.config.tools)[params.requiredTool.family]
86+
) {
87+
throw new Error(
88+
`Feishu ${params.requiredTool.label} tools are disabled for account "${account.accountId}"`,
89+
);
90+
}
91+
return account;
6392
}
6493

6594
export function createFeishuToolClient(params: {
6695
api: Pick<OpenClawPluginApi, "config">;
6796
executeParams?: AccountAwareParams;
6897
defaultAccountId?: string;
98+
requiredTool?: FeishuToolRequirement;
6999
}): Lark.Client {
70100
return createFeishuClient(resolveFeishuToolAccount(params));
71101
}

extensions/feishu/src/wiki.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,7 @@ export function registerFeishuWikiTools(api: OpenClawPluginApi) {
238238
api,
239239
executeParams: p,
240240
defaultAccountId,
241+
requiredTool: { family: "wiki", label: "Wiki" },
241242
});
242243
switch (p.action) {
243244
case "spaces":

0 commit comments

Comments
 (0)