Skip to content

Commit f5a87e9

Browse files
authored
feat: add allowedModels/disallowedModels policy enforcement to api-proxy
Implements issue #4389: allowed and disallowed model glob pattern lists enforced at two points: 1. Alias resolution: policy-violating candidates filtered in _resolveAliasPatterns 2. Inference guard: model-policy-guard.js checked in enforceGuards pipeline Config flows from awf.yml → TypeScript config → env vars AWF_ALLOWED_MODELS / AWF_DISALLOWED_MODELS (JSON arrays) → api-proxy container. Disallowed takes priority over allowed when a model matches both lists.
1 parent 698b5b1 commit f5a87e9

15 files changed

Lines changed: 602 additions & 11 deletions

containers/api-proxy/guards/common-guard-checks.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@
3232
* @param {Function} deps.getRetiredModelBlockState
3333
* @param {Function} deps.buildRetiredModelError
3434
* @param {Function} deps.checkUnknownModelRejection
35+
* @param {Function} deps.getModelPolicyBlockState
36+
* @param {Function} deps.buildModelPolicyError
3537
* @param {string|null} model - Model name extracted from the request, or null
3638
* to skip model-specific guards.
3739
* @returns {Array<object>} Array of guard descriptor objects.
@@ -51,6 +53,8 @@ function buildCommonGuardChecks(deps, model) {
5153
getRetiredModelBlockState,
5254
buildRetiredModelError,
5355
checkUnknownModelRejection,
56+
getModelPolicyBlockState,
57+
buildModelPolicyError,
5458
} = deps;
5559

5660
return [
@@ -134,6 +138,17 @@ function buildCommonGuardChecks(deps, model) {
134138
model: block.model,
135139
}),
136140
},
141+
{
142+
block: getModelPolicyBlockState(model),
143+
isBlocked: block => !!block,
144+
statusCode: 400,
145+
eventName: 'model_policy_violation',
146+
buildError: buildModelPolicyError,
147+
buildLogFields: block => ({
148+
model: block.model,
149+
reason: block.reason,
150+
}),
151+
},
137152
] : []),
138153
];
139154
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
'use strict';
2+
3+
const { globMatch } = require('../model-utils');
4+
5+
/**
6+
* Model policy enforcement for AWF API proxy.
7+
*
8+
* Enforces allowed and disallowed model lists using glob patterns.
9+
*
10+
* Config (JSON arrays of glob patterns):
11+
* AWF_ALLOWED_MODELS — allowlist: only models matching at least one pattern are permitted
12+
* AWF_DISALLOWED_MODELS — denylist: models matching any pattern are rejected
13+
*
14+
* Rules:
15+
* 1. If a model matches any disallowed pattern → rejected.
16+
* 2. If an allowlist is configured and the model matches no allowed pattern → rejected.
17+
* 3. Otherwise → permitted.
18+
*
19+
* Glob syntax: * wildcard, case-insensitive. Examples: "*opus*", "claude-*", "gpt-5*".
20+
*/
21+
22+
/**
23+
* Parse a JSON array of glob pattern strings from a raw env var value.
24+
*
25+
* @param {string|null|undefined} raw
26+
* @returns {string[]|null} Parsed array of pattern strings, or null if absent/invalid/empty.
27+
*/
28+
function parseModelPatterns(raw) {
29+
if (!raw || !raw.trim()) return null;
30+
try {
31+
const parsed = JSON.parse(raw.trim());
32+
if (!Array.isArray(parsed)) return null;
33+
const strings = parsed.filter(p => typeof p === 'string' && p.trim());
34+
return strings.length > 0 ? strings : null;
35+
} catch {
36+
return null;
37+
}
38+
}
39+
40+
const ALLOWED_MODELS = parseModelPatterns(process.env.AWF_ALLOWED_MODELS);
41+
const DISALLOWED_MODELS = parseModelPatterns(process.env.AWF_DISALLOWED_MODELS);
42+
43+
if (ALLOWED_MODELS) {
44+
const { logRequest } = require('../logging');
45+
logRequest('info', 'startup', {
46+
message: 'Model policy: allowed models configured',
47+
allowed_models: ALLOWED_MODELS,
48+
});
49+
}
50+
51+
if (DISALLOWED_MODELS) {
52+
const { logRequest } = require('../logging');
53+
logRequest('info', 'startup', {
54+
message: 'Model policy: disallowed models configured',
55+
disallowed_models: DISALLOWED_MODELS,
56+
});
57+
}
58+
59+
/**
60+
* Check whether a model name is permitted by the current policy.
61+
*
62+
* @param {string} model - The model name to check (case-insensitive)
63+
* @param {string[]|null} [allowedModels] - Override for allowed patterns (defaults to module-level config)
64+
* @param {string[]|null} [disallowedModels] - Override for disallowed patterns (defaults to module-level config)
65+
* @returns {boolean} true when the model is permitted.
66+
*/
67+
function isModelPermittedByPolicy(model, allowedModels = ALLOWED_MODELS, disallowedModels = DISALLOWED_MODELS) {
68+
if (!allowedModels && !disallowedModels) return true;
69+
if (!model) return true;
70+
71+
// Disallowed check first (denylist takes priority over allowlist)
72+
if (disallowedModels && disallowedModels.some(pattern => globMatch(pattern, model))) {
73+
return false;
74+
}
75+
76+
// Allowlist check
77+
if (allowedModels && !allowedModels.some(pattern => globMatch(pattern, model))) {
78+
return false;
79+
}
80+
81+
return true;
82+
}
83+
84+
/**
85+
* Returns a block-state object when the model is rejected by the model policy,
86+
* or null when the model is permitted.
87+
*
88+
* @param {string|null} model - The model name extracted from the request body.
89+
* @returns {{ model: string, reason: 'disallowed'|'not_allowed' } | null}
90+
*/
91+
function getModelPolicyBlockState(model) {
92+
if (!model) return null;
93+
if (!ALLOWED_MODELS && !DISALLOWED_MODELS) return null;
94+
95+
if (DISALLOWED_MODELS && DISALLOWED_MODELS.some(pattern => globMatch(pattern, model))) {
96+
return { model, reason: 'disallowed' };
97+
}
98+
99+
if (ALLOWED_MODELS && !ALLOWED_MODELS.some(pattern => globMatch(pattern, model))) {
100+
return { model, reason: 'not_allowed' };
101+
}
102+
103+
return null;
104+
}
105+
106+
/**
107+
* Builds the structured 400 error response body for a model-policy rejection.
108+
*
109+
* @param {{ model: string, reason: string }} state
110+
* @returns {{ error: object }}
111+
*/
112+
function buildModelPolicyError(state) {
113+
const message = state.reason === 'disallowed'
114+
? `Model '${state.model}' is not permitted: it is explicitly disallowed by the model policy.`
115+
: `Model '${state.model}' is not permitted: it does not match the allowed models policy.`;
116+
return {
117+
error: {
118+
type: 'model_policy_violation',
119+
message,
120+
model: state.model,
121+
reason: state.reason,
122+
},
123+
};
124+
}
125+
126+
module.exports = {
127+
parseModelPatterns,
128+
isModelPermittedByPolicy,
129+
getModelPolicyBlockState,
130+
buildModelPolicyError,
131+
ALLOWED_MODELS,
132+
DISALLOWED_MODELS,
133+
};
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
'use strict';
2+
3+
// Reset module registry between tests so env-var-based constants are re-evaluated.
4+
beforeEach(() => {
5+
jest.resetModules();
6+
delete process.env.AWF_ALLOWED_MODELS;
7+
delete process.env.AWF_DISALLOWED_MODELS;
8+
});
9+
10+
afterEach(() => {
11+
delete process.env.AWF_ALLOWED_MODELS;
12+
delete process.env.AWF_DISALLOWED_MODELS;
13+
});
14+
15+
// Helper: load a fresh instance of the guard with the current env vars.
16+
function loadGuard() {
17+
return require('./model-policy-guard');
18+
}
19+
20+
describe('parseModelPatterns', () => {
21+
it('should return null for an empty string', () => {
22+
const { parseModelPatterns } = loadGuard();
23+
expect(parseModelPatterns('')).toBeNull();
24+
expect(parseModelPatterns(' ')).toBeNull();
25+
expect(parseModelPatterns(null)).toBeNull();
26+
expect(parseModelPatterns(undefined)).toBeNull();
27+
});
28+
29+
it('should return null for invalid JSON', () => {
30+
const { parseModelPatterns } = loadGuard();
31+
expect(parseModelPatterns('not-json')).toBeNull();
32+
expect(parseModelPatterns('{}')).toBeNull();
33+
expect(parseModelPatterns('"string"')).toBeNull();
34+
});
35+
36+
it('should return null for an empty array', () => {
37+
const { parseModelPatterns } = loadGuard();
38+
expect(parseModelPatterns('[]')).toBeNull();
39+
});
40+
41+
it('should return null for arrays with non-string items', () => {
42+
const { parseModelPatterns } = loadGuard();
43+
expect(parseModelPatterns('[1, 2]')).toBeNull();
44+
});
45+
46+
it('should return the parsed array for a valid JSON array of strings', () => {
47+
const { parseModelPatterns } = loadGuard();
48+
expect(parseModelPatterns('["*opus*", "gpt-5*"]')).toEqual(['*opus*', 'gpt-5*']);
49+
});
50+
51+
it('should filter out empty strings', () => {
52+
const { parseModelPatterns } = loadGuard();
53+
expect(parseModelPatterns('["*opus*", "", " "]')).toEqual(['*opus*']);
54+
});
55+
});
56+
57+
describe('isModelPermittedByPolicy', () => {
58+
it('should permit all models when no policy is configured', () => {
59+
const { isModelPermittedByPolicy } = loadGuard();
60+
expect(isModelPermittedByPolicy('claude-opus-4.5', null, null)).toBe(true);
61+
expect(isModelPermittedByPolicy('gpt-5-codex', null, null)).toBe(true);
62+
});
63+
64+
it('should reject models matching a disallowed pattern', () => {
65+
const { isModelPermittedByPolicy } = loadGuard();
66+
const disallowed = ['*opus*'];
67+
expect(isModelPermittedByPolicy('claude-opus-4.5', null, disallowed)).toBe(false);
68+
expect(isModelPermittedByPolicy('claude-sonnet-4.6', null, disallowed)).toBe(true);
69+
});
70+
71+
it('should reject models not matching an allowed pattern', () => {
72+
const { isModelPermittedByPolicy } = loadGuard();
73+
const allowed = ['*sonnet*', '*haiku*'];
74+
expect(isModelPermittedByPolicy('claude-sonnet-4.6', allowed, null)).toBe(true);
75+
expect(isModelPermittedByPolicy('claude-haiku-3-5', allowed, null)).toBe(true);
76+
expect(isModelPermittedByPolicy('claude-opus-4.5', allowed, null)).toBe(false);
77+
expect(isModelPermittedByPolicy('gpt-5-codex', allowed, null)).toBe(false);
78+
});
79+
80+
it('should reject models in disallowed list even if also in allowed list', () => {
81+
const { isModelPermittedByPolicy } = loadGuard();
82+
const allowed = ['*sonnet*'];
83+
const disallowed = ['*sonnet*'];
84+
expect(isModelPermittedByPolicy('claude-sonnet-4.6', allowed, disallowed)).toBe(false);
85+
});
86+
87+
it('should be case-insensitive', () => {
88+
const { isModelPermittedByPolicy } = loadGuard();
89+
expect(isModelPermittedByPolicy('Claude-Sonnet-4.6', ['*sonnet*'], null)).toBe(true);
90+
expect(isModelPermittedByPolicy('CLAUDE-OPUS-4.5', null, ['*opus*'])).toBe(false);
91+
});
92+
93+
it('should permit models when allowed list is empty/null', () => {
94+
const { isModelPermittedByPolicy } = loadGuard();
95+
expect(isModelPermittedByPolicy('any-model', null, null)).toBe(true);
96+
});
97+
});
98+
99+
describe('getModelPolicyBlockState', () => {
100+
describe('no policy configured', () => {
101+
it('should return null for any model', () => {
102+
const guard = loadGuard();
103+
expect(guard.getModelPolicyBlockState('claude-opus-4.5')).toBeNull();
104+
expect(guard.getModelPolicyBlockState('gpt-5-codex')).toBeNull();
105+
expect(guard.getModelPolicyBlockState(null)).toBeNull();
106+
});
107+
});
108+
109+
describe('disallowedModels only', () => {
110+
beforeEach(() => {
111+
process.env.AWF_DISALLOWED_MODELS = JSON.stringify(['*opus*']);
112+
});
113+
114+
it('should return block state for a disallowed model', () => {
115+
const guard = loadGuard();
116+
const result = guard.getModelPolicyBlockState('claude-opus-4.5');
117+
expect(result).not.toBeNull();
118+
expect(result.model).toBe('claude-opus-4.5');
119+
expect(result.reason).toBe('disallowed');
120+
});
121+
122+
it('should return null for a non-disallowed model', () => {
123+
const guard = loadGuard();
124+
expect(guard.getModelPolicyBlockState('claude-sonnet-4.6')).toBeNull();
125+
});
126+
127+
it('should handle multiple disallowed patterns', () => {
128+
process.env.AWF_DISALLOWED_MODELS = JSON.stringify(['*opus*', 'gpt-5*']);
129+
const guard = loadGuard();
130+
expect(guard.getModelPolicyBlockState('claude-opus-4.5')).not.toBeNull();
131+
expect(guard.getModelPolicyBlockState('gpt-5-codex')).not.toBeNull();
132+
expect(guard.getModelPolicyBlockState('claude-sonnet-4.6')).toBeNull();
133+
});
134+
});
135+
136+
describe('allowedModels only', () => {
137+
beforeEach(() => {
138+
process.env.AWF_ALLOWED_MODELS = JSON.stringify(['*sonnet*', '*haiku*']);
139+
});
140+
141+
it('should return null for an allowed model', () => {
142+
const guard = loadGuard();
143+
expect(guard.getModelPolicyBlockState('claude-sonnet-4.6')).toBeNull();
144+
expect(guard.getModelPolicyBlockState('claude-haiku-3-5')).toBeNull();
145+
});
146+
147+
it('should return block state for a model not in the allowed list', () => {
148+
const guard = loadGuard();
149+
const result = guard.getModelPolicyBlockState('claude-opus-4.5');
150+
expect(result).not.toBeNull();
151+
expect(result.model).toBe('claude-opus-4.5');
152+
expect(result.reason).toBe('not_allowed');
153+
});
154+
155+
it('should return null for null model', () => {
156+
const guard = loadGuard();
157+
expect(guard.getModelPolicyBlockState(null)).toBeNull();
158+
});
159+
});
160+
161+
describe('both allowedModels and disallowedModels', () => {
162+
beforeEach(() => {
163+
process.env.AWF_ALLOWED_MODELS = JSON.stringify(['*claude*']);
164+
process.env.AWF_DISALLOWED_MODELS = JSON.stringify(['*opus*']);
165+
});
166+
167+
it('should block a model in the disallowed list even if it matches allowed', () => {
168+
const guard = loadGuard();
169+
const result = guard.getModelPolicyBlockState('claude-opus-4.5');
170+
expect(result).not.toBeNull();
171+
expect(result.reason).toBe('disallowed');
172+
});
173+
174+
it('should block a model not in the allowed list', () => {
175+
const guard = loadGuard();
176+
const result = guard.getModelPolicyBlockState('gpt-5-codex');
177+
expect(result).not.toBeNull();
178+
expect(result.reason).toBe('not_allowed');
179+
});
180+
181+
it('should allow a model that matches allowed and does not match disallowed', () => {
182+
const guard = loadGuard();
183+
expect(guard.getModelPolicyBlockState('claude-sonnet-4.6')).toBeNull();
184+
});
185+
});
186+
});
187+
188+
describe('buildModelPolicyError', () => {
189+
it('should build a disallowed error message', () => {
190+
const { buildModelPolicyError } = loadGuard();
191+
const result = buildModelPolicyError({ model: 'claude-opus-4.5', reason: 'disallowed' });
192+
expect(result.error.type).toBe('model_policy_violation');
193+
expect(result.error.model).toBe('claude-opus-4.5');
194+
expect(result.error.reason).toBe('disallowed');
195+
expect(result.error.message).toContain('explicitly disallowed');
196+
});
197+
198+
it('should build a not_allowed error message', () => {
199+
const { buildModelPolicyError } = loadGuard();
200+
const result = buildModelPolicyError({ model: 'gpt-5-codex', reason: 'not_allowed' });
201+
expect(result.error.type).toBe('model_policy_violation');
202+
expect(result.error.model).toBe('gpt-5-codex');
203+
expect(result.error.reason).toBe('not_allowed');
204+
expect(result.error.message).toContain('allowed models policy');
205+
});
206+
});

containers/api-proxy/model-body-rewriter.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,10 @@ const { resolveModel } = require('./model-resolver');
2222
* @param {Record<string, string[]|{patterns: string[], fallback?: boolean}>} aliases - Parsed alias map
2323
* @param {Record<string, string[]|null>} availableModels - Cached models per provider
2424
* @param {{ enabled?: boolean, strategy?: string }} [modelFallbackConfig]
25+
* @param {{ allowedModels?: string[]|null, disallowedModels?: string[]|null }|null} [modelPolicyConfig]
2526
* @returns {{ body: Buffer, originalModel: string, resolvedModel: string, log: string[], fallback?: object } | null}
2627
*/
27-
function rewriteModelInBody(body, provider, aliases, availableModels, modelFallbackConfig) {
28+
function rewriteModelInBody(body, provider, aliases, availableModels, modelFallbackConfig, modelPolicyConfig) {
2829
// Only attempt rewrite for non-empty bodies
2930
if (!body || body.length === 0) return null;
3031

@@ -34,7 +35,7 @@ function rewriteModelInBody(body, provider, aliases, availableModels, modelFallb
3435
// Determine the requested model. If absent, try the default alias ("").
3536
const originalModel = typeof parsed.model === 'string' ? parsed.model : '';
3637

37-
const resolution = resolveModel(originalModel, aliases, availableModels, provider, [], modelFallbackConfig);
38+
const resolution = resolveModel(originalModel, aliases, availableModels, provider, [], modelFallbackConfig, modelPolicyConfig);
3839
if (!resolution) return null;
3940

4041
const { resolvedModel, log } = resolution;

0 commit comments

Comments
 (0)