diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 70951332c..7f391640e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -707,7 +707,7 @@ jobs: # Build gh-aw-node: minimal Node.js Alpine image for the gh-aw safeoutputs MCP server. # Fixes CVEs in libcrypto3/libssl3, musl (Alpine 3.24), tar, brace-expansion, sigstore, - # and undici by using node:22.23.1-alpine3.24 + npm 11.18.0. + # and undici by using node:22.23.2-alpine3.24 + npm 11.18.0. build-gh-aw-node: name: Build gh-aw-node Image runs-on: ubuntu-latest diff --git a/.grype.yaml b/.grype.yaml index bda777bb4..335085ee1 100644 --- a/.grype.yaml +++ b/.grype.yaml @@ -10,6 +10,31 @@ # Format reference: https://github.com/anchore/grype?tab=readme-ov-file#configuration ignore: + # ── Node.js 22.23.2 Permission Model false positive ─────────────────────────── + # + # CVE-2026-58043 (Node.js Permission Model path matching, HIGH): + # Grype reports this against Node.js 22.23.2 because its advisory record has + # no affected-version bounds. It also reports the finding against the patched + # 24.18.1 and 26.5.1 security releases. + # + # Node.js 22.23.2 is the official July 29, 2026 security release for the 22.x + # line. The Node.js security announcement lists CVE-2026-58043 among the + # issues fixed by the newly available 22.x, 24.x, and 26.x updates: + # https://nodejs.org/en/blog/vulnerability/july-2026-security-releases + # + # Defense in depth: the vulnerable code is only active under Node's + # experimental `--permission` model. AWF does not invoke Node with + # `--permission`, `--allow-fs-read`, or `--allow-fs-write`; filesystem + # isolation is enforced by the container/chroot and bind-mount policy. + # + # This exception is scoped to the patched Node.js 22.23.2 binary only. Delete + # it once Grype publishes corrected affected-version metadata. + - vulnerability: CVE-2026-58043 + package: + name: node + version: "22.23.2" + type: binary + # ── stdlib@go1.24.6 embedded in gosu binary ────────────────────────────────── # # GO-2026-4337 (stdlib go1.24.6 -> 1.24.13 / 1.25.7 / 1.26.0-rc.3, CRITICAL): diff --git a/containers/agent/Dockerfile b/containers/agent/Dockerfile index be4314e33..f0fd3670a 100644 --- a/containers/agent/Dockerfile +++ b/containers/agent/Dockerfile @@ -28,7 +28,7 @@ RUN if getent hosts azure.archive.ubuntu.com >/dev/null 2>&1; then \ echo "Azure apt mirror not reachable, using default archive.ubuntu.com"; \ fi -# Install required packages, GitHub CLI 2.97.0, Node.js 22.23.1, and npm 11.18.0 +# Install required packages, GitHub CLI 2.97.0, Node.js 22.23.2, and npm 11.18.0 # Note: Some packages may already exist in runner-like base images, apt handles this gracefully # apt_update_retry: retries up to 3 times with backoff; if all fail, reverts to archive.ubuntu.com RUN set -eux; \ @@ -104,26 +104,26 @@ RUN set -eux; \ > /etc/apt/sources.list.d/github-cli.list && \ apt-get update && \ apt_install_retry gh=2.97.0 && \ - # Install Node.js 22.23.1 from the official Node.js binary distribution + # Install Node.js 22.23.2 from the official Node.js binary distribution # (nodejs.org), NOT the NodeSource apt repo. The NodeSource .deb is tracked by # a Debian/NodeSource security feed that yields false-positive CVE matches # (e.g. CVE-2023-44487 and CVE-2026-45447) against a binary that is already - # patched (22.23.1 bundles OpenSSL 3.5.7). The official tarball carries no .deb + # patched (22.23.2 bundles OpenSSL 3.5.7). The official tarball carries no .deb # metadata, so grype matches it via NVD CPE data instead, which correctly - # excludes 22.23.1 for both CVEs. - # Checksums sourced from https://nodejs.org/dist/v22.23.1/SHASUMS256.txt + # excludes 22.23.2 for both CVEs. + # Checksums sourced from https://nodejs.org/dist/v22.23.2/SHASUMS256.txt # Note: node --version segfaults under QEMU arm64 emulation, so detect QEMU # and skip the runtime version check there. UNDER_QEMU=false && \ if [ -f /dev/.buildkit_qemu_emulator ] || [ -n "${QEMU_CPU:-}" ]; then UNDER_QEMU=true; fi && \ # Remove any existing nodejs packages first to avoid conflicting detections (apt-get remove -y nodejs npm || true) && \ - NODE_VERSION="v22.23.1" && \ + NODE_VERSION="v22.23.2" && \ NODE_DPKG_ARCH="$(dpkg --print-architecture)" && \ case "$NODE_DPKG_ARCH" in \ - amd64) NODE_ARCH="x64"; NODE_SHA256="7a8cb04b4a1df4eaf432125324b81b29a088e73570a23259a8de1c65d07fc129" ;; \ - arm64) NODE_ARCH="arm64"; NODE_SHA256="543fa39e57d4c07855939459a323f4deb9a79dd1bb45e6e99458b0f2de10db8d" ;; \ - armhf) NODE_ARCH="armv7l"; NODE_SHA256="03c56ac0bd3ef3cce967c2f7b2f7ac2259a4ae7ceeaa661291aadf65729a8b53" ;; \ + amd64) NODE_ARCH="x64"; NODE_SHA256="b294a556e639d64338823920e5866c21c02741742d2e1529ee1a225c1ec9252a" ;; \ + arm64) NODE_ARCH="arm64"; NODE_SHA256="013b59cfd2819703a6f4a14ab891fc46fc2a4e3f5bcd92de3fb4929b43e35b30" ;; \ + armhf) NODE_ARCH="armv7l"; NODE_SHA256="2a2f59eb8fd9dec27b3bee17c729131d1fd3e6d9943d479f1156ce38af8cd599" ;; \ *) echo "Unsupported architecture for Node.js: $NODE_DPKG_ARCH" >&2; exit 1 ;; \ esac && \ NODE_TARBALL="node-${NODE_VERSION}-linux-${NODE_ARCH}.tar.gz" && \ @@ -134,12 +134,12 @@ RUN set -eux; \ rm -f "/tmp/${NODE_TARBALL}" && \ # Hold gh so the later apt-get upgrade does not silently replace the pinned version apt-mark hold gh && \ - # Verify Node.js 22.23.1 was installed correctly (skip node exec under QEMU — segfaults) + # Verify Node.js 22.23.2 was installed correctly (skip node exec under QEMU — segfaults) if [ "$UNDER_QEMU" = "true" ]; then \ echo "Skipping node --version check (QEMU emulation detected)" && \ test -x /usr/local/bin/node || (echo "ERROR: /usr/local/bin/node missing" && exit 1); \ else \ - node --version | grep -qE '^v22\.23\.1' || (echo "ERROR: Node.js 22.23.1 not installed correctly" && exit 1) && \ + node --version | grep -qE '^v22\.23\.2' || (echo "ERROR: Node.js 22.23.2 not installed correctly" && exit 1) && \ npx --version || (echo "ERROR: npx not found" && exit 1); \ fi && \ # Replace Node's bundled npm 10.x with npm 11.18.0 diff --git a/containers/api-proxy/Dockerfile b/containers/api-proxy/Dockerfile index 23f8f6bb0..db0e7e068 100644 --- a/containers/api-proxy/Dockerfile +++ b/containers/api-proxy/Dockerfile @@ -1,11 +1,11 @@ # Node.js API proxy for credential management # Routes through Squid to respect domain whitelisting -FROM node:22.23.1-alpine3.24 +FROM node:22.23.2-alpine3.24 # Install curl for healthchecks (>=8.21.0-r0 to fix CVE in 8.20.x) RUN apk add --no-cache "curl>=8.21.0-r0" -# Replace the vulnerable npm 10.x bundled with Node 22.23.1 with npm 11.18.0. +# Replace the vulnerable npm 10.x bundled with Node 22.23.2 with npm 11.18.0. # npm 11.18.0 bundles: tar 7.5.19 (fixes GHSA-23hp-3jrh-7fpw/GHSA-8x88-c5mf-7j5w), # sigstore 4.1.1 (fixes GHSA-52v5-jr5w-gjxr), brace-expansion 5.0.7 and # picomatch 4.0.4 (via tinyglobby, fixes GHSA-3jxr-9vmj-r5cp / GHSA-c2c7-rcm5-vvqj). @@ -23,7 +23,7 @@ RUN set -eux; \ /usr/local/lib/node_modules/npm/bin/npx-cli.js; \ ln -sf /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm; \ ln -sf /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx; \ - node --version | grep -qE '^v22\.23\.1' || (echo "ERROR: expected Node.js v22.23.1" && exit 1); \ + node --version | grep -qE '^v22\.23\.2' || (echo "ERROR: expected Node.js v22.23.2" && exit 1); \ npm --version | grep -qE '^11\.18\.0' || (echo "ERROR: expected npm 11.18.0" && exit 1); \ rm -f /tmp/npm-11.18.0.tgz diff --git a/containers/api-proxy/guards/ai-credits-guard.js b/containers/api-proxy/guards/ai-credits-guard.js index ec30adf37..d2aa7871e 100644 --- a/containers/api-proxy/guards/ai-credits-guard.js +++ b/containers/api-proxy/guards/ai-credits-guard.js @@ -93,7 +93,7 @@ function canonicalizeModel(model) { return withoutDateSuffix.replace(/[._]/g, '-'); } -function resolveModelPricing(model, state = aiCreditsState, provider = undefined, inputTokens = 0) { +function resolveModelPricing(model, state = aiCreditsState, provider = undefined, inputTokens = 0, options = {}) { const operatorPricing = provider ? resolveProviderPricingOverlay(provider, model) : null; if (operatorPricing) return operatorPricing; @@ -102,7 +102,7 @@ function resolveModelPricing(model, state = aiCreditsState, provider = undefined .every(field => Object.hasOwn(runtime.pricing, field))) { return runtime; } - const fallback = resolveLowerPriorityPricing(model, state); + const fallback = resolveLowerPriorityPricing(model, state, options); if (!runtime) return fallback; const mergedPricing = {}; for (const field of ['input', 'cachedInput', 'cacheWrite', 'output']) { @@ -117,7 +117,7 @@ function resolveModelPricing(model, state = aiCreditsState, provider = undefined return { ...runtime, pricing: mergedPricing }; } -function resolveLowerPriorityPricing(model, state) { +function resolveLowerPriorityPricing(model, state, options = {}) { if (Object.hasOwn(pricingByModel, model)) { return { pricing: pricingByModel[model], source: 'curated', tier: 'default' }; } @@ -147,7 +147,11 @@ function resolveLowerPriorityPricing(model, state) { return { pricing: catalogModel.pricing, source: 'models.dev', tier: 'default' }; } - if (!state.warnedUnknownModels.has(model)) { + // Speculative callers (e.g. filtering a fallback candidate pool) pass quiet:true + // so that probing a model neither emits an operator-facing warning nor marks the + // model as already-warned — which would suppress the warning if it is genuinely + // requested later. + if (!options.quiet && !state.warnedUnknownModels.has(model)) { logRequest('warn', 'unknown_model_ai_credits_pricing', { model: sanitizeForLog(model), }); @@ -171,6 +175,32 @@ function resolveLowerPriorityPricing(model, state) { return null; } +/** + * Side-effect-free check for whether a model has resolvable AI-credits pricing. + * + * Mirrors checkUnknownModelRejection's resolution (both the default and the + * highest selectable pricing tier must resolve) but emits no logs and does not + * mutate guard state, so it is safe to call across a large pool of speculative + * candidates that may never be selected. + * + * @param {string} model + * @param {string} [provider] + * @returns {boolean} + */ +function isModelPriceable(model, provider = undefined) { + if (!model) return true; + const defaultTier = resolveModelPricing(model, aiCreditsState, provider, 0, { quiet: true }); + if (!defaultTier) return false; + const highestTier = resolveModelPricing( + model, + aiCreditsState, + provider, + Number.MAX_SAFE_INTEGER, + { quiet: true }, + ); + return !!highestTier; +} + /** * Check if a model is unresolvable and should be rejected. * Only rejects when maxAiCredits is active and no default pricing is configured. @@ -374,6 +404,7 @@ module.exports = { getAiCreditsBlockState, buildAiCreditsLimitError, checkUnknownModelRejection, + isModelPriceable, canonicalizeModel, resetAiCreditsGuardForTests, }; diff --git a/containers/api-proxy/guards/ai-credits-guard.test.js b/containers/api-proxy/guards/ai-credits-guard.test.js index fc40594ba..56867dd85 100644 --- a/containers/api-proxy/guards/ai-credits-guard.test.js +++ b/containers/api-proxy/guards/ai-credits-guard.test.js @@ -5,6 +5,7 @@ const { getAiCreditsBlockState, buildAiCreditsLimitError, checkUnknownModelRejection, + isModelPriceable, canonicalizeModel, resetAiCreditsGuardForTests, } = require('./ai-credits-guard'); @@ -672,4 +673,38 @@ describe('ai-credits-guard', () => { expect(checkUnknownModelRejection('auto', PROVIDER_OPENAI)).not.toBeNull(); }); }); + + describe('isModelPriceable (side-effect-free)', () => { + it('reports priced and unpriced models correctly', () => { + expect(isModelPriceable('gpt-4-turbo', PROVIDER_OPENAI)).toBe(true); + expect(isModelPriceable('crest-alpha-0418-block-cy4.5', PROVIDER_OPENAI)).toBe(false); + }); + + it('emits no warning when probing unpriceable models', () => { + process.env.AWF_MAX_AI_CREDITS = '10'; + resetAiCreditsGuardForTests(); + + const { lines, spy } = collectLogOutput(); + for (let i = 0; i < 25; i++) { + isModelPriceable(`crest-alpha-probe-${i}`, PROVIDER_OPENAI); + } + spy.mockRestore(); + + expect(lines.filter(l => l.event === 'unknown_model_ai_credits_pricing')).toHaveLength(0); + }); + + it('does not suppress the warning if a probed model is later requested', () => { + process.env.AWF_MAX_AI_CREDITS = '10'; + resetAiCreditsGuardForTests(); + + isModelPriceable('crest-alpha-probe-7', PROVIDER_OPENAI); + + const { lines, spy } = collectLogOutput(); + checkUnknownModelRejection('crest-alpha-probe-7', PROVIDER_OPENAI); + spy.mockRestore(); + + expect(lines.some(l => l.event === 'unknown_model_ai_credits_pricing')).toBe(true); + }); + }); + }); diff --git a/containers/api-proxy/model-config.js b/containers/api-proxy/model-config.js index 767d36e34..98a92443a 100644 --- a/containers/api-proxy/model-config.js +++ b/containers/api-proxy/model-config.js @@ -10,6 +10,7 @@ const { sanitizeForLog, logRequest } = require('./logging'); const { diag } = require('./token-persistence'); const { getCopilotModelFallbackPolicy } = require('./providers/copilot-auth'); const { ALLOWED_MODELS, DISALLOWED_MODELS } = require('./guards/model-policy-guard'); +const { isModelPriceable } = require('./guards/ai-credits-guard'); const MODEL_ALIASES_RAW = (process.env.AWF_MODEL_ALIASES || '').trim() || undefined; const MODEL_ALIASES = parseModelAliases(MODEL_ALIASES_RAW); @@ -71,6 +72,23 @@ logRequest('info', 'startup', { model_fallback: MODEL_FALLBACK, }); +/** + * Build a predicate that reports whether the AI-credits guard can price a model. + * + * Used to keep the middle-power fallback from synthesizing a model that the + * guard would immediately reject. Returns null (no filtering) unless the guard + * is actually active — i.e. a credit cap is set with no configured default + * pricing — so pricing coverage never constrains resolution otherwise. + * + * @param {string} provider + * @returns {((model: string) => boolean)|null} + */ +function makeIsModelPriceable(provider) { + if (!process.env.AWF_MAX_AI_CREDITS) return null; + if (process.env.AWF_DEFAULT_AI_CREDITS_PRICING) return null; + return (model) => isModelPriceable(model, provider); +} + function getModelFallbackPolicyForProvider(provider) { if (MODEL_FALLBACK.excludeEngines && MODEL_FALLBACK.excludeEngines.includes(provider.toLowerCase())) { return { @@ -86,7 +104,8 @@ function getModelFallbackPolicyForProvider(provider) { } function getModelFallbackForProvider(provider) { - return getModelFallbackPolicyForProvider(provider).effective; + const effective = getModelFallbackPolicyForProvider(provider).effective; + return { ...effective, isModelPriceable: makeIsModelPriceable(provider) }; } function getEffectiveModelFallbackForReflect(adapters) { diff --git a/containers/api-proxy/model-fallback.js b/containers/api-proxy/model-fallback.js index f928bf8e4..f1741238d 100644 --- a/containers/api-proxy/model-fallback.js +++ b/containers/api-proxy/model-fallback.js @@ -25,6 +25,9 @@ function normalizeFallbackConfig(modelFallbackConfig) { return { enabled: config.enabled !== false, strategy: config.strategy || 'middle_power', + isModelPriceable: typeof config.isModelPriceable === 'function' + ? config.isModelPriceable + : null, }; } @@ -81,7 +84,29 @@ function selectMiddlePowerFallback(requestedModel, availableModels, currentProvi const familyCandidates = familyPrefix ? providerModels.filter(model => model.toLowerCase().startsWith(familyPrefix)) : []; - const selectedPool = familyCandidates.length > 0 ? familyCandidates : providerModels; + const basePool = familyCandidates.length > 0 ? familyCandidates : providerModels; + + // Soft price filter: never let the fallback synthesize a model the proxy has no + // pricing for, since such a pick is rejected downstream by the AI-credits guard. + // Applied only to this synthesized-selection path — explicitly requested or + // pattern-matched models are unaffected. Falls back to the unfiltered pool when + // filtering would leave nothing, so this can never turn a success into a failure. + let selectedPool = basePool; + let priceFiltered = false; + if (fallbackConfig.isModelPriceable) { + const priceable = basePool.filter(model => { + try { + return fallbackConfig.isModelPriceable(model) === true; + } catch { + return true; + } + }); + if (priceable.length > 0 && priceable.length < basePool.length) { + selectedPool = priceable; + priceFiltered = true; + } + } + const sortedCandidates = getTierSortedModels(currentProvider, selectedPool); if (sortedCandidates.length === 0) return null; @@ -94,6 +119,7 @@ function selectMiddlePowerFallback(requestedModel, availableModels, currentProvi selection_method: 'middle_power_median', available_models_count: providerModels.length, used_family_filter: familyCandidates.length > 0, + used_price_filter: priceFiltered, candidates: sortedCandidates, }, }; diff --git a/containers/api-proxy/model-resolver.js b/containers/api-proxy/model-resolver.js index c4d649bae..f8a535c16 100644 --- a/containers/api-proxy/model-resolver.js +++ b/containers/api-proxy/model-resolver.js @@ -176,6 +176,10 @@ function _resolveAliasPatterns(aliasKey, aliasDefinition, requestedModel, aliase log.push(`[model-resolver] alias: "${requestedModel}" → [${patterns.join(', ')}]`); const candidates = []; + // Candidates produced by a nested alias's middle-power fallback are synthesized + // guesses, not genuine pattern matches. They are kept separate so they can never + // out-rank a sibling pattern that actually matched a model. + const synthesizedCandidates = []; for (const pattern of patterns) { const slashIdx = pattern.indexOf('/'); @@ -194,7 +198,11 @@ function _resolveAliasPatterns(aliasKey, aliasDefinition, requestedModel, aliase ); if (sub) { log.push(...sub.log); - candidates.push(sub.resolvedModel); + if (sub.fallback && sub.fallback.activated) { + synthesizedCandidates.push(sub.resolvedModel); + } else { + candidates.push(sub.resolvedModel); + } } } else { // "provider/modelpattern" ref — only match for the current provider @@ -212,20 +220,45 @@ function _resolveAliasPatterns(aliasKey, aliasDefinition, requestedModel, aliase } } + // Prefer genuine pattern matches. Synthesized fallback picks from nested + // aliases are only considered when no sibling pattern matched anything. + const effectiveCandidates = candidates.length > 0 ? candidates : synthesizedCandidates; + if (candidates.length > 0 && synthesizedCandidates.length > 0) { + log.push( + `[model-resolver] ignoring ${synthesizedCandidates.length} synthesized fallback candidate(s) ` + + `in favour of ${candidates.length} genuine match(es)` + ); + } + // Apply model policy filter: remove candidates that are not permitted. const filteredCandidates = modelPolicyConfig - ? candidates.filter(c => _isModelPermittedByPolicy(c, modelPolicyConfig)) - : candidates; + ? effectiveCandidates.filter(c => _isModelPermittedByPolicy(c, modelPolicyConfig)) + : effectiveCandidates; - if (filteredCandidates.length < candidates.length) { - const blocked = candidates.filter(c => !filteredCandidates.includes(c)); + if (filteredCandidates.length < effectiveCandidates.length) { + const blocked = effectiveCandidates.filter(c => !filteredCandidates.includes(c)); log.push(`[model-resolver] model policy filtered out ${blocked.length} candidate(s): ${blocked.slice(0, 5).join(', ')}${blocked.length > 5 ? ', …' : ''}`); } if (filteredCandidates.length === 0) { log.push(`[model-resolver] no candidates found for "${aliasKey}" on provider "${currentProvider}"`); const hasProviderPattern = patterns.some((pattern) => pattern.includes('/')); - if (aliasDefinition.fallback && hasProviderPattern && !modelPolicyConfig) { + // Only fall back when this alias actually names the current provider. An alias + // whose patterns target *other* providers (e.g. "haiku" → copilot/*, anthropic/* + // evaluated on an openai proxy) has a legitimately empty candidate set. + // + // This is enforced only for *nested* alias references, where sibling patterns in + // the parent fan-out can still supply a genuine match. A top-level request keeps + // the existing graceful-degradation behaviour of substituting something rather + // than failing outright. + const isNestedReference = newChain.length > 1; + const targetsCurrentProvider = patterns.some((pattern) => { + const slashIdx = pattern.indexOf('/'); + return slashIdx !== -1 && + pattern.slice(0, slashIdx).toLowerCase() === currentProvider.toLowerCase(); + }); + const fallbackAllowed = !isNestedReference || targetsCurrentProvider; + if (aliasDefinition.fallback && fallbackAllowed && hasProviderPattern && !modelPolicyConfig) { return tryMiddlePowerFallback( requestedModel, availableModels, currentProvider, 'no_alias_match_and_not_in_available_models', fallbackConfig, log @@ -239,6 +272,7 @@ function _resolveAliasPatterns(aliasKey, aliasDefinition, requestedModel, aliase unique.sort(compareByVersion); const resolved = unique[0]; + const resolvedViaSynthesis = candidates.length === 0 && synthesizedCandidates.length > 0; log.push( `[model-resolver] resolved: "${requestedModel}" → "${resolved}"` + (unique.length > 1 @@ -251,7 +285,13 @@ function _resolveAliasPatterns(aliasKey, aliasDefinition, requestedModel, aliase candidates: unique, log, fallback: fallbackConfig.enabled - ? { activated: false, selection_method: 'middle_power_median', reason: 'normal_resolution_succeeded' } + ? { + activated: resolvedViaSynthesis, + selection_method: 'middle_power_median', + reason: resolvedViaSynthesis + ? 'no_alias_match_and_not_in_available_models' + : 'normal_resolution_succeeded', + } : undefined, }; } diff --git a/containers/api-proxy/model-resolver.test.js b/containers/api-proxy/model-resolver.test.js index 2eadf9283..c9b14ae69 100644 --- a/containers/api-proxy/model-resolver.test.js +++ b/containers/api-proxy/model-resolver.test.js @@ -983,3 +983,144 @@ describe('filterAvailableModelsToConfiguredProviders', () => { )).toEqual({}); }); }); + +// ── Cross-provider fan-out regression ────────────────────────────────────── +// +// Regression coverage for alias fan-outs that resolve against a single-provider +// proxy. A nested alias scoped to *other* providers (e.g. "haiku" on an OpenAI +// proxy) previously triggered middle-power fallback, synthesizing an unrelated +// model from the full live catalog that then out-ranked its legitimate siblings. + +describe('cross-provider alias fan-out', () => { + // Mirrors gh-aw's built-in table (pkg/workflow/data/model_aliases.json). + const ghAwAliases = { + detection: ['small'], + small: ['mini'], + mini: ['haiku', 'gpt-5-mini', 'gpt-5-nano', 'gemini-flash-lite'], + haiku: ['copilot/*haiku*', 'anthropic/*haiku*'], + 'gpt-5-mini': ['copilot/gpt-5*mini*', 'openai/gpt-5*mini*'], + 'gpt-5-nano': ['copilot/gpt-5*nano*', 'openai/gpt-5*nano*'], + 'gemini-flash-lite': ['copilot/gemini-*flash*lite*', 'gemini/gemini-*flash*lite*'], + }; + + // A live OpenAI catalog containing internal staging models alongside real ones. + const openaiCatalog = [ + 'crest-alpha-0416-block-a-cy4-after-40-calls', + 'crest-alpha-0418-block-cy4.5', + 'crest-alpha-0420-block-z-cy4.9', + 'gpt-5-mini-2025-08-07', + 'gpt-5-nano-2025-08-07', + 'gpt-4-turbo', + ]; + + it('resolves a nested fan-out to a legitimate sibling, not a synthesized model', () => { + const result = resolveModel('detection', ghAwAliases, { openai: openaiCatalog }, 'openai'); + expect(result).not.toBeNull(); + expect(result.resolvedModel).not.toMatch(/^crest-alpha/); + expect(['gpt-5-mini-2025-08-07', 'gpt-5-nano-2025-08-07']).toContain(result.resolvedModel); + }); + + it('does not let a provider-mismatched nested alias contribute a candidate', () => { + const result = resolveModel('mini', ghAwAliases, { openai: openaiCatalog }, 'openai'); + expect(result).not.toBeNull(); + expect(result.candidates.every(c => !c.startsWith('crest-alpha'))).toBe(true); + }); + + it('still reports fallback as not activated when a genuine match wins', () => { + const result = resolveModel('detection', ghAwAliases, { openai: openaiCatalog }, 'openai'); + expect(result.fallback.activated).toBe(false); + }); + + it('preserves top-level graceful degradation for a directly requested model', () => { + // "haiku" requested directly on an OpenAI proxy still substitutes something + // rather than failing outright — only nested references are skipped. + const result = resolveModel('haiku', ghAwAliases, { openai: openaiCatalog }, 'openai'); + expect(result).not.toBeNull(); + expect(result.fallback.activated).toBe(true); + }); + + it('yields no candidate when every nested alias targets another provider', () => { + const aliases = { + onlyremote: ['haiku', 'gemini-flash-lite'], + haiku: ['copilot/*haiku*', 'anthropic/*haiku*'], + 'gemini-flash-lite': ['gemini/gemini-*flash*lite*'], + }; + const result = resolveModel('onlyremote', aliases, { openai: openaiCatalog }, 'openai'); + expect(result).toBeNull(); + }); + + it('marks fallback activated when only synthesized candidates exist', () => { + // The nested alias DOES name the current provider, so it is eligible for + // middle-power fallback — its pattern simply matches nothing. The parent then + // has no genuine candidate and must fall through to the synthesized one. + const aliases = { + parent: ['missing-on-openai'], + 'missing-on-openai': ['openai/no-such-model-*'], + }; + const result = resolveModel('parent', aliases, { openai: openaiCatalog }, 'openai'); + expect(result).not.toBeNull(); + expect(result.fallback.activated).toBe(true); + expect(result.fallback.reason).toBe('no_alias_match_and_not_in_available_models'); + }); + + it('prefers a genuine match over a synthesized one from a sibling pattern', () => { + // One child synthesizes (names openai, matches nothing); the other matches for real. + const aliases = { + parent: ['missing-on-openai', 'gpt-5-nano'], + 'missing-on-openai': ['openai/no-such-model-*'], + 'gpt-5-nano': ['openai/gpt-5*nano*'], + }; + const result = resolveModel('parent', aliases, { openai: openaiCatalog }, 'openai'); + expect(result).not.toBeNull(); + expect(result.resolvedModel).toBe('gpt-5-nano-2025-08-07'); + expect(result.fallback.activated).toBe(false); + expect(result.log.some(l => l.includes('ignoring 1 synthesized fallback candidate'))).toBe(true); + }); +}); + +// ── Middle-power price filtering ─────────────────────────────────────────── + +describe('selectMiddlePowerFallback price filtering', () => { + const catalog = [ + 'crest-alpha-0416-block-a', + 'crest-alpha-0418-block-b', + 'crest-alpha-0420-block-c', + 'crest-alpha-0422-block-d', + 'gpt-4-turbo', + ]; + const isPriceable = m => !m.startsWith('crest-alpha'); + + it('excludes unpriceable models from the fallback pool', () => { + const result = selectMiddlePowerFallback( + 'something', { openai: catalog }, 'openai', 'test', + { enabled: true, strategy: 'middle_power', isModelPriceable: isPriceable } + ); + expect(result.resolvedModel).toBe('gpt-4-turbo'); + expect(result.fallback.used_price_filter).toBe(true); + }); + + it('falls back to the unfiltered pool when nothing is priceable', () => { + const result = selectMiddlePowerFallback( + 'something', { openai: catalog }, 'openai', 'test', + { enabled: true, strategy: 'middle_power', isModelPriceable: () => false } + ); + expect(result).not.toBeNull(); + expect(result.fallback.used_price_filter).toBe(false); + }); + + it('is a no-op when no predicate is supplied', () => { + const withOut = selectMiddlePowerFallback( + 'something', { openai: catalog }, 'openai', 'test', + { enabled: true, strategy: 'middle_power' } + ); + expect(withOut.fallback.used_price_filter).toBe(false); + }); + + it('treats a throwing predicate as priceable rather than failing resolution', () => { + const result = selectMiddlePowerFallback( + 'something', { openai: catalog }, 'openai', 'test', + { enabled: true, strategy: 'middle_power', isModelPriceable: () => { throw new Error('boom'); } } + ); + expect(result).not.toBeNull(); + }); +}); diff --git a/containers/bounded-agent/Dockerfile b/containers/bounded-agent/Dockerfile index 5c0c4c006..97a65279b 100644 --- a/containers/bounded-agent/Dockerfile +++ b/containers/bounded-agent/Dockerfile @@ -43,7 +43,7 @@ RUN chmod 0555 /usr/local/bin/run-bounded-agent \ # ────────────────────────────────────────────────────────────────────────── # broker stage: trusted broker with Node + docker-cli (default build target) # ────────────────────────────────────────────────────────────────────────── -FROM node:22.23.1-alpine3.24 AS broker +FROM node:22.23.2-alpine3.24 AS broker # docker-cli — used by the broker to launch enclave containers RUN apk add --no-cache docker-cli \ diff --git a/containers/bounded-query/Dockerfile b/containers/bounded-query/Dockerfile index a72a7e1a6..d4896c243 100644 --- a/containers/bounded-query/Dockerfile +++ b/containers/bounded-query/Dockerfile @@ -36,7 +36,7 @@ RUN mkdir -p /query /awf/seed # ────────────────────────────────────────────────────────────────────────── # broker stage: trusted broker with Node + docker-cli (default build target) # ────────────────────────────────────────────────────────────────────────── -FROM node:22.23.1-alpine3.24 AS broker +FROM node:22.23.2-alpine3.24 AS broker # docker-cli — used by the broker to launch query containers RUN apk add --no-cache docker-cli \ diff --git a/containers/cli-proxy/Dockerfile b/containers/cli-proxy/Dockerfile index 0eb3ec840..592b81995 100644 --- a/containers/cli-proxy/Dockerfile +++ b/containers/cli-proxy/Dockerfile @@ -6,7 +6,7 @@ # forwards localhost traffic to the external proxy for TLS hostname matching. # # Pin to a specific alpine version so the curl constraint below is stable. -FROM node:22.23.1-alpine3.24 +FROM node:22.23.2-alpine3.24 # Install system packages. # Pin curl >= 8.21.0-r0 to fix CVE-2026-8924/8925/8926/8927/9079/10536/11564/11856 @@ -41,7 +41,7 @@ RUN set -eux; \ rm -rf "/tmp/${GH_TGZ}" "/tmp/gh_${GH_VERSION}_linux_${GH_ARCH}"; \ gh --version -# Replace the vulnerable npm 10.x bundled with Node 22.23.1 with npm 11.18.0. +# Replace the vulnerable npm 10.x bundled with Node 22.23.2 with npm 11.18.0. # npm 11.18.0 bundles: tar 7.5.19 (fixes GHSA-23hp-3jrh-7fpw/GHSA-8x88-c5mf-7j5w), # sigstore 4.1.1 (fixes GHSA-52v5-jr5w-gjxr), brace-expansion 5.0.7 and # picomatch 4.0.4 (via tinyglobby, fixes GHSA-3jxr-9vmj-r5cp / GHSA-c2c7-rcm5-vvqj). diff --git a/containers/gh-aw-node/Dockerfile b/containers/gh-aw-node/Dockerfile index 046442f88..54ae691eb 100644 --- a/containers/gh-aw-node/Dockerfile +++ b/containers/gh-aw-node/Dockerfile @@ -6,7 +6,7 @@ # # Security patches applied in this build: # -# libcrypto3/libssl3 >= 3.5.7: resolved by using node:22.23.1-alpine3.24 +# libcrypto3/libssl3 >= 3.5.7: resolved by using node:22.23.2-alpine3.24 # and running `apk upgrade --no-cache` to pull in the latest Alpine 3.24 # security-patched builds of openssl. # @@ -17,15 +17,15 @@ # tar >= 7.5.19 (GHSA-23hp-3jrh-7fpw / GHSA-8x88-c5mf-7j5w), # brace-expansion >= 5.0.7 (GHSA-3jxr-9vmj-r5cp / GHSA-c2c7-rcm5-vvqj), # sigstore >= 4.1.1 (GHSA-52v5-jr5w-gjxr): resolved by replacing the -# npm 10.x bundle that ships with Node 22.23.1 with npm 11.18.0, which +# npm 10.x bundle that ships with Node 22.23.2 with npm 11.18.0, which # carries all three fixed transitive dependencies. # -# undici >= 6.27.0: Node.js 22.23.1 bundles undici 6.27.0 (the Node.js +# undici >= 6.27.0: Node.js 22.23.2 bundles a patched undici (the Node.js # fetch API implementation), which meets the required floor; no separate fix needed. # # curl >= 8.21.0-r0 (CVE-2026-8924/8925/8926/8927/9079/10536/11564/11856): # resolved by pinning the apk constraint below. -FROM node:22.23.1-alpine3.24 +FROM node:22.23.2-alpine3.24 # Install curl for healthchecks and the npm tarball download below. # Pin >= 8.21.0-r0 to fix CVE-2026-8924/8925/8926/8927/9079/10536/11564/11856 @@ -34,7 +34,7 @@ FROM node:22.23.1-alpine3.24 RUN apk upgrade --no-cache && \ apk add --no-cache "curl>=8.21.0-r0" git -# Replace the vulnerable npm 10.x bundled with Node 22.23.1 with npm 11.18.0. +# Replace the vulnerable npm 10.x bundled with Node 22.23.2 with npm 11.18.0. # npm 11.18.0 bundles: tar 7.5.19 (fixes GHSA-23hp-3jrh-7fpw/GHSA-8x88-c5mf-7j5w), # sigstore 4.1.1 (fixes GHSA-52v5-jr5w-gjxr), brace-expansion 5.0.7 and # picomatch 4.0.4 (via tinyglobby, fixes GHSA-3jxr-9vmj-r5cp / GHSA-c2c7-rcm5-vvqj). @@ -52,7 +52,7 @@ RUN set -eux; \ /usr/local/lib/node_modules/npm/bin/npx-cli.js; \ ln -sf /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm; \ ln -sf /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx; \ - node --version | grep -qE '^v22\.23\.1' || (echo "ERROR: expected Node.js v22.23.1" && exit 1); \ + node --version | grep -qE '^v22\.23\.2' || (echo "ERROR: expected Node.js v22.23.2" && exit 1); \ npm --version | grep -qE '^11\.18\.0' || (echo "ERROR: expected npm 11.18.0" && exit 1); \ rm -f /tmp/npm-11.18.0.tgz diff --git a/docs-site/package-lock.json b/docs-site/package-lock.json index d47f66c24..9f3e0c4b3 100644 --- a/docs-site/package-lock.json +++ b/docs-site/package-lock.json @@ -5120,9 +5120,9 @@ } }, "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "funding": [ { "type": "github", diff --git a/package-lock.json b/package-lock.json index faac1934d..36e4a480b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2899,30 +2899,6 @@ "node": ">=8" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", - "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/@istanbuljs/schema": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", @@ -5463,20 +5439,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/esquery": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", @@ -8842,13 +8804,6 @@ "source-map": "^0.6.0" } }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true, - "license": "BSD-3-Clause" - }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", diff --git a/package.json b/package.json index e83e02a73..d022dccdd 100644 --- a/package.json +++ b/package.json @@ -82,9 +82,7 @@ "test-exclude": "^7.0.1", "minimatch": ">=10.2.1", "brace-expansion": ">=5.0.7", - "markdownlint-cli2": { - "js-yaml": "^4.3.1" - } + "js-yaml": "$js-yaml" }, "engines": { "node": ">=20.19.0"