Recipe: LLM model allow-list
The problem
Your org has vetted Claude Sonnet 4, Claude Opus, GPT-4, and Gemini 2.5. You do not want agents to quietly call a new model an engineer discovered on Twitter last night, because (a) the legal team has not seen the privacy terms, (b) billing is not set up, and (c) evals have not been run.
The policy
version: '1'
# Model allow-list for outbound LLM traffic.
#
# The Gateway (and SDK guard() wrapper) extracts the target model_id
# from the request body before forwarding. Only the models named
# below are permitted; everything else hits default_action deny.
settings:
default_action: deny
default_on_missing: deny
default_on_tamper: deny
rules:
- id: allow-claude-family
allow: 'llm:call'
when:
model: 'claude-sonnet-4*'
reason: 'Allowed model: Claude Sonnet 4 family.'
- id: allow-claude-opus
allow: 'llm:call'
when:
model: 'claude-opus-*'
reason: 'Allowed model: Claude Opus family.'
- id: allow-gpt-4
allow: 'llm:call'
when:
model: 'gpt-4*'
reason: 'Allowed model: OpenAI GPT-4 family.'
- id: allow-gemini-2-5
allow: 'llm:call'
when:
model: 'gemini-2.5*'
reason: 'Allowed model: Google Gemini 2.5 family.'
Why it works
Every LLM call (whether it arrives via the Gateway, the Python SDK,
or the Node SDK) carries the target model in its request body. The
policy layer reads that field, matches it against the glob patterns
in each rule's when block, and allows only when a rule matches. If
an engineer fires off sketchy-new-model-v0.1, no rule matches and
default_action: deny fires.
What gets blocked
| Agent call (model) | Extracted action | Decision | reason_code |
|---|---|---|---|
sketchy-new-model-v0.1 | llm:call | deny | NO_RULE_MATCH |
claude-2.1 (deprecated) | llm:call | deny | NO_RULE_MATCH |
gpt-3.5-turbo (not on list) | llm:call | deny | NO_RULE_MATCH |
empty model field | llm:call | deny | NO_RULE_MATCH |
What gets allowed
| Agent call (model) | Extracted action | Decision | reason_code |
|---|---|---|---|
claude-sonnet-4-6 | llm:call | allow | RULE_MATCH |
claude-opus-4-7 | llm:call | allow | RULE_MATCH |
gpt-4-turbo | llm:call | allow | RULE_MATCH |
gemini-2.5-pro | llm:call | allow | RULE_MATCH |
Test it yourself
curl -O https://docs.controlzero.ai/recipes/llm-model-allowlist/policy.yaml
curl -O https://docs.controlzero.ai/recipes/llm-model-allowlist/scenarios.json
controlzero test-policy policy.yaml --scenarios scenarios.json
To see your current traffic mix before you flip this recipe to
deny, run it with default_action: warn first for 48 hours and
watch the audit log for warns on models you did not expect agents to
use.
Caveats
model_idis what arrives in the request body. Some providers accept aliases -- for example,gpt-4o-mini-2024-07-18vsgpt-4o-mini. Write yourwhen:patterns against the IDs that actually flow through your Gateway, not the marketing names. The audit log shows the raw IDs.- Routing layers like OpenRouter or LiteLLM may translate one model
ID to another before the provider sees it. Install the Gateway
upstream of the router so the policy decision runs against the
client-supplied ID, not the translated one. Otherwise the rules see
openrouter/anthropic/claude-sonnet-4-6and fail to matchclaude-sonnet-4*. - This recipe does not cover locally-hosted / offline models. Pair with your Ollama-provider allow-list if that is in scope.