From c09fb60972f6f5059e4173913a662edabc7eddcc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Jun 2026 14:38:14 +0000 Subject: [PATCH 1/4] Initial plan From e47105c034522b135366c06c2073e8909a0c4ebf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Jun 2026 14:48:27 +0000 Subject: [PATCH 2/4] feat: add max-permission-denied guard for repeated auth failures --- .../guards/max-permission-denied-guard.js | 93 +++++++++++ .../max-permission-denied-guard.test.js | 156 ++++++++++++++++++ containers/api-proxy/management.js | 3 + containers/api-proxy/proxy-request.js | 30 ++++ containers/api-proxy/server.js | 2 + .../api-proxy/server.token-guards.test.js | 118 ++++++++++++- containers/api-proxy/upstream-response.js | 2 + containers/api-proxy/websocket-proxy.js | 16 ++ 8 files changed, 419 insertions(+), 1 deletion(-) create mode 100644 containers/api-proxy/guards/max-permission-denied-guard.js create mode 100644 containers/api-proxy/guards/max-permission-denied-guard.test.js diff --git a/containers/api-proxy/guards/max-permission-denied-guard.js b/containers/api-proxy/guards/max-permission-denied-guard.js new file mode 100644 index 000000000..4c5e6c062 --- /dev/null +++ b/containers/api-proxy/guards/max-permission-denied-guard.js @@ -0,0 +1,93 @@ +'use strict'; + +const { parsePositiveInteger } = require('./guard-utils'); + +let permDeniedGuardState = { + configKey: null, + deniedCount: 0, +}; + +const permDeniedConfigCache = { + rawMax: undefined, + parsed: null, +}; + +function getPermDeniedConfig() { + const rawMax = process.env.AWF_MAX_PERMISSION_DENIED; + if (permDeniedConfigCache.rawMax === rawMax) { + return permDeniedConfigCache.parsed; + } + permDeniedConfigCache.rawMax = rawMax; + permDeniedConfigCache.parsed = parsePositiveInteger(rawMax); + return permDeniedConfigCache.parsed; +} + +function getPermDeniedState(max) { + if (!max) return null; + const configKey = String(max); + if (permDeniedGuardState.configKey !== configKey) { + permDeniedGuardState = { configKey, deniedCount: 0 }; + } + return permDeniedGuardState; +} + +function applyPermissionDenied() { + const max = getPermDeniedConfig(); + const state = getPermDeniedState(max); + if (!state) return; + state.deniedCount += 1; +} + +function getPermissionDeniedBlockState() { + const max = getPermDeniedConfig(); + const state = getPermDeniedState(max); + if (!state) return null; + return { + maxPermissionDenied: max, + deniedCount: state.deniedCount, + maxExceeded: state.deniedCount >= max, + }; +} + +function getPermissionDeniedReflectState() { + const max = getPermDeniedConfig(); + const state = getPermDeniedState(max); + if (!state) { + return { + enabled: false, + max_permission_denied: null, + denied_count: 0, + }; + } + return { + enabled: true, + max_permission_denied: max, + denied_count: state.deniedCount, + }; +} + +function resetPermissionDeniedGuardForTests() { + permDeniedGuardState = { configKey: null, deniedCount: 0 }; + permDeniedConfigCache.rawMax = undefined; + permDeniedConfigCache.parsed = null; +} + +function buildPermissionDeniedLimitError(state) { + return { + error: { + type: 'permission_denied_limit_exceeded', + message: `Permission denied limit exceeded (${state.deniedCount} / ${state.maxPermissionDenied}). ` + + 'The run has been stopped due to repeated permission errors — check that all API keys and tokens are correctly configured.', + denied_count: state.deniedCount, + max_permission_denied: state.maxPermissionDenied, + }, + }; +} + +module.exports = { + applyPermissionDenied, + getPermissionDeniedBlockState, + getPermissionDeniedReflectState, + resetPermissionDeniedGuardForTests, + buildPermissionDeniedLimitError, +}; diff --git a/containers/api-proxy/guards/max-permission-denied-guard.test.js b/containers/api-proxy/guards/max-permission-denied-guard.test.js new file mode 100644 index 000000000..4bbe6bfb8 --- /dev/null +++ b/containers/api-proxy/guards/max-permission-denied-guard.test.js @@ -0,0 +1,156 @@ +'use strict'; + +const { + applyPermissionDenied, + getPermissionDeniedBlockState, + getPermissionDeniedReflectState, + resetPermissionDeniedGuardForTests, + buildPermissionDeniedLimitError, +} = require('./max-permission-denied-guard'); + +describe('max-permission-denied-guard', () => { + beforeEach(() => { + delete process.env.AWF_MAX_PERMISSION_DENIED; + resetPermissionDeniedGuardForTests(); + }); + + afterEach(() => { + delete process.env.AWF_MAX_PERMISSION_DENIED; + resetPermissionDeniedGuardForTests(); + }); + + describe('when AWF_MAX_PERMISSION_DENIED is not configured', () => { + it('applyPermissionDenied does nothing', () => { + applyPermissionDenied(); + expect(getPermissionDeniedBlockState()).toBeNull(); + }); + + it('getPermissionDeniedBlockState returns null', () => { + expect(getPermissionDeniedBlockState()).toBeNull(); + }); + + it('getPermissionDeniedReflectState returns disabled state', () => { + expect(getPermissionDeniedReflectState()).toEqual({ + enabled: false, + max_permission_denied: null, + denied_count: 0, + }); + }); + }); + + describe('when AWF_MAX_PERMISSION_DENIED is configured', () => { + beforeEach(() => { + process.env.AWF_MAX_PERMISSION_DENIED = '3'; + resetPermissionDeniedGuardForTests(); + }); + + it('starts with denied_count of 0 and maxExceeded false', () => { + const state = getPermissionDeniedBlockState(); + expect(state).toEqual({ + maxPermissionDenied: 3, + deniedCount: 0, + maxExceeded: false, + }); + }); + + it('increments denied count on each applyPermissionDenied call', () => { + applyPermissionDenied(); + applyPermissionDenied(); + const state = getPermissionDeniedBlockState(); + expect(state.deniedCount).toBe(2); + expect(state.maxExceeded).toBe(false); + }); + + it('sets maxExceeded true when denied count reaches the max', () => { + applyPermissionDenied(); + applyPermissionDenied(); + applyPermissionDenied(); + const state = getPermissionDeniedBlockState(); + expect(state.deniedCount).toBe(3); + expect(state.maxExceeded).toBe(true); + }); + + it('remains exceeded after further denials beyond the limit', () => { + for (let i = 0; i < 5; i++) applyPermissionDenied(); + const state = getPermissionDeniedBlockState(); + expect(state.deniedCount).toBe(5); + expect(state.maxExceeded).toBe(true); + }); + + it('returns enabled reflect state with running count', () => { + applyPermissionDenied(); + expect(getPermissionDeniedReflectState()).toEqual({ + enabled: true, + max_permission_denied: 3, + denied_count: 1, + }); + }); + + it('resets state correctly between tests', () => { + applyPermissionDenied(); + resetPermissionDeniedGuardForTests(); + const state = getPermissionDeniedBlockState(); + expect(state.deniedCount).toBe(0); + expect(state.maxExceeded).toBe(false); + }); + }); + + describe('when AWF_MAX_PERMISSION_DENIED is invalid', () => { + it('treats zero as unconfigured', () => { + process.env.AWF_MAX_PERMISSION_DENIED = '0'; + resetPermissionDeniedGuardForTests(); + expect(getPermissionDeniedBlockState()).toBeNull(); + }); + + it('treats non-numeric value as unconfigured', () => { + process.env.AWF_MAX_PERMISSION_DENIED = 'abc'; + resetPermissionDeniedGuardForTests(); + expect(getPermissionDeniedBlockState()).toBeNull(); + }); + + it('treats negative value as unconfigured', () => { + process.env.AWF_MAX_PERMISSION_DENIED = '-1'; + resetPermissionDeniedGuardForTests(); + expect(getPermissionDeniedBlockState()).toBeNull(); + }); + }); + + describe('buildPermissionDeniedLimitError', () => { + it('builds a structured error payload', () => { + const state = { deniedCount: 3, maxPermissionDenied: 3 }; + const error = buildPermissionDeniedLimitError(state); + expect(error).toEqual({ + error: { + type: 'permission_denied_limit_exceeded', + message: expect.stringContaining('3 / 3'), + denied_count: 3, + max_permission_denied: 3, + }, + }); + }); + + it('includes investigation hint in the message', () => { + const state = { deniedCount: 2, maxPermissionDenied: 2 }; + const error = buildPermissionDeniedLimitError(state); + expect(error.error.message).toMatch(/check/i); + }); + }); + + describe('config cache invalidation', () => { + it('picks up a new AWF_MAX_PERMISSION_DENIED value at runtime', () => { + process.env.AWF_MAX_PERMISSION_DENIED = '2'; + resetPermissionDeniedGuardForTests(); + + applyPermissionDenied(); + applyPermissionDenied(); + expect(getPermissionDeniedBlockState().maxExceeded).toBe(true); + + // Raise the limit while the process is running + process.env.AWF_MAX_PERMISSION_DENIED = '5'; + const state = getPermissionDeniedBlockState(); + expect(state.deniedCount).toBe(0); + expect(state.maxPermissionDenied).toBe(5); + expect(state.maxExceeded).toBe(false); + }); + }); +}); diff --git a/containers/api-proxy/management.js b/containers/api-proxy/management.js index 8ca886426..7078c6f13 100644 --- a/containers/api-proxy/management.js +++ b/containers/api-proxy/management.js @@ -29,6 +29,7 @@ const metrics = require('./metrics'); * @property {() => Record} getEffectiveModelFallback - Returns provider-effective fallback summary * @property {() => object} getEffectiveTokenUsage - Returns effective token usage summary * @property {() => object} getMaxRunsUsage - Returns max-runs usage summary + * @property {() => object} getPermissionDeniedUsage - Returns permission-denied usage summary */ /** @@ -52,6 +53,7 @@ function createManagementHandlers(deps) { getEffectiveModelFallback, getEffectiveTokenUsage, getMaxRunsUsage, + getPermissionDeniedUsage, } = deps; /** @@ -103,6 +105,7 @@ function createManagementHandlers(deps) { model_fallback_effective: getEffectiveModelFallback(), effective_tokens: getEffectiveTokenUsage(), runs: getMaxRunsUsage(), + permission_denied: getPermissionDeniedUsage(), }; } diff --git a/containers/api-proxy/proxy-request.js b/containers/api-proxy/proxy-request.js index 8a1fa38f3..8a0898536 100644 --- a/containers/api-proxy/proxy-request.js +++ b/containers/api-proxy/proxy-request.js @@ -39,6 +39,13 @@ const { resetMaxRunsGuardForTests, buildMaxRunsExceededError, } = require('./guards/max-runs-guard'); +const { + applyPermissionDenied, + getPermissionDeniedBlockState, + getPermissionDeniedReflectState, + resetPermissionDeniedGuardForTests, + buildPermissionDeniedLimitError, +} = require('./guards/max-permission-denied-guard'); const { getAndClearPendingTimeoutSteeringMessage, resetTimeoutSteeringForTests, @@ -179,6 +186,8 @@ const proxyWebSocket = createProxyWebSocket({ buildEffectiveTokenLimitError, getMaxRunsBlockState, buildMaxRunsExceededError, + getPermissionDeniedBlockState, + buildPermissionDeniedLimitError, trackWebSocketTokenUsage, applyEffectiveTokenUsage, }); @@ -247,6 +256,7 @@ const { handleUpstreamResponse } = createUpstreamResponseHandlers({ trackTokenUsage, applyEffectiveTokenUsage, applyMaxRunsInvocation, + applyPermissionDenied, extractBillingHeaders, parseDeprecatedHeaderFromBody, learnAndStripDeprecatedHeaderValue, @@ -504,6 +514,24 @@ function proxyRequest(req, res, targetHost, injectHeaders, provider, basePath = return; } + const pdBlock = getPermissionDeniedBlockState(); + if (pdBlock && pdBlock.maxExceeded) { + const duration = Date.now() - startTime; + metrics.gaugeDec('active_requests', { provider }); + metrics.increment('requests_total', { provider, method: req.method, status_class: '4xx' }); + metrics.observe('request_duration_ms', duration, { provider }); + logRequest('warn', 'permission_denied_limit_exceeded', { + request_id: requestId, + provider, + denied_count: pdBlock.deniedCount, + max_permission_denied: pdBlock.maxPermissionDenied, + }); + otel.endSpan(span, 403); + res.writeHead(403, { 'Content-Type': 'application/json', 'X-Request-ID': requestId }); + res.end(JSON.stringify(buildPermissionDeniedLimitError(pdBlock))); + return; + } + sendUpstreamRequest(headers, { body, targetHost, upstreamPath, req, res, provider, requestId, startTime, span, requestBytes, }); @@ -521,8 +549,10 @@ module.exports = { HTTPS_PROXY, getEffectiveTokenReflectState, getMaxRunsReflectState, + getPermissionDeniedReflectState, resetEffectiveTokenGuardForTests, resetMaxRunsGuardForTests, + resetPermissionDeniedGuardForTests, resetTimeoutSteeringForTests, resetAnthropicDeprecatedBetaHeadersForTests: resetDeprecatedHeaderValuesForTests, getAndClearPendingSteeringMessage, diff --git a/containers/api-proxy/server.js b/containers/api-proxy/server.js index f5c161e54..850db624f 100644 --- a/containers/api-proxy/server.js +++ b/containers/api-proxy/server.js @@ -49,6 +49,7 @@ const { extractBillingHeaders, getEffectiveTokenReflectState, getMaxRunsReflectState, + getPermissionDeniedReflectState, } = require('./proxy-request'); const { @@ -125,6 +126,7 @@ const { healthResponse, reflectEndpoints, handleManagementEndpoint } = createMan getEffectiveModelFallback: () => getEffectiveModelFallbackForReflect(registeredAdapters), getEffectiveTokenUsage: () => getEffectiveTokenReflectState(), getMaxRunsUsage: () => getMaxRunsReflectState(), + getPermissionDeniedUsage: () => getPermissionDeniedReflectState(), }); function buildModelsJson() { diff --git a/containers/api-proxy/server.token-guards.test.js b/containers/api-proxy/server.token-guards.test.js index 46e40f434..8998a1602 100644 --- a/containers/api-proxy/server.token-guards.test.js +++ b/containers/api-proxy/server.token-guards.test.js @@ -16,14 +16,16 @@ const { let proxyRequest; let resetEffectiveTokenGuardForTests; let resetMaxRunsGuardForTests; +let resetPermissionDeniedGuardForTests; setupServerTestEnv(() => { ({ proxyRequest } = require('./server')); ({ resetEffectiveTokenGuardForTests, resetMaxRunsGuardForTests, + resetPermissionDeniedGuardForTests, } = require('./proxy-request')); - return { proxyRequest, resetEffectiveTokenGuardForTests, resetMaxRunsGuardForTests }; + return { proxyRequest, resetEffectiveTokenGuardForTests, resetMaxRunsGuardForTests, resetPermissionDeniedGuardForTests }; }); describe('proxyRequest effective token guard', () => { @@ -170,3 +172,117 @@ describe('proxyRequest max-runs guard', () => { expect(res.writeHead).not.toHaveBeenCalledWith(429, expect.anything()); }); }); + +describe('proxyRequest permission-denied guard', () => { + function makeReq(headers = {}) { + return makeReqFactory('/v1/chat/completions', headers); + } + + beforeEach(() => { + process.env.AWF_MAX_PERMISSION_DENIED = '1'; + resetPermissionDeniedGuardForTests(); + }); + + afterEach(() => { + delete process.env.AWF_MAX_PERMISSION_DENIED; + resetPermissionDeniedGuardForTests(); + jest.restoreAllMocks(); + }); + + it('returns 403 with structured payload when permission denied limit is exceeded', () => { + let responseHandler; + const upstreamRequest = new EventEmitter(); + upstreamRequest.end = jest.fn(); + upstreamRequest.write = jest.fn(); + upstreamRequest.destroy = jest.fn(); + + const httpsRequestSpy = jest.spyOn(https, 'request').mockImplementation((options, cb) => { + responseHandler = cb; + return upstreamRequest; + }); + + // First request returns 403 from upstream — triggers the permission denied counter + const req1 = makeReq(); + const res1 = makeRes(); + proxyRequest(req1, res1, 'api.openai.com', { Authorization: '******' }, 'openai'); + req1.emit('end'); + + const proxyRes = new EventEmitter(); + proxyRes.statusCode = 403; + proxyRes.headers = { 'content-type': 'application/json' }; + proxyRes.pipe = jest.fn(); + + responseHandler(proxyRes); + proxyRes.emit('end'); + + // Second request — permission denied limit is now exceeded + const req2 = makeReq(); + const res2 = makeRes(); + proxyRequest(req2, res2, 'api.openai.com', { Authorization: '******' }, 'openai'); + req2.emit('end'); + + expect(httpsRequestSpy).toHaveBeenCalledTimes(1); + expect(res2.writeHead).toHaveBeenCalledWith(403, expect.objectContaining({ + 'Content-Type': 'application/json', + })); + const payload = JSON.parse(res2.end.mock.calls[0][0]); + expect(payload.error.type).toBe('permission_denied_limit_exceeded'); + expect(payload.error.max_permission_denied).toBe(1); + expect(payload.error.denied_count).toBe(1); + }); + + it('also triggers on 401 upstream responses', () => { + let responseHandler; + const upstreamRequest = new EventEmitter(); + upstreamRequest.end = jest.fn(); + upstreamRequest.write = jest.fn(); + upstreamRequest.destroy = jest.fn(); + + const httpsRequestSpy = jest.spyOn(https, 'request').mockImplementation((options, cb) => { + responseHandler = cb; + return upstreamRequest; + }); + + const req1 = makeReq(); + const res1 = makeRes(); + proxyRequest(req1, res1, 'api.openai.com', { Authorization: '******' }, 'openai'); + req1.emit('end'); + + const proxyRes = new EventEmitter(); + proxyRes.statusCode = 401; + proxyRes.headers = { 'content-type': 'application/json' }; + proxyRes.pipe = jest.fn(); + + responseHandler(proxyRes); + proxyRes.emit('end'); + + const req2 = makeReq(); + const res2 = makeRes(); + proxyRequest(req2, res2, 'api.openai.com', { Authorization: '******' }, 'openai'); + req2.emit('end'); + + expect(httpsRequestSpy).toHaveBeenCalledTimes(1); + const payload = JSON.parse(res2.end.mock.calls[0][0]); + expect(payload.error.type).toBe('permission_denied_limit_exceeded'); + }); + + it('allows requests when permission denied limit is not configured', () => { + delete process.env.AWF_MAX_PERMISSION_DENIED; + resetPermissionDeniedGuardForTests(); + + const upstreamRequest = new EventEmitter(); + upstreamRequest.end = jest.fn(); + upstreamRequest.write = jest.fn(); + upstreamRequest.destroy = jest.fn(); + + const httpsRequestSpy = jest.spyOn(https, 'request').mockImplementation(() => upstreamRequest); + + const req = makeReq(); + const res = makeRes(); + proxyRequest(req, res, 'api.openai.com', { Authorization: '******' }, 'openai'); + req.emit('end'); + + expect(httpsRequestSpy).toHaveBeenCalledTimes(1); + expect(res.writeHead).not.toHaveBeenCalledWith(403, expect.anything()); + }); +}); diff --git a/containers/api-proxy/upstream-response.js b/containers/api-proxy/upstream-response.js index 85c2e4e44..b03e84e18 100644 --- a/containers/api-proxy/upstream-response.js +++ b/containers/api-proxy/upstream-response.js @@ -30,6 +30,7 @@ function createUpstreamResponseHandlers({ trackTokenUsage, applyEffectiveTokenUsage, applyMaxRunsInvocation, + applyPermissionDenied, extractBillingHeaders, parseDeprecatedHeaderFromBody, learnAndStripDeprecatedHeaderValue, @@ -59,6 +60,7 @@ function createUpstreamResponseHandlers({ function logUpstreamAuthError(statusCode, { requestId, provider, targetHost, req, responseBody }) { if (statusCode === 401 || statusCode === 403) { + applyPermissionDenied(); logRequest('warn', 'upstream_auth_error', { request_id: requestId, provider, status: statusCode, upstream_host: targetHost, path: sanitizeForLog(req.url), diff --git a/containers/api-proxy/websocket-proxy.js b/containers/api-proxy/websocket-proxy.js index c92187a85..5aad74a14 100644 --- a/containers/api-proxy/websocket-proxy.js +++ b/containers/api-proxy/websocket-proxy.js @@ -18,6 +18,8 @@ function createProxyWebSocket({ buildEffectiveTokenLimitError, getMaxRunsBlockState, buildMaxRunsExceededError, + getPermissionDeniedBlockState, + buildPermissionDeniedLimitError, trackWebSocketTokenUsage, applyEffectiveTokenUsage, }) { @@ -89,6 +91,20 @@ function createProxyWebSocket({ return; } + const pdBlock = getPermissionDeniedBlockState(); + if (pdBlock && pdBlock.maxExceeded) { + logRequest('warn', 'permission_denied_limit_exceeded', { + request_id: requestId, + provider, + denied_count: pdBlock.deniedCount, + max_permission_denied: pdBlock.maxPermissionDenied, + }); + socket.write('HTTP/1.1 403 Forbidden\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n'); + socket.write(JSON.stringify(buildPermissionDeniedLimitError(pdBlock))); + socket.destroy(); + return; + } + const rateCheck = limiter.check(provider, 0); if (!rateCheck.allowed) { metrics.increment('rate_limit_rejected_total', { provider, limit_type: rateCheck.limitType }); From 7f203adbdb6970e93da38116879edceedffd941b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Jun 2026 16:50:48 +0000 Subject: [PATCH 3/4] feat: wire maxPermissionDenied through stdin config, schema, and docs --- docs/api-proxy-sidecar.md | 71 +++++++++++++++++++ docs/awf-config.schema.json | 5 ++ src/awf-config-schema.json | 5 ++ src/commands/build-config.test.ts | 1 + src/commands/build-config.ts | 3 + src/commands/validate-options.test.ts | 12 ++++ .../validators/config-assembly.test.ts | 1 + src/commands/validators/config-assembly.ts | 1 + src/commands/validators/log-and-limits.ts | 14 ++++ src/config-file-mapping.test.ts | 10 +++ src/config-file.ts | 2 + .../api-proxy-service-rate-limit.test.ts | 20 ++++++ src/services/api-proxy-service.ts | 3 + src/types/rate-limit-options.ts | 11 +++ 14 files changed, 159 insertions(+) diff --git a/docs/api-proxy-sidecar.md b/docs/api-proxy-sidecar.md index c3970730d..2e74f689e 100644 --- a/docs/api-proxy-sidecar.md +++ b/docs/api-proxy-sidecar.md @@ -1053,6 +1053,77 @@ if (response.status === 429) { } ``` +## Max-permission-denied limit + +The API proxy can enforce a **maximum number of upstream permission-denied (401/403) responses** per run. When enabled, each upstream 401 or 403 response increments a counter, and all further requests are rejected once the threshold is reached. This stops the run early when API credentials are misconfigured or missing, preventing the agent from burning tokens retrying a broken setup. + +### Configuration + +Set in the AWF config file (not available as a CLI flag): + +```json +{ + "apiProxy": { + "maxPermissionDenied": 3 + } +} +``` + +When `maxPermissionDenied` is not set, the guard is disabled and permission errors are not counted. + +### Enforcement + +Before forwarding each request to the upstream provider, the proxy checks the permission-denied counter: + +- **Under limit**: Request is forwarded normally. +- **Limit reached or exceeded**: Request is rejected immediately with: + - **HTTP `403 Forbidden`** + - **Error body**: + + ```json + { + "error": { + "type": "permission_denied_limit_exceeded", + "message": "Permission denied limit exceeded (3 / 3). The run has been stopped due to repeated permission errors — check that all API keys and tokens are correctly configured.", + "denied_count": 3, + "max_permission_denied": 3 + } + } + ``` + +WebSocket upgrade requests are also rejected with `403` when the limit is reached. + +:::caution +Once the limit is reached, **all subsequent requests in the run are rejected**. Check that all provider API keys and tokens are correctly configured before increasing this limit. +::: + +### Introspection + +The `/reflect` endpoint exposes the current permission-denied guard state under the `permission_denied` key: + +```json +{ + "permission_denied": { + "enabled": true, + "max_permission_denied": 3, + "denied_count": 1 + } +} +``` + +When `maxPermissionDenied` is not configured, `enabled` is `false` and `max_permission_denied` is `null`. + +### Detecting the limit + +```javascript +if (response.status === 403) { + const body = await response.json(); + if (body.error?.type === 'permission_denied_limit_exceeded') { + console.log(`Permission denied limit exceeded: ${body.error.denied_count} / ${body.error.max_permission_denied}`); + } +} +``` + ## OpenTelemetry distributed tracing The api-proxy sidecar emits one [OpenTelemetry](https://opentelemetry.io/) CLIENT span per diff --git a/docs/awf-config.schema.json b/docs/awf-config.schema.json index 131262aa8..6331c0b6c 100644 --- a/docs/awf-config.schema.json +++ b/docs/awf-config.schema.json @@ -90,6 +90,11 @@ "minimum": 1, "description": "Maximum number of LLM invocations allowed for a run. When reached, the API proxy rejects subsequent requests with HTTP 429 and error type 'max_runs_exceeded'. See spec §11." }, + "maxPermissionDenied": { + "type": "integer", + "minimum": 1, + "description": "Maximum number of upstream permission-denied (401/403) responses allowed per run. When reached, the API proxy rejects all subsequent requests with HTTP 403 and error type 'permission_denied_limit_exceeded', stopping the run to avoid wasting tokens on misconfigured or missing API credentials. When unset, the guard is disabled." + }, "requestedModel": { "type": "string", "description": "Expected model name for pre-startup validation. When set, the API proxy validates at startup that this model is available in at least one provider's model catalogue. Emits a clear diagnostic if the model is retired, restricted, or misspelled. Does not block startup." diff --git a/src/awf-config-schema.json b/src/awf-config-schema.json index 131262aa8..6331c0b6c 100644 --- a/src/awf-config-schema.json +++ b/src/awf-config-schema.json @@ -90,6 +90,11 @@ "minimum": 1, "description": "Maximum number of LLM invocations allowed for a run. When reached, the API proxy rejects subsequent requests with HTTP 429 and error type 'max_runs_exceeded'. See spec §11." }, + "maxPermissionDenied": { + "type": "integer", + "minimum": 1, + "description": "Maximum number of upstream permission-denied (401/403) responses allowed per run. When reached, the API proxy rejects all subsequent requests with HTTP 403 and error type 'permission_denied_limit_exceeded', stopping the run to avoid wasting tokens on misconfigured or missing API credentials. When unset, the guard is disabled." + }, "requestedModel": { "type": "string", "description": "Expected model name for pre-startup validation. When set, the API proxy validates at startup that this model is available in at least one provider's model catalogue. Emits a clear diagnostic if the model is retired, restricted, or misspelled. Does not block startup." diff --git a/src/commands/build-config.test.ts b/src/commands/build-config.test.ts index b044ff2d9..5b9bc46b6 100644 --- a/src/commands/build-config.test.ts +++ b/src/commands/build-config.test.ts @@ -47,6 +47,7 @@ function makeInputs(overrides: Partial[0]> = {}): effectiveTokenModelMultipliers: undefined, effectiveTokenDefaultModelMultiplier: undefined, maxRuns: undefined, + maxPermissionDenied: undefined, resolvedCopilotApiTarget: undefined, resolvedCopilotApiBasePath: undefined, dockerHostPathPrefix: undefined, diff --git a/src/commands/build-config.ts b/src/commands/build-config.ts index 77a7ff847..a6d171a99 100644 --- a/src/commands/build-config.ts +++ b/src/commands/build-config.ts @@ -26,6 +26,7 @@ interface BuildConfigInputs { effectiveTokenModelMultipliers: Record | undefined; effectiveTokenDefaultModelMultiplier: number | undefined; maxRuns: number | undefined; + maxPermissionDenied: number | undefined; resolvedCopilotApiTarget: string | undefined; resolvedCopilotApiBasePath: string | undefined; dockerHostPathPrefix: string | undefined; @@ -59,6 +60,7 @@ export function buildConfig(inputs: BuildConfigInputs): WrapperConfig { effectiveTokenModelMultipliers, effectiveTokenDefaultModelMultiplier, maxRuns, + maxPermissionDenied, resolvedCopilotApiTarget, resolvedCopilotApiBasePath, dockerHostPathPrefix, @@ -111,6 +113,7 @@ export function buildConfig(inputs: BuildConfigInputs): WrapperConfig { effectiveTokenModelMultipliers, effectiveTokenDefaultModelMultiplier, maxRuns, + maxPermissionDenied, enableTokenSteering: options.enableTokenSteering as boolean, debugTokens: (options.debugTokens as boolean | undefined) ?? (process.env.AWF_DEBUG_TOKENS === '1' ? true : undefined), tokenLogDir: (options.tokenLogDir as string | undefined) ?? (process.env.AWF_TOKEN_LOG_DIR?.trim() || undefined), diff --git a/src/commands/validate-options.test.ts b/src/commands/validate-options.test.ts index a1f93b095..4157558ec 100644 --- a/src/commands/validate-options.test.ts +++ b/src/commands/validate-options.test.ts @@ -72,6 +72,7 @@ const STUB_CONFIG = { effectiveTokenModelMultipliers: undefined, effectiveTokenDefaultModelMultiplier: undefined, maxRuns: undefined, + maxPermissionDenied: undefined, enableTokenSteering: false, openaiApiKey: undefined, anthropicApiKey: undefined, @@ -233,6 +234,17 @@ describe('validateOptions', () => { }); }); + describe('maxPermissionDenied validation', () => { + it('exits when maxPermissionDenied is not a positive integer', () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); + expect(() => + validateOptions({ logLevel: 'info', maxPermissionDenied: 'not-a-number' }, 'echo hi'), + ).toThrow('process.exit called'); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid maxPermissionDenied')); + consoleSpy.mockRestore(); + }); + }); + describe('parseModelMultipliersCli error', () => { it('exits when --max-model-multiplier is malformed', () => { mockedOptionParsers.parseModelMultipliersCli.mockReturnValue({ error: 'bad format' }); diff --git a/src/commands/validators/config-assembly.test.ts b/src/commands/validators/config-assembly.test.ts index 865e30399..858f050d3 100644 --- a/src/commands/validators/config-assembly.test.ts +++ b/src/commands/validators/config-assembly.test.ts @@ -90,6 +90,7 @@ describe('config-assembly', () => { effectiveTokenModelMultipliers: {}, effectiveTokenDefaultModelMultiplier: undefined, maxRuns: undefined, + maxPermissionDenied: undefined, }); const createMinimalNetworkOptions = (): NetworkOptionsResult => ({ diff --git a/src/commands/validators/config-assembly.ts b/src/commands/validators/config-assembly.ts index c8c73ed58..48d34cb67 100644 --- a/src/commands/validators/config-assembly.ts +++ b/src/commands/validators/config-assembly.ts @@ -65,6 +65,7 @@ export function assembleAndValidateConfig( effectiveTokenModelMultipliers: logAndLimits.effectiveTokenModelMultipliers, effectiveTokenDefaultModelMultiplier: logAndLimits.effectiveTokenDefaultModelMultiplier, maxRuns: logAndLimits.maxRuns, + maxPermissionDenied: logAndLimits.maxPermissionDenied, resolvedCopilotApiTarget: networkOptions.resolvedCopilotApiTarget, resolvedCopilotApiBasePath: networkOptions.resolvedCopilotApiBasePath, dockerHostPathPrefix: networkOptions.dockerHostPathPrefixResolution.dockerHostPathPrefix, diff --git a/src/commands/validators/log-and-limits.ts b/src/commands/validators/log-and-limits.ts index b8be54426..9f16e8c25 100644 --- a/src/commands/validators/log-and-limits.ts +++ b/src/commands/validators/log-and-limits.ts @@ -19,6 +19,7 @@ export interface LogAndLimitsResult { effectiveTokenModelMultipliers: Record | undefined; effectiveTokenDefaultModelMultiplier: number | undefined; maxRuns: number | undefined; + maxPermissionDenied: number | undefined; memoryLimit: string | undefined; agentImage: string | undefined; } @@ -115,6 +116,18 @@ export function validateLogAndLimits(options: Record): LogAndLi process.exit(1); } + const maxPermissionDeniedOption = (options as Record).maxPermissionDenied as + | string + | number + | undefined; + const maxPermissionDenied = + maxPermissionDeniedOption !== undefined ? Number(maxPermissionDeniedOption) : undefined; + + if (maxPermissionDenied !== undefined && (!Number.isInteger(maxPermissionDenied) || maxPermissionDenied <= 0)) { + console.error('Error: Invalid maxPermissionDenied value (must be a positive integer)'); + process.exit(1); + } + logger.setLevel(logLevel); // --- Resource limits ----------------------------------------------------- @@ -146,6 +159,7 @@ export function validateLogAndLimits(options: Record): LogAndLi effectiveTokenModelMultipliers, effectiveTokenDefaultModelMultiplier, maxRuns, + maxPermissionDenied, memoryLimit: memoryLimit.value, agentImage: agentImageResult.agentImage, }; diff --git a/src/config-file-mapping.test.ts b/src/config-file-mapping.test.ts index 981c82b65..08e3f4ce8 100644 --- a/src/config-file-mapping.test.ts +++ b/src/config-file-mapping.test.ts @@ -156,6 +156,11 @@ describe('mapAwfFileConfigToCliOptions', () => { expect(result.maxRuns).toBe(42); }); + it('maps maxPermissionDenied field', () => { + const result = mapAwfFileConfigToCliOptions({ apiProxy: { maxPermissionDenied: 3 } }); + expect(result.maxPermissionDenied).toBe(3); + }); + it('maps requestedModel field', () => { const result = mapAwfFileConfigToCliOptions({ apiProxy: { requestedModel: 'gpt-4o' } }); expect(result.requestedModel).toBe('gpt-4o'); @@ -186,6 +191,11 @@ describe('mapAwfFileConfigToCliOptions', () => { expect(result.maxRuns).toBeUndefined(); }); + it('leaves maxPermissionDenied undefined when not set', () => { + const result = mapAwfFileConfigToCliOptions({}); + expect(result.maxPermissionDenied).toBeUndefined(); + }); + it('leaves anthropicAutoCache and anthropicCacheTailTtl undefined when not set', () => { const result = mapAwfFileConfigToCliOptions({}); expect(result.anthropicAutoCache).toBeUndefined(); diff --git a/src/config-file.ts b/src/config-file.ts index 7d881d6b7..c885485a7 100644 --- a/src/config-file.ts +++ b/src/config-file.ts @@ -21,6 +21,7 @@ interface AwfFileConfig { modelMultipliers?: Record; defaultModelMultiplier?: number; maxRuns?: number; + maxPermissionDenied?: number; requestedModel?: string; modelFallback?: { enabled?: boolean; @@ -186,6 +187,7 @@ export function mapAwfFileConfigToCliOptions(config: AwfFileConfig): Record { expect(env.AWF_MAX_RUNS).toBeUndefined(); }); + it('should set AWF_MAX_PERMISSION_DENIED in api-proxy when maxPermissionDenied is configured', () => { + const configWithMaxPermDenied = { + ...mockConfig, + enableApiProxy: true, + openaiApiKey: 'sk-test-key', + maxPermissionDenied: 3, + }; + const result = generateDockerCompose(configWithMaxPermDenied, mockNetworkConfigWithProxy); + const proxy = result.services['api-proxy']; + const env = proxy.environment as Record; + expect(env.AWF_MAX_PERMISSION_DENIED).toBe('3'); + }); + + it('should not set AWF_MAX_PERMISSION_DENIED in api-proxy when maxPermissionDenied is not configured', () => { + const result = generateDockerCompose({ ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key' }, mockNetworkConfigWithProxy); + const proxy = result.services['api-proxy']; + const env = proxy.environment as Record; + expect(env.AWF_MAX_PERMISSION_DENIED).toBeUndefined(); + }); + it('should set AWF_AGENT_TIMEOUT_MINUTES in api-proxy when agentTimeout is configured', () => { const configWithAgentTimeout = { ...mockConfig, diff --git a/src/services/api-proxy-service.ts b/src/services/api-proxy-service.ts index de71c5641..2cb87eeee 100644 --- a/src/services/api-proxy-service.ts +++ b/src/services/api-proxy-service.ts @@ -174,6 +174,9 @@ export function buildApiProxyService(params: ApiProxyServiceParams): ApiProxyBui ...(config.maxRuns !== undefined && { AWF_MAX_RUNS: String(config.maxRuns), }), + ...(config.maxPermissionDenied !== undefined && { + AWF_MAX_PERMISSION_DENIED: String(config.maxPermissionDenied), + }), ...(config.agentTimeout !== undefined && { AWF_AGENT_TIMEOUT_MINUTES: String(config.agentTimeout), }), diff --git a/src/types/rate-limit-options.ts b/src/types/rate-limit-options.ts index 0821f25b4..8c34d3058 100644 --- a/src/types/rate-limit-options.ts +++ b/src/types/rate-limit-options.ts @@ -48,6 +48,17 @@ export interface RateLimitOptions { */ maxRuns?: number; + /** + * Maximum number of upstream permission-denied (401/403) responses allowed + * for the current AWF run. + * + * When set, the API proxy counts upstream 401/403 responses and rejects + * further requests once this threshold is reached, stopping the run early + * to avoid wasting tokens on misconfigured or missing API credentials. + * When unset, the guard is disabled and permission errors are not counted. + */ + maxPermissionDenied?: number; + /** * Enable effective token budget steering warnings in the API proxy * From 6d4b32584c11870559f328b90e81173933331317 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Jun 2026 18:25:16 +0000 Subject: [PATCH 4/4] fix: exclude sei.cmu.edu from lychee link checker (flaky timeout) --- .github/lychee.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/lychee.toml b/.github/lychee.toml index cadd937f1..6515d634b 100644 --- a/.github/lychee.toml +++ b/.github/lychee.toml @@ -18,6 +18,7 @@ exclude = [ "^https://mcp\\.tavily\\.com", "^https://docs\\.sigstore\\.dev/cosign/installation", "^https://docs\\.github\\.com/en/github/site-policy/github-bug-bounty", + "^https://www\\.sei\\.cmu\\.edu/", # LOGGING.md is referenced but doesn't exist yet (planned doc) "LOGGING\\.md", ]