diff --git a/src/agents/model-visibility-policy.test.ts b/src/agents/model-visibility-policy.test.ts index 1cb291b5ccea..1262b4d1c171 100644 --- a/src/agents/model-visibility-policy.test.ts +++ b/src/agents/model-visibility-policy.test.ts @@ -213,6 +213,27 @@ describe("explicit model visibility policy", () => { ]); }); + it("keeps nested prefix wildcards scoped when segments carry boundary whitespace", () => { + const policy = createPolicy({ + agents: { + defaults: { + modelPolicy: { allow: [" clawrouter / anthropic / * ", " openai / gpt-5.5 "] }, + }, + }, + }); + + // The padded nested wildcard must keep its namespace rather than widening to + // every clawrouter model. + expect(policy.allowsKey("clawrouter/anthropic/claude-haiku-4-5")).toBe(true); + expect(policy.allowsKey("clawrouter/google/gemini-3.5-flash")).toBe(false); + expect(policy.allowsKey("openai/gpt-5.5")).toBe(true); + expect(policy.allowsKey("openai/gpt-5.6-sol")).toBe(false); + expect(policy.allowedCatalog.map((entry) => `${entry.provider}/${entry.id}`)).toEqual([ + "clawrouter/anthropic/claude-haiku-4-5", + "openai/gpt-5.5", + ]); + }); + it("keeps top-level provider wildcard behavior for nested model ids", () => { const policy = createPolicy({ agents: { diff --git a/src/config/config.model-ref-validation.test.ts b/src/config/config.model-ref-validation.test.ts index 4eab829f777d..0f5e36d6a25a 100644 --- a/src/config/config.model-ref-validation.test.ts +++ b/src/config/config.model-ref-validation.test.ts @@ -174,4 +174,64 @@ describe("config model reference validation", () => { expect(res.config.models?.providers?.myproxy?.models?.[0]?.id).toBe("vendor/modern-model"); } }); + it.each([ + [ + "default policy", + { + agents: { + defaults: { + modelPolicy: { + allow: [ + " openai / gpt-5.5 ", + "clawrouter/ anthropic/claude-haiku-4-5", + " openai / * ", + " clawrouter / anthropic / * ", + ], + }, + }, + }, + }, + ], + [ + "per-agent policy", + { + agents: { + list: [ + { + id: "worker", + modelPolicy: { + allow: [" openai / gpt-5.5 ", " openai / * ", " openai / ns / * "], + }, + }, + ], + }, + }, + ], + ])("accepts separator padding in the %s", (_label, config) => { + const res = validateConfigObjectWithPlugins(config, { pluginValidation: "skip" }); + + expect(res.ok).toBe(true); + }); + + it.each(["clawrouter/anthropic /claude-haiku-4-5", "openai/gpt 5.5", "openai//gpt-5.5"])( + "still rejects malformed model policy ref %j", + (ref) => { + const res = validateConfigObjectWithPlugins( + { + agents: { + defaults: { + modelPolicy: { allow: [ref] }, + }, + }, + }, + { pluginValidation: "skip" }, + ); + + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.issues[0]?.path).toBe("agents.defaults.modelPolicy.allow.0"); + expect(res.issues[0]?.message).toContain("invalid model policy ref"); + } + }, + ); }); diff --git a/src/config/model-policy-ref.ts b/src/config/model-policy-ref.ts index bd24c952731a..9f8d1b85e3b9 100644 --- a/src/config/model-policy-ref.ts +++ b/src/config/model-policy-ref.ts @@ -39,10 +39,9 @@ type ModelPolicyWildcardRef = { /** Parse and canonicalize a segment-boundary model-policy prefix wildcard. */ export function parseModelPolicyWildcardRef(raw: string): ModelPolicyWildcardRef | null { const trimmed = raw.trim(); - if (!trimmed.endsWith("/*")) { - return null; - } - const segments = trimmed.split("/"); + // Wildcard keys match on segment boundaries, so normalize boundary padding + // before building the canonical key used by policy matching. + const segments = trimmed.split("/").map((segment) => segment.trim()); if ( segments.at(-1) !== "*" || !hasValidSegments(segments.slice(0, -1), { @@ -63,9 +62,13 @@ export function parseModelPolicyWildcardRef(raw: string): ModelPolicyWildcardRef /** True for a syntactically valid exact provider/model policy reference. */ export function isValidExactModelPolicyRef(raw: string): boolean { - const trimmed = raw.trim(); - const parsed = parseModelCatalogRef(trimmed); - return Boolean(parsed && hasValidSegments(trimmed.split("/"), { min: 2 })); + const parsed = parseModelCatalogRef(raw); + return Boolean( + parsed && + hasValidSegments([parsed.provider, ...parsed.modelId.split("/")], { + min: 2, + }), + ); } /** True for a supported bare selector whose target is resolved from config. */