fix(gateway): fail closed on invalid subagent allowlists

This commit is contained in:
Josh Lehman
2026-03-16 22:49:14 -07:00
parent e2ece61cdd
commit 1b223b2fa4
4 changed files with 80 additions and 4 deletions

View File

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

View File

@@ -52,6 +52,7 @@ export function setFallbackGatewayContext(ctx: GatewayRequestContext): void {
type PluginSubagentOverridePolicy = {
allowModelOverride: boolean;
allowAnyModel: boolean;
hasConfiguredAllowlist: boolean;
allowedModels: Set<string>;
};
@@ -104,6 +105,7 @@ function setPluginSubagentOverridePolicies(cfg: ReturnType<typeof loadConfig>):
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<string>();
let allowAnyModel = false;
@@ -118,12 +120,18 @@ function setPluginSubagentOverridePolicies(cfg: ReturnType<typeof loadConfig>):
}
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);

View File

@@ -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", () => {

View File

@@ -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 }
: {}),