diff --git a/src/gateway/server-plugins.test.ts b/src/gateway/server-plugins.test.ts index 5c349685eaa..c6cfaae867f 100644 --- a/src/gateway/server-plugins.test.ts +++ b/src/gateway/server-plugins.test.ts @@ -322,6 +322,38 @@ describe("loadGatewayPlugins", () => { expect(getLastDispatchedParams()).not.toHaveProperty("provider"); }); + test("rejects trusted fallback overrides when the configured allowlist normalizes to empty", async () => { + const serverPlugins = await importServerPluginsModule(); + const runtime = await createSubagentRuntime(serverPlugins, { + plugins: { + entries: { + "voice-call": { + subagent: { + allowModelOverride: true, + allowedModels: ["anthropic"], + }, + }, + }, + }, + }); + serverPlugins.setFallbackGatewayContext(createTestContext("fallback-invalid-allowlist")); + const gatewayScopeModule = await import("../plugins/runtime/gateway-request-scope.js"); + + await expect( + gatewayScopeModule.withPluginRuntimePluginIdScope("voice-call", () => + runtime.run({ + sessionKey: "s-invalid-allowlist", + message: "use trusted override", + provider: "anthropic", + model: "claude-haiku-4-5", + deliver: false, + }), + ), + ).rejects.toThrow( + 'plugin "voice-call" configured subagent.allowedModels, but none of the entries normalized to a valid provider/model target.', + ); + }); + test("uses least-privilege synthetic fallback scopes without admin", async () => { const serverPlugins = await importServerPluginsModule(); const runtime = await createSubagentRuntime(serverPlugins); diff --git a/src/gateway/server-plugins.ts b/src/gateway/server-plugins.ts index 68158d1ad50..2ea249b28b4 100644 --- a/src/gateway/server-plugins.ts +++ b/src/gateway/server-plugins.ts @@ -52,6 +52,7 @@ export function setFallbackGatewayContext(ctx: GatewayRequestContext): void { type PluginSubagentOverridePolicy = { allowModelOverride: boolean; allowAnyModel: boolean; + hasConfiguredAllowlist: boolean; allowedModels: Set; }; @@ -104,6 +105,7 @@ function setPluginSubagentOverridePolicies(cfg: ReturnType): const policies: PluginSubagentPolicyState["policies"] = {}; for (const [pluginId, entry] of Object.entries(normalized.entries)) { const allowModelOverride = entry.subagent?.allowModelOverride === true; + const hasConfiguredAllowlist = entry.subagent?.hasAllowedModelsConfig === true; const configuredAllowedModels = entry.subagent?.allowedModels ?? []; const allowedModels = new Set(); let allowAnyModel = false; @@ -118,12 +120,18 @@ function setPluginSubagentOverridePolicies(cfg: ReturnType): } allowedModels.add(normalizedModelRef); } - if (!allowModelOverride && allowedModels.size === 0 && !allowAnyModel) { + if ( + !allowModelOverride && + !hasConfiguredAllowlist && + allowedModels.size === 0 && + !allowAnyModel + ) { continue; } policies[pluginId] = { allowModelOverride, allowAnyModel, + hasConfiguredAllowlist, allowedModels, }; } @@ -149,7 +157,16 @@ function authorizeFallbackModelOverride(params: { reason: `plugin "${pluginId}" is not trusted for fallback provider/model override requests.`, }; } - if (policy.allowAnyModel || policy.allowedModels.size === 0) { + if (policy.allowAnyModel) { + return { allowed: true }; + } + if (policy.hasConfiguredAllowlist && policy.allowedModels.size === 0) { + return { + allowed: false, + reason: `plugin "${pluginId}" configured subagent.allowedModels, but none of the entries normalized to a valid provider/model target.`, + }; + } + if (policy.allowedModels.size === 0) { return { allowed: true }; } const requestedModelRef = resolveRequestedFallbackModelRef(params); diff --git a/src/plugins/config-state.test.ts b/src/plugins/config-state.test.ts index 80817b804bc..915f647950e 100644 --- a/src/plugins/config-state.test.ts +++ b/src/plugins/config-state.test.ts @@ -91,11 +91,30 @@ describe("normalizePluginsConfig", () => { }); expect(result.entries["voice-call"]?.subagent).toEqual({ allowModelOverride: true, + hasAllowedModelsConfig: true, allowedModels: ["anthropic/claude-haiku-4-5", "openai/gpt-4.1-mini"], }); }); - it("drops invalid plugin subagent override policy values", () => { + it("preserves explicit subagent allowlist intent even when all entries are invalid", () => { + const result = normalizePluginsConfig({ + entries: { + "voice-call": { + subagent: { + allowModelOverride: true, + allowedModels: [42, null, "anthropic"], + } as unknown as { allowModelOverride: boolean; allowedModels: string[] }, + }, + }, + }); + expect(result.entries["voice-call"]?.subagent).toEqual({ + allowModelOverride: true, + hasAllowedModelsConfig: true, + allowedModels: ["anthropic"], + }); + }); + + it("keeps explicit invalid subagent allowlist config visible to callers", () => { const result = normalizePluginsConfig({ entries: { "voice-call": { @@ -106,7 +125,9 @@ describe("normalizePluginsConfig", () => { }, }, }); - expect(result.entries["voice-call"]?.subagent).toBeUndefined(); + expect(result.entries["voice-call"]?.subagent).toEqual({ + hasAllowedModelsConfig: true, + }); }); it("normalizes legacy plugin ids to their merged bundled plugin id", () => { diff --git a/src/plugins/config-state.ts b/src/plugins/config-state.ts index 2c97514e9c9..0dde14a8941 100644 --- a/src/plugins/config-state.ts +++ b/src/plugins/config-state.ts @@ -21,6 +21,7 @@ export type NormalizedPluginsConfig = { subagent?: { allowModelOverride?: boolean; allowedModels?: string[]; + hasAllowedModelsConfig?: boolean; }; config?: unknown; } @@ -133,6 +134,9 @@ const normalizePluginEntries = (entries: unknown): NormalizedPluginsConfig["entr ? { allowModelOverride: (subagentRaw as { allowModelOverride?: unknown }) .allowModelOverride, + hasAllowedModelsConfig: Array.isArray( + (subagentRaw as { allowedModels?: unknown }).allowedModels, + ), allowedModels: Array.isArray((subagentRaw as { allowedModels?: unknown }).allowedModels) ? ((subagentRaw as { allowedModels?: unknown }).allowedModels as unknown[]) .map((model) => (typeof model === "string" ? model.trim() : "")) @@ -143,11 +147,13 @@ const normalizePluginEntries = (entries: unknown): NormalizedPluginsConfig["entr const normalizedSubagent = subagent && (typeof subagent.allowModelOverride === "boolean" || + subagent.hasAllowedModelsConfig || (Array.isArray(subagent.allowedModels) && subagent.allowedModels.length > 0)) ? { ...(typeof subagent.allowModelOverride === "boolean" ? { allowModelOverride: subagent.allowModelOverride } : {}), + ...(subagent.hasAllowedModelsConfig ? { hasAllowedModelsConfig: true } : {}), ...(Array.isArray(subagent.allowedModels) && subagent.allowedModels.length > 0 ? { allowedModels: subagent.allowedModels } : {}),