Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .grype.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,38 @@ ignore:
package:
name: brace-expansion
type: npm

# GHSA-rgw5-rvv9-x895 (brace-expansion <5.0.9, HIGH) is a follow-on DoS
# affecting the same npm-vendored package and threat surface described above.
# AWF does not pass remote input to npm's brace expansion code. npm 12.0.1,
# the latest official release as of 2026-08-04, still bundles 5.0.7; retain
# this acceptance only until an official npm release bundles >=5.0.9.
- vulnerability: GHSA-rgw5-rvv9-x895
package:
name: brace-expansion
type: npm

# ── ip-address bundled inside the vendored npm CLI ───────────────────────────
#
# GHSA-mwp4-54f8-5fhr (ip-address <=10.3.0 -> 10.3.1, HIGH):
# IPv4 octets with leading zeroes can be interpreted inconsistently, which
# can bypass an SSRF guard that trusts ip-address classification.
#
# Risk acceptance — NOT USED AS A SECURITY BOUNDARY:
# This copy is bundled inside the npm CLI, not an AWF application
# dependency. AWF's proxy, firewall, and network policy do not use it for
# address classification or SSRF decisions. npm is a local CLI in these
# images and its vendored copy is not exposed as a request handler.
#
# No official npm release contains the fix yet: npm 12.0.1, the latest
# release as of 2026-08-04, still bundles ip-address 10.2.0. Hand-patching
# npm's internal dependency tree would replace a verified upstream artifact
# with a locally modified distribution.
#
# Revisit: once an official npm release bundles ip-address >=10.3.1, update
# the npm tarball pin and SHA256 in all four container Dockerfiles and DELETE
# this entry.
- vulnerability: GHSA-mwp4-54f8-5fhr
package:
name: ip-address
type: npm
8 changes: 2 additions & 6 deletions containers/api-proxy/key-validation.js
Original file line number Diff line number Diff line change
Expand Up @@ -255,13 +255,9 @@ function validateRequestedModel() {
for (const line of resolution.log) {
logRequest('debug', 'model_validation_step', { message: line, provider: resolution.provider });
}
// resolved_via is 'alias' when: the model name matches an alias key (alias lookup
// takes precedence in resolveModel) OR the resolved name differs from the request.
// An explicit provider model takes precedence over a matching alias key.
const requestedKey = requestedModel.toLowerCase();
const isAlias = modelAliases
? Object.keys(modelAliases.models).some(k => k.toLowerCase() === requestedKey)
: false;
const resolvedVia = isAlias || resolution.resolvedModel.toLowerCase() !== requestedKey ? 'alias' : 'direct';
const resolvedVia = resolution.resolvedModel.toLowerCase() !== requestedKey ? 'alias' : 'direct';
logRequest('info', 'model_validation', {
requested_model: requestedModel,
resolved_via: resolvedVia,
Expand Down
51 changes: 36 additions & 15 deletions containers/api-proxy/model-resolver.js
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,16 @@ function _resolveAliasPatterns(aliasKey, aliasDefinition, requestedModel, aliase

if (slashIdx === -1) {
// Recursive alias reference (no provider prefix)
const sub = resolveModel(pattern, aliases, availableModels, currentProvider, newChain, fallbackConfig, modelPolicyConfig);
const sub = resolveModel(
pattern,
aliases,
availableModels,
currentProvider,
newChain,
fallbackConfig,
modelPolicyConfig,
false
);
if (sub) {
log.push(...sub.log);
candidates.push(sub.resolvedModel);
Expand Down Expand Up @@ -252,9 +261,10 @@ function _resolveAliasPatterns(aliasKey, aliasDefinition, requestedModel, aliase
*
* Resolution algorithm:
* 1. Loop detection — bail out if key already visited.
* 2. Alias lookup (case-insensitive); family alias fallback for gpt-5.<minor>.
* 3. No alias found → _resolveDirectMatch (direct, family-version, or middle-power).
* 4. Alias found → _resolveAliasPatterns (pattern expansion + best-candidate selection).
* 2. Direct match — preserve an explicitly available provider model.
* 3. Alias lookup (case-insensitive); family alias fallback for gpt-5.<minor>.
* 4. No alias found → _resolveDirectMatch (family-version or middle-power fallback).
* 5. Alias found → _resolveAliasPatterns (pattern expansion + best-candidate selection).
*
* @param {string} requestedModel - Model name from the request body (or "" for default)
* @param {Record<string, string[]|{patterns: string[], fallback?: boolean}>} aliases - Alias map from parseModelAliases()
Expand All @@ -263,9 +273,19 @@ function _resolveAliasPatterns(aliasKey, aliasDefinition, requestedModel, aliase
* @param {string[]} [chain=[]] - Accumulates visited alias names for loop detection
* @param {{ enabled?: boolean, strategy?: string }} [modelFallbackConfig]
* @param {{ allowedModels?: string[]|null, disallowedModels?: string[]|null }|null} [modelPolicyConfig]
* @param {boolean} [preferDirectRequest=true] - Prefer an exact provider model over a same-named alias for top-level requests
* @returns {{ resolvedModel: string, candidates: string[], log: string[], fallback?: object } | null}
*/
function resolveModel(requestedModel, aliases, availableModels, currentProvider, chain = [], modelFallbackConfig = DEFAULT_MODEL_FALLBACK, modelPolicyConfig = null) {
function resolveModel(
requestedModel,
aliases,
availableModels,
currentProvider,
chain = [],
modelFallbackConfig = DEFAULT_MODEL_FALLBACK,
modelPolicyConfig = null,
preferDirectRequest = true
) {
const log = [];
const key = requestedModel.toLowerCase();
const fallbackConfig = normalizeFallbackConfig(modelFallbackConfig);
Expand All @@ -289,32 +309,33 @@ function resolveModel(requestedModel, aliases, availableModels, currentProvider,
}
const newChain = [...chain, key];

// Find alias entry (case-insensitive)
let aliasEntry = Object.entries(aliases).find(([k]) => k.toLowerCase() === key);

if (!aliasEntry) {
// Prefer exact provider-advertised model names over family-alias fallback.
// This avoids silently rewriting a concrete user request (e.g. gpt-5.6-sol)
// to another family member when that exact model is already available.
// An explicit model available from this provider is authoritative over any
// matching alias. This prevents an alias with the same name from silently
// steering a request away from the configured provider.
if (preferDirectRequest) {
const providerModels = (availableModels[currentProvider] || []);
const direct = providerModels.find(m => m.toLowerCase() === key);
if (direct) {
if (!_isModelPermittedByPolicy(direct, modelPolicyConfig)) {
// Model is advertised but blocked by policy — treat as terminal to prevent
// a denied model from being silently rewritten to a permitted family member.
log.push(`[model-resolver] model policy blocked direct match: "${direct}"`);
return null;
}
log.push(`[model-resolver] direct match: "${requestedModel}" → "${direct}"`);
return {
resolvedModel: direct,
candidates: [direct],
log,
fallback: fallbackConfig.enabled
? { activated: false, selection_method: 'middle_power_median', reason: 'direct_match' }
: undefined,
};
}
}

// Find alias entry (case-insensitive)
let aliasEntry = Object.entries(aliases).find(([k]) => k.toLowerCase() === key);

if (!aliasEntry) {
// Family fallback: treat gpt-5.<minor> as gpt-5 when only the family alias
// exists. This keeps versioned IDs like gpt-5.4 compatible with configs that
// define "gpt-5" alias patterns.
Expand Down Expand Up @@ -368,7 +389,7 @@ function filterResolvableAliases(aliases, availableModels) {

for (const aliasKey of Object.keys(aliases)) {
const canResolve = providersWithData.some(provider => {
const resolution = resolveModel(aliasKey, aliases, availableModels, provider, [], noFallback);
const resolution = resolveModel(aliasKey, aliases, availableModels, provider, [], noFallback, null, false);
return resolution !== null;
});

Expand Down
39 changes: 39 additions & 0 deletions containers/api-proxy/model-resolver.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,36 @@ describe('resolveModel', () => {
expect(result.resolvedModel).toBe('gpt-5.6-sol');
});

it('preserves an explicit provider model when an alias has the same name', () => {
const result = resolveModel(
'claude-sonnet-5',
{ 'claude-sonnet-5': ['copilot/claude-sonnet-6*'] },
{
anthropic: ['claude-sonnet-5'],
copilot: ['claude-sonnet-6'],
},
'anthropic'
);
expect(result).not.toBeNull();
expect(result.resolvedModel).toBe('claude-sonnet-5');
expect(result.candidates).toEqual(['claude-sonnet-5']);
expect(result.log).toContain('[model-resolver] direct match: "claude-sonnet-5" → "claude-sonnet-5"');
});

it('expands a same-named alias when referenced recursively', () => {
const result = resolveModel(
'coding',
{
coding: ['claude-sonnet-5'],
'claude-sonnet-5': ['anthropic/claude-sonnet-6*'],
},
{ anthropic: ['claude-sonnet-5', 'claude-sonnet-6'] },
'anthropic'
);
expect(result).not.toBeNull();
expect(result.resolvedModel).toBe('claude-sonnet-6');
});

it('returns null (terminal) when the exact advertised model is denied by policy — does not fall through to family alias', () => {
// gpt-5.6-sol is advertised by the provider AND has a gpt-5 family alias.
// When gpt-5.6-sol is explicitly denylisted, resolution must stop (null) rather
Expand Down Expand Up @@ -477,6 +507,15 @@ describe('filterResolvableAliases', () => {
expect(result).not.toHaveProperty('sonnet');
// 'gpt-5-codex' has no match
expect(result).not.toHaveProperty('gpt-5-codex');
});

it('should not keep an alias solely because its key is an available model', () => {
const collidingAliases = {
'claude-sonnet-5': ['anthropic/claude-sonnet-6*'],
};
const availableModels = { anthropic: ['claude-sonnet-5'] };
const result = filterResolvableAliases(collidingAliases, availableModels);
expect(result).not.toHaveProperty('claude-sonnet-5');
// '' → 'sonnet' → no match → filtered out too
expect(result).not.toHaveProperty('');
});
Expand Down
29 changes: 29 additions & 0 deletions containers/api-proxy/server.startup-model-validation.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,35 @@ describe('validateRequestedModel', () => {
}
});

it('logs an explicit provider model as direct when it shares an alias name', () => {
const prevAliases = process.env.AWF_MODEL_ALIASES;
process.env.AWF_MODEL_ALIASES = JSON.stringify({
models: { 'claude-sonnet-5': ['copilot/claude-sonnet-6*'] },
});

let isolatedServer;
jest.isolateModules(() => {
jest.mock('./logging', () => ({ logRequest: jest.fn() }));
isolatedServer = require('./server');
});

const { logRequest: isolatedLog } = require('./logging');

try {
isolatedServer.resetModelCacheState();
isolatedServer.cachedModels.anthropic = ['claude-sonnet-5'];
process.env.AWF_REQUESTED_MODEL = 'claude-sonnet-5';
isolatedServer.validateRequestedModel();
expect(isolatedLog).toHaveBeenCalledWith('info', 'model_validation', expect.objectContaining({
requested_model: 'claude-sonnet-5',
resolved_via: 'direct',
}));
} finally {
if (prevAliases === undefined) delete process.env.AWF_MODEL_ALIASES;
else process.env.AWF_MODEL_ALIASES = prevAliases;
}
});

it('does not emit model_validation via alias when fallback would fire but model is absent', () => {
const prevAliases = process.env.AWF_MODEL_ALIASES;
const prevFallback = process.env.AWF_MODEL_FALLBACK;
Expand Down
Loading