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
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions .grype.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
22 changes: 11 additions & 11 deletions containers/agent/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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; \
Expand Down Expand Up @@ -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" && \
Expand All @@ -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
Expand Down
6 changes: 3 additions & 3 deletions containers/api-proxy/Dockerfile
Original file line number Diff line number Diff line change
@@ -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).
Expand All @@ -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

Expand Down
39 changes: 35 additions & 4 deletions containers/api-proxy/guards/ai-credits-guard.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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']) {
Expand All @@ -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' };
}
Expand Down Expand Up @@ -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),
});
Expand All @@ -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.
Expand Down Expand Up @@ -374,6 +404,7 @@ module.exports = {
getAiCreditsBlockState,
buildAiCreditsLimitError,
checkUnknownModelRejection,
isModelPriceable,
canonicalizeModel,
resetAiCreditsGuardForTests,
};
35 changes: 35 additions & 0 deletions containers/api-proxy/guards/ai-credits-guard.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const {
getAiCreditsBlockState,
buildAiCreditsLimitError,
checkUnknownModelRejection,
isModelPriceable,
canonicalizeModel,
resetAiCreditsGuardForTests,
} = require('./ai-credits-guard');
Expand Down Expand Up @@ -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);
});
});

});
21 changes: 20 additions & 1 deletion containers/api-proxy/model-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 {
Expand All @@ -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) {
Expand Down
28 changes: 27 additions & 1 deletion containers/api-proxy/model-fallback.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}

Expand Down Expand Up @@ -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;

Expand All @@ -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,
},
};
Expand Down
Loading
Loading