From 4b98c6c091a306116ae96d76477265b2de0326fa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 24 Sep 2026 01:23:09 +0000 Subject: [PATCH 1/6] Initial plan From b4212cc874e2c7c9fa339be7faf7502146648502 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 24 Sep 2026 01:29:45 +0000 Subject: [PATCH 2/6] feat: enforce Codex hosted web domain policy --- containers/api-proxy/claude-hosted-web.js | 75 +---- containers/api-proxy/codex-hosted-web.js | 302 ++++++++++++++++++ .../api-proxy/codex-hosted-web.server.test.js | 150 +++++++++ containers/api-proxy/codex-hosted-web.test.js | 146 +++++++++ containers/api-proxy/hosted-web-policy.js | 74 +++++ containers/api-proxy/providers/openai.js | 13 +- containers/api-proxy/proxy-utils.js | 8 +- docs/awf-config.schema.json | 8 +- src/awf-config-schema.json | 7 +- src/claude-hosted-web-policy.ts | 144 +-------- src/codex-hosted-web-policy.test.ts | 114 +++++++ src/codex-hosted-web-policy.ts | 15 + src/commands/build-config.ts | 1 + src/config-file.ts | 2 + src/config-mapper.ts | 1 + src/hosted-web-policy.ts | 99 ++++++ src/services/api-proxy-env-config.ts | 5 + src/types/api-proxy-diagnostics-options.ts | 8 + 18 files changed, 956 insertions(+), 216 deletions(-) create mode 100644 containers/api-proxy/codex-hosted-web.js create mode 100644 containers/api-proxy/codex-hosted-web.server.test.js create mode 100644 containers/api-proxy/codex-hosted-web.test.js create mode 100644 containers/api-proxy/hosted-web-policy.js create mode 100644 src/codex-hosted-web-policy.test.ts create mode 100644 src/codex-hosted-web-policy.ts create mode 100644 src/hosted-web-policy.ts diff --git a/containers/api-proxy/claude-hosted-web.js b/containers/api-proxy/claude-hosted-web.js index d4f4ddc4b..f623dec2f 100644 --- a/containers/api-proxy/claude-hosted-web.js +++ b/containers/api-proxy/claude-hosted-web.js @@ -1,5 +1,7 @@ 'use strict'; +const { isValidDomain, parseHostedWebPolicy } = require('./hosted-web-policy'); + /** * Claude hosted-web policy enforcement for the AWF API proxy. * @@ -43,12 +45,6 @@ const HOSTED_WEB_TOOL_PATTERN = /^web_(search|fetch)_(\d{4})(\d{2})(\d{2})$/; */ const HOSTED_WEB_TOOL_PREFIX = /^web_(search|fetch)(_|$)/; -/** Lowercase DNS label characters (validated label-by-label; see isValidDomain). */ -const LABEL_CHARS = /^[a-z0-9-]+$/; -const DIGITS_ONLY = /^\d+$/; -const MAX_DOMAIN_LENGTH = 253; -const MAX_LABEL_LENGTH = 63; - /** * Error thrown when a request violates the configured hosted-web policy. * `statusCode` and `code` are surfaced verbatim by proxy-request.js. @@ -88,29 +84,6 @@ function isHostedWebToolCandidate(type) { return typeof type === 'string' && HOSTED_WEB_TOOL_PREFIX.test(type); } -/** - * Same syntax as the AWF config schema: a lowercase DNS hostname with at least - * two labels, no scheme/port/path/wildcard and no raw IPv4 address. Checked - * label-by-label rather than with one nested-quantifier regex so untrusted, - * request-supplied values cannot trigger catastrophic backtracking. - * - * @param {unknown} value - * @returns {boolean} - */ -function isValidDomain(value) { - if (typeof value !== 'string' || value.length === 0 || value.length > MAX_DOMAIN_LENGTH) return false; - const labels = value.split('.'); - if (labels.length < 2) return false; - if (labels.every(label => DIGITS_ONLY.test(label))) return false; // raw IPv4 - return labels.every(label => ( - label.length > 0 && - label.length <= MAX_LABEL_LENGTH && - LABEL_CHARS.test(label) && - !label.startsWith('-') && - !label.endsWith('-') - )); -} - /** * Parse and validate the serialized policy from the sidecar environment. * Throws on any malformed policy so the sidecar fails at startup rather than @@ -120,49 +93,7 @@ function isValidDomain(value) { * @returns {{ enabled: boolean, mode: 'allow'|'block'|null, domains: string[], maxUses?: number }|null} */ function parseClaudeHostedWebPolicy(raw) { - if (raw === undefined || raw === null || String(raw).trim() === '') return null; - - let parsed; - try { - parsed = JSON.parse(String(raw)); - } catch (err) { - throw new Error(`AWF_CLAUDE_HOSTED_WEB_POLICY is not valid JSON: ${err.message}`); - } - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - throw new Error('AWF_CLAUDE_HOSTED_WEB_POLICY must be a JSON object'); - } - if (typeof parsed.enabled !== 'boolean') { - throw new Error('AWF_CLAUDE_HOSTED_WEB_POLICY.enabled must be a boolean'); - } - - if (!parsed.enabled) { - return { enabled: false, mode: null, domains: [] }; - } - - if (parsed.mode !== 'allow' && parsed.mode !== 'block') { - throw new Error('AWF_CLAUDE_HOSTED_WEB_POLICY.mode must be "allow" or "block" when enabled'); - } - if (!Array.isArray(parsed.domains) || parsed.domains.length === 0) { - throw new Error('AWF_CLAUDE_HOSTED_WEB_POLICY.domains must be a non-empty array when enabled'); - } - const domains = []; - for (const domain of parsed.domains) { - if (!isValidDomain(domain)) { - throw new Error(`AWF_CLAUDE_HOSTED_WEB_POLICY.domains contains an invalid domain: ${JSON.stringify(domain)}`); - } - if (!domains.includes(domain)) domains.push(domain); - } - - const policy = { enabled: true, mode: parsed.mode, domains }; - - if (parsed.maxUses !== undefined) { - if (!Number.isInteger(parsed.maxUses) || parsed.maxUses < 1) { - throw new Error('AWF_CLAUDE_HOSTED_WEB_POLICY.maxUses must be a positive integer'); - } - policy.maxUses = parsed.maxUses; - } - - return policy; + return parseHostedWebPolicy(raw, 'AWF_CLAUDE_HOSTED_WEB_POLICY'); } /** diff --git a/containers/api-proxy/codex-hosted-web.js b/containers/api-proxy/codex-hosted-web.js new file mode 100644 index 000000000..e7a51b3cd --- /dev/null +++ b/containers/api-proxy/codex-hosted-web.js @@ -0,0 +1,302 @@ +'use strict'; + +const { + isValidDomain, + parseHostedWebPolicy, + domainIsWithin, + intersectDomains, +} = require('./hosted-web-policy'); + +const WEB_SEARCH_TOOL = /^web_search(?:_(\d{4})_(\d{2})_(\d{2}))?$/; +const WEB_SEARCH_CANDIDATE = /^web_search(?:_|$)/; +const SEARCH_PATHS = new Set(['/v1/alpha/search', '/alpha/search']); +const SEARCH_COMMANDS = new Set(['search_query', 'image_query', 'open', 'click', 'find', 'screenshot']); +const URL_COMMANDS = new Set(['open', 'find', 'screenshot']); + +class CodexHostedWebPolicyError extends Error { + constructor(code, message, statusCode = 403) { + super(message); + this.name = 'CodexHostedWebPolicyError'; + this.code = code; + this.statusCode = statusCode; + } +} + +function parseCodexHostedWebPolicy(raw) { + return parseHostedWebPolicy(raw, 'AWF_CODEX_HOSTED_WEB_POLICY'); +} + +function hasField(value, field) { + return Object.prototype.hasOwnProperty.call(value, field) && value[field] !== undefined; +} + +function readDomains(value, field) { + if (!Array.isArray(value) || value.length === 0) { + throw new CodexHostedWebPolicyError( + 'codex_hosted_web_filter_invalid', + `Codex hosted web "${field}" must be a non-empty array of domains.`, + 400, + ); + } + const domains = []; + for (const entry of value) { + const normalized = typeof entry === 'string' ? entry.trim().toLowerCase() : entry; + if (!isValidDomain(normalized)) { + throw new CodexHostedWebPolicyError( + 'codex_hosted_web_domain_invalid', + `Codex hosted web "${field}" contains an invalid domain.`, + 400, + ); + } + if (!domains.includes(normalized)) domains.push(normalized); + } + return domains; +} + +function resolveFilters(policy, filters) { + if (filters !== undefined && (!filters || typeof filters !== 'object' || Array.isArray(filters))) { + throw new CodexHostedWebPolicyError( + 'codex_hosted_web_filter_invalid', + 'Codex hosted web "filters" must be an object.', + 400, + ); + } + const input = filters || {}; + const hasAllowed = hasField(input, 'allowed_domains'); + const hasBlocked = hasField(input, 'blocked_domains'); + const requestedAllowed = hasAllowed ? readDomains(input.allowed_domains, 'allowed_domains') : null; + const requestedBlocked = hasBlocked ? readDomains(input.blocked_domains, 'blocked_domains') : null; + const result = { ...input }; + + if (policy.mode === 'allow') { + const allowed = requestedAllowed ? intersectDomains(policy.domains, requestedAllowed) : policy.domains; + if (allowed.length === 0) { + throw new CodexHostedWebPolicyError( + 'codex_hosted_web_empty_intersection', + 'Codex hosted web allowed domains have no overlap with the AWF policy.', + ); + } + result.allowed_domains = allowed; + if (requestedBlocked) result.blocked_domains = requestedBlocked; + } else { + result.blocked_domains = [...new Set([...policy.domains, ...(requestedBlocked || [])])]; + if (requestedAllowed) result.allowed_domains = requestedAllowed; + } + return result; +} + +function validateAccessMode(value, field, allowedStrings = []) { + if (value === undefined || typeof value === 'boolean') return; + if (typeof value === 'string' && allowedStrings.includes(value)) return; + throw new CodexHostedWebPolicyError( + 'codex_hosted_web_access_invalid', + `Codex hosted web "${field}" has an unsupported access mode.`, + 400, + ); +} + +function resolveMaxUses(policy, value) { + if (value !== undefined && (!Number.isInteger(value) || value < 1)) { + throw new CodexHostedWebPolicyError( + 'codex_hosted_web_max_uses_invalid', + 'Codex hosted web "max_uses" must be a positive integer.', + 400, + ); + } + if (policy.maxUses === undefined) return value; + return value === undefined ? policy.maxUses : Math.min(policy.maxUses, value); +} + +function isRecognizedToolType(type) { + const match = typeof type === 'string' ? WEB_SEARCH_TOOL.exec(type) : null; + if (!match) return false; + if (!match[1]) return true; + const month = Number(match[2]); + const day = Number(match[3]); + return month >= 1 && month <= 12 && day >= 1 && day <= 31; +} + +function enforceResponses(body, policy) { + if (!body || !Array.isArray(body.tools)) return null; + let matched = false; + const tools = body.tools.map(tool => { + const type = tool && typeof tool === 'object' ? tool.type : undefined; + if (typeof type !== 'string' || !WEB_SEARCH_CANDIDATE.test(type)) return tool; + matched = true; + if (!policy.enabled) { + throw new CodexHostedWebPolicyError( + 'codex_hosted_web_disabled', + 'Codex hosted web search is disabled by AWF policy.', + ); + } + if (!isRecognizedToolType(type)) { + throw new CodexHostedWebPolicyError( + 'codex_hosted_web_tool_unrecognized', + 'Unrecognized Codex hosted web tool; AWF cannot prove the domain policy applies.', + 400, + ); + } + validateAccessMode(tool.external_web_access, 'external_web_access'); + validateAccessMode(tool.indexed_web_access, 'indexed_web_access'); + const result = { ...tool, filters: resolveFilters(policy, tool.filters) }; + const maxUses = resolveMaxUses(policy, tool.max_uses); + if (maxUses === undefined) delete result.max_uses; + else result.max_uses = maxUses; + return result; + }); + return matched ? { ...body, tools } : null; +} + +function hostAllowed(host, filters) { + const allowed = filters.allowed_domains; + if (allowed && !allowed.some(domain => domainIsWithin(host, domain))) return false; + const blocked = filters.blocked_domains; + return !blocked || !blocked.some(domain => domainIsWithin(host, domain)); +} + +function narrowQueryDomains(query, filters) { + if (!hasField(query, 'domains')) return query; + const requested = readDomains(query.domains, 'domains'); + let effective = filters.allowed_domains + ? intersectDomains(filters.allowed_domains, requested) + : requested; + if (filters.blocked_domains) { + effective = effective.filter(domain => + !filters.blocked_domains.some(blocked => domainIsWithin(domain, blocked))); + } + if (effective.length === 0) { + throw new CodexHostedWebPolicyError( + 'codex_hosted_web_empty_query_scope', + 'A Codex hosted web query has no domains permitted by the effective policy.', + ); + } + return { ...query, domains: effective }; +} + +function checkLiteralUrl(value, filters) { + if (typeof value !== 'string' || !value.includes('://')) return; + let parsed; + try { + parsed = new URL(value); + } catch { + throw new CodexHostedWebPolicyError( + 'codex_hosted_web_url_invalid', + 'A Codex hosted web command contains an invalid URL.', + 400, + ); + } + if ((parsed.protocol !== 'http:' && parsed.protocol !== 'https:') || !isValidDomain(parsed.hostname.toLowerCase())) { + throw new CodexHostedWebPolicyError( + 'codex_hosted_web_url_invalid', + 'A Codex hosted web command contains an invalid HTTP(S) URL host.', + 400, + ); + } + if (!hostAllowed(parsed.hostname.toLowerCase(), filters)) { + throw new CodexHostedWebPolicyError( + 'codex_hosted_web_url_disallowed', + 'A Codex hosted web URL host is not permitted by the effective AWF policy.', + ); + } +} + +function enforceStandalone(body, policy) { + if (!policy.enabled) { + throw new CodexHostedWebPolicyError( + 'codex_hosted_web_disabled', + 'Codex standalone hosted search is disabled by AWF policy.', + ); + } + if (policy.maxUses !== undefined) { + throw new CodexHostedWebPolicyError( + 'codex_hosted_web_max_uses_unsupported', + 'Codex standalone hosted search cannot enforce the configured maxUses limit.', + ); + } + if (!body || typeof body !== 'object' || Array.isArray(body)) { + throw new CodexHostedWebPolicyError( + 'codex_hosted_web_shape_invalid', + 'Codex standalone hosted search body must be an object.', + 400, + ); + } + const settings = body.settings === undefined ? {} : body.settings; + if (!settings || typeof settings !== 'object' || Array.isArray(settings)) { + throw new CodexHostedWebPolicyError( + 'codex_hosted_web_shape_invalid', + 'Codex standalone hosted search settings must be an object.', + 400, + ); + } + validateAccessMode(settings.external_web_access, 'external_web_access', ['cached', 'indexed', 'live']); + const filters = resolveFilters(policy, settings.filters); + const commands = body.commands === undefined ? {} : body.commands; + if (!commands || typeof commands !== 'object' || Array.isArray(commands)) { + throw new CodexHostedWebPolicyError( + 'codex_hosted_web_shape_invalid', + 'Codex standalone hosted search commands must be an object.', + 400, + ); + } + const updatedCommands = {}; + for (const [name, entries] of Object.entries(commands)) { + if (!SEARCH_COMMANDS.has(name) || !Array.isArray(entries)) { + throw new CodexHostedWebPolicyError( + 'codex_hosted_web_command_unrecognized', + 'Codex standalone hosted search contains an unrecognized command shape.', + 400, + ); + } + updatedCommands[name] = entries.map(entry => { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + throw new CodexHostedWebPolicyError( + 'codex_hosted_web_command_unrecognized', + 'Codex standalone hosted search contains an unrecognized command shape.', + 400, + ); + } + if (name === 'search_query' || name === 'image_query') return narrowQueryDomains(entry, filters); + if (URL_COMMANDS.has(name)) checkLiteralUrl(entry.ref_id, filters); + return entry; + }); + } + return { + ...body, + commands: updatedCommands, + settings: { ...settings, filters }, + }; +} + +function makeCodexHostedWebTransform(policy) { + if (!policy) return null; + return (bodyBuffer, req) => { + let pathname = ''; + try { + pathname = new URL(req?.url || '/', 'http://localhost').pathname; + } catch {} + const standalone = SEARCH_PATHS.has(pathname); + let body; + try { + body = JSON.parse(bodyBuffer.toString('utf8')); + } catch { + if (!standalone) return null; + throw new CodexHostedWebPolicyError( + 'codex_hosted_web_shape_invalid', + 'Codex standalone hosted search body must be valid JSON.', + 400, + ); + } + const updated = standalone ? enforceStandalone(body, policy) : enforceResponses(body, policy); + if (updated === null) return null; + const result = Buffer.from(JSON.stringify(updated), 'utf8'); + return result.equals(bodyBuffer) ? null : result; + }; +} + +module.exports = { + CodexHostedWebPolicyError, + parseCodexHostedWebPolicy, + enforceResponses, + enforceStandalone, + makeCodexHostedWebTransform, +}; diff --git a/containers/api-proxy/codex-hosted-web.server.test.js b/containers/api-proxy/codex-hosted-web.server.test.js new file mode 100644 index 000000000..f35e50ee7 --- /dev/null +++ b/containers/api-proxy/codex-hosted-web.server.test.js @@ -0,0 +1,150 @@ +'use strict'; + +const https = require('https'); +const { + makeReq, + makeRes, + makeProxyReq, + setupServerTestEnv, + flushPromises, +} = require('./test-helpers/server-mock-factories'); +const { createOpenAIAdapter } = require('./providers/openai'); + +let proxyRequest; + +setupServerTestEnv(() => { + ({ proxyRequest } = require('./server')); + return { proxyRequest }; +}); + +const POLICY = JSON.stringify({ + enabled: true, + mode: 'allow', + domains: ['docs.github.com'], +}); + +async function dispatch(adapter, path, body) { + const upstreamRequest = makeProxyReq(); + jest.spyOn(https, 'request').mockImplementation(() => upstreamRequest); + const req = makeReq(path); + const res = makeRes(); + proxyRequest( + req, + res, + 'api.openai.com', + { Authorization: '******' }, + 'openai', + '', + adapter.getBodyTransform(), + ); + req.emit('data', Buffer.from(JSON.stringify(body))); + req.emit('end'); + await flushPromises(); + const written = upstreamRequest.write.mock.calls[0]; + return { + res, + upstreamBody: written ? JSON.parse(written[0].toString('utf8')) : null, + }; +} + +describe('Codex hosted-web policy through the OpenAI adapter', () => { + afterEach(() => jest.restoreAllMocks()); + + it('fails sidecar startup for invalid serialized policy', () => { + expect(() => createOpenAIAdapter({ + OPENAI_API_KEY: 'sk-test', + AWF_CODEX_HOSTED_WEB_POLICY: '{"enabled":true}', + })).toThrow(/AWF_CODEX_HOSTED_WEB_POLICY/); + }); + + it('does not install an extra transform when policy is omitted', () => { + const marker = body => body; + expect(createOpenAIAdapter( + { OPENAI_API_KEY: 'sk-test' }, + { bodyTransform: marker }, + ).getBodyTransform()).toBe(marker); + }); + + it('sends an exactly enforced Responses body upstream after model transforms', async () => { + const modelTransform = body => { + const parsed = JSON.parse(body); + parsed.model = 'resolved-model'; + return Buffer.from(JSON.stringify(parsed)); + }; + const adapter = createOpenAIAdapter( + { OPENAI_API_KEY: 'sk-test', AWF_CODEX_HOSTED_WEB_POLICY: POLICY }, + { bodyTransform: modelTransform }, + ); + const { upstreamBody } = await dispatch(adapter, '/v1/responses', { + model: 'alias', + input: 'search privately', + tools: [{ + type: 'web_search', + filters: { allowed_domains: ['github.com'] }, + }], + }); + expect(upstreamBody).toEqual({ + model: 'resolved-model', + input: 'search privately', + tools: [{ + type: 'web_search', + filters: { allowed_domains: ['docs.github.com'] }, + }], + }); + }); + + it('sends an exactly enforced standalone search body upstream', async () => { + const adapter = createOpenAIAdapter({ + OPENAI_API_KEY: 'sk-test', + AWF_CODEX_HOSTED_WEB_POLICY: POLICY, + }); + const { upstreamBody } = await dispatch(adapter, '/v1/alpha/search', { + id: 'session', + model: 'gpt-5', + commands: { + search_query: [{ q: 'documentation', domains: ['github.com'] }], + open: [{ ref_id: 'turn0search0' }], + }, + settings: { external_web_access: true }, + }); + expect(upstreamBody).toEqual({ + id: 'session', + model: 'gpt-5', + commands: { + search_query: [{ q: 'documentation', domains: ['docs.github.com'] }], + open: [{ ref_id: 'turn0search0' }], + }, + settings: { + external_web_access: true, + filters: { allowed_domains: ['docs.github.com'] }, + }, + }); + }); + + it('rejects disabled standalone search without upstream dispatch', async () => { + const adapter = createOpenAIAdapter({ + OPENAI_API_KEY: 'sk-test', + AWF_CODEX_HOSTED_WEB_POLICY: '{"enabled":false}', + }); + const { res, upstreamBody } = await dispatch(adapter, '/v1/alpha/search', { + input: 'sensitive query text', + settings: { external_web_access: true }, + }); + expect(upstreamBody).toBeNull(); + expect(res.writeHead).toHaveBeenCalledWith(403, expect.objectContaining({ + 'Content-Type': 'application/json', + })); + const payload = JSON.parse(res.end.mock.calls[0][0]); + expect(payload.error.code).toBe('codex_hosted_web_disabled'); + expect(payload.error.message).not.toContain('sensitive query text'); + }); + + it('leaves ordinary OpenAI requests exactly unchanged', async () => { + const adapter = createOpenAIAdapter({ + OPENAI_API_KEY: 'sk-test', + AWF_CODEX_HOSTED_WEB_POLICY: POLICY, + }); + const body = { model: 'gpt-5', input: 'ordinary request' }; + expect((await dispatch(adapter, '/v1/responses', body)).upstreamBody).toEqual(body); + }); +}); diff --git a/containers/api-proxy/codex-hosted-web.test.js b/containers/api-proxy/codex-hosted-web.test.js new file mode 100644 index 000000000..630de63c8 --- /dev/null +++ b/containers/api-proxy/codex-hosted-web.test.js @@ -0,0 +1,146 @@ +'use strict'; + +const { + parseCodexHostedWebPolicy, + enforceResponses, + enforceStandalone, + makeCodexHostedWebTransform, +} = require('./codex-hosted-web'); + +const allow = { enabled: true, mode: 'allow', domains: ['docs.github.com'], maxUses: 5 }; +const block = { enabled: true, mode: 'block', domains: ['evil.example'] }; + +describe('Codex hosted-web policy', () => { + it('validates serialized policy at startup', () => { + expect(() => parseCodexHostedWebPolicy('{"enabled":true}')) + .toThrow(/AWF_CODEX_HOSTED_WEB_POLICY/); + expect(parseCodexHostedWebPolicy('{"enabled":false}')) + .toEqual({ enabled: false, mode: null, domains: [] }); + }); + + it('leaves Responses requests without hosted tools unchanged', () => { + expect(enforceResponses({ model: 'gpt-5', tools: [{ type: 'function' }] }, allow)).toBeNull(); + }); + + it('injects/intersects filters and clamps max_uses on every Responses tool', () => { + const result = enforceResponses({ + tools: [ + { type: 'web_search', filters: { allowed_domains: ['github.com'] }, max_uses: 9 }, + { type: 'web_search_2025_08_26' }, + ], + }, allow); + expect(result.tools).toEqual([ + { + type: 'web_search', + filters: { allowed_domains: ['docs.github.com'] }, + max_uses: 5, + }, + { + type: 'web_search_2025_08_26', + filters: { allowed_domains: ['docs.github.com'] }, + max_uses: 5, + }, + ]); + }); + + it('combines cross-mode filters rather than discarding either restriction', () => { + expect(enforceResponses({ + tools: [{ + type: 'web_search', + filters: { + allowed_domains: ['docs.example.com'], + blocked_domains: ['ads.example'], + }, + }], + }, { enabled: true, mode: 'allow', domains: ['example.com'] }).tools[0].filters) + .toEqual({ + allowed_domains: ['docs.example.com'], + blocked_domains: ['ads.example'], + }); + + expect(enforceResponses({ + tools: [{ type: 'web_search', filters: { allowed_domains: ['safe.example'] } }], + }, block).tools[0].filters).toEqual({ + allowed_domains: ['safe.example'], + blocked_domains: ['evil.example'], + }); + }); + + it.each([ + [{ enabled: false, mode: null, domains: [] }, 'codex_hosted_web_disabled'], + [allow, 'codex_hosted_web_empty_intersection'], + ])('rejects disabled access and empty intersections', (policy, code) => { + const body = { + tools: [{ + type: 'web_search', + filters: policy.enabled ? { allowed_domains: ['evil.example'] } : undefined, + }], + }; + expect(() => enforceResponses(body, policy)).toThrow(expect.objectContaining({ code })); + }); + + it('injects standalone filters and narrows each query independently', () => { + const result = enforceStandalone({ + settings: { external_web_access: 'indexed' }, + commands: { + search_query: [ + { q: 'one', domains: ['github.com'] }, + { q: 'two', domains: ['docs.github.com'] }, + ], + }, + }, { ...allow, maxUses: undefined }); + expect(result.settings.filters).toEqual({ allowed_domains: ['docs.github.com'] }); + expect(result.commands.search_query.map(query => query.domains)) + .toEqual([['docs.github.com'], ['docs.github.com']]); + }); + + it('unions standalone blocklists and prevents query scopes from removing blocks', () => { + const result = enforceStandalone({ + settings: { filters: { blocked_domains: ['ads.example'] } }, + commands: { search_query: [{ q: 'safe', domains: ['safe.example'] }] }, + }, block); + expect(result.settings.filters.blocked_domains).toEqual(['evil.example', 'ads.example']); + expect(result.commands.search_query[0].domains).toEqual(['safe.example']); + expect(() => enforceStandalone({ + commands: { search_query: [{ q: 'bad', domains: ['evil.example'] }] }, + }, block)).toThrow(expect.objectContaining({ code: 'codex_hosted_web_empty_query_scope' })); + }); + + it.each(['open', 'find', 'screenshot'])('checks literal URLs in %s commands', command => { + expect(() => enforceStandalone({ + commands: { [command]: [{ ref_id: 'https://evil.example/private' }] }, + }, { ...allow, maxUses: undefined })) + .toThrow(expect.objectContaining({ code: 'codex_hosted_web_url_disallowed' })); + }); + + it('allows non-URL reference IDs and rejects unknown command shapes', () => { + expect(enforceStandalone({ + commands: { open: [{ ref_id: 'turn0search0' }] }, + }, { ...allow, maxUses: undefined }).commands.open[0].ref_id).toBe('turn0search0'); + expect(() => enforceStandalone({ + commands: { future_fetch: [{}] }, + }, { ...allow, maxUses: undefined })) + .toThrow(expect.objectContaining({ code: 'codex_hosted_web_command_unrecognized' })); + }); + + it('rejects unsupported access modes and unsupported standalone maxUses', () => { + expect(() => enforceStandalone({ + settings: { external_web_access: 'future' }, + }, { ...allow, maxUses: undefined })) + .toThrow(expect.objectContaining({ code: 'codex_hosted_web_access_invalid' })); + expect(() => enforceStandalone({}, allow)) + .toThrow(expect.objectContaining({ code: 'codex_hosted_web_max_uses_unsupported' })); + }); + + it('uses the request path to select the standalone body shape', () => { + const transform = makeCodexHostedWebTransform({ ...allow, maxUses: undefined }); + const transformed = transform( + Buffer.from(JSON.stringify({ commands: {} })), + { url: '/v1/alpha/search' }, + ); + expect(JSON.parse(transformed)).toEqual({ + commands: {}, + settings: { filters: { allowed_domains: ['docs.github.com'] } }, + }); + }); +}); diff --git a/containers/api-proxy/hosted-web-policy.js b/containers/api-proxy/hosted-web-policy.js new file mode 100644 index 000000000..2f14657da --- /dev/null +++ b/containers/api-proxy/hosted-web-policy.js @@ -0,0 +1,74 @@ +'use strict'; + +const LABEL_CHARS = /^[a-z0-9-]+$/; +const DIGITS_ONLY = /^\d+$/; + +function isValidDomain(value) { + if (typeof value !== 'string' || value.length === 0 || value.length > 253) return false; + const labels = value.split('.'); + if (labels.length < 2 || labels.every(label => DIGITS_ONLY.test(label))) return false; + return labels.every(label => ( + label.length <= 63 && + LABEL_CHARS.test(label) && + !label.startsWith('-') && + !label.endsWith('-') + )); +} + +function parseHostedWebPolicy(raw, envName) { + if (raw === undefined || raw === null || String(raw).trim() === '') return null; + let parsed; + try { + parsed = JSON.parse(String(raw)); + } catch (err) { + throw new Error(`${envName} is not valid JSON: ${err.message}`); + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`${envName} must be a JSON object`); + } + if (typeof parsed.enabled !== 'boolean') { + throw new Error(`${envName}.enabled must be a boolean`); + } + if (!parsed.enabled) return { enabled: false, mode: null, domains: [] }; + if (parsed.mode !== 'allow' && parsed.mode !== 'block') { + throw new Error(`${envName}.mode must be "allow" or "block" when enabled`); + } + if (!Array.isArray(parsed.domains) || parsed.domains.length === 0) { + throw new Error(`${envName}.domains must be a non-empty array when enabled`); + } + const domains = []; + for (const domain of parsed.domains) { + if (!isValidDomain(domain)) { + throw new Error(`${envName}.domains contains an invalid domain: ${JSON.stringify(domain)}`); + } + if (!domains.includes(domain)) domains.push(domain); + } + const policy = { enabled: true, mode: parsed.mode, domains }; + if (parsed.maxUses !== undefined) { + if (!Number.isInteger(parsed.maxUses) || parsed.maxUses < 1) { + throw new Error(`${envName}.maxUses must be a positive integer`); + } + policy.maxUses = parsed.maxUses; + } + return policy; +} + +function domainIsWithin(domain, parent) { + return domain === parent || domain.endsWith(`.${parent}`); +} + +function intersectDomains(left, right) { + return [...new Set(left.flatMap(leftDomain => + right.flatMap(rightDomain => { + if (domainIsWithin(leftDomain, rightDomain)) return [leftDomain]; + if (domainIsWithin(rightDomain, leftDomain)) return [rightDomain]; + return []; + })))]; +} + +module.exports = { + isValidDomain, + parseHostedWebPolicy, + domainIsWithin, + intersectDomains, +}; diff --git a/containers/api-proxy/providers/openai.js b/containers/api-proxy/providers/openai.js index 312a7ef70..ff59019ac 100644 --- a/containers/api-proxy/providers/openai.js +++ b/containers/api-proxy/providers/openai.js @@ -16,6 +16,11 @@ const { } = require('../proxy-utils'); const { validateAuthHeaderEnv } = require('../oidc-adapter-utils'); const { buildAuthHeaderFn } = require('./auth-headers'); +const { composeBodyTransforms } = require('../proxy-utils'); +const { + parseCodexHostedWebPolicy, + makeCodexHostedWebTransform, +} = require('../codex-hosted-web'); const { createProviderAuthScaffold, createOidcAwareProviderAdapter } = require('../adapter-factory'); const { OPENAI_ENV, COPILOT_ENV } = require('../provider-env-constants'); @@ -56,6 +61,11 @@ function createOpenAIAdapter(env, deps = {}) { const explicitOpenAITarget = env[OPENAI_ENV.TARGET] ? openaiTarget : undefined; const rawTarget = explicitOpenAITarget || (copilotAzureByokEnabled ? copilotByokTarget : undefined) || 'api.openai.com'; const explicitBasePath = openaiBasePath || (copilotAzureByokEnabled ? copilotByokBasePath : ''); + const hostedWebPolicy = parseCodexHostedWebPolicy(env.AWF_CODEX_HOSTED_WEB_POLICY); + const composedBodyTransform = composeBodyTransforms( + bodyTransform, + makeCodexHostedWebTransform(hostedWebPolicy), + ); // For the default OpenAI endpoint, unversioned clients (e.g. Codex CLI sending // /responses) need a /v1 prefix to reach the correct versioned API surface. @@ -96,7 +106,7 @@ function createOpenAIAdapter(env, deps = {}) { name: 'openai', port: 10000, isManagementPort: true, - bodyTransform, + bodyTransform: composedBodyTransform, missingCredentialResponse: { kind: 'plain_error', statusCode: 404, @@ -112,6 +122,7 @@ function createOpenAIAdapter(env, deps = {}) { extra: { /** Port 10000 always counts toward the startup validation latch. */ participatesInValidation: true, + _hostedWebPolicy: hostedWebPolicy, }, }), }); diff --git a/containers/api-proxy/proxy-utils.js b/containers/api-proxy/proxy-utils.js index 6c14a19b0..49a588381 100644 --- a/containers/api-proxy/proxy-utils.js +++ b/containers/api-proxy/proxy-utils.js @@ -244,11 +244,11 @@ function composeBodyTransforms(first, second) { if (!first) return second; if (!second) return first; const isPromise = (v) => v && typeof v.then === 'function'; - return (body) => { - const a = first(body); + return (body, ...args) => { + const a = first(body, ...args); if (isPromise(a)) { return Promise.resolve(a).then((aResolved) => { - const b = second(aResolved !== null ? aResolved : body); + const b = second(aResolved !== null ? aResolved : body, ...args); if (isPromise(b)) { return Promise.resolve(b).then((bResolved) => { if (bResolved !== null) return bResolved; @@ -262,7 +262,7 @@ function composeBodyTransforms(first, second) { }); } - const b = second(a !== null ? a : body); + const b = second(a !== null ? a : body, ...args); if (isPromise(b)) { return Promise.resolve(b).then((bResolved) => { if (bResolved !== null) return bResolved; diff --git a/docs/awf-config.schema.json b/docs/awf-config.schema.json index 9be52a1b2..128868403 100644 --- a/docs/awf-config.schema.json +++ b/docs/awf-config.schema.json @@ -150,8 +150,8 @@ "description": "Provider allowlist upper bound sent to Anthropic as allowed_domains. Mutually exclusive with blockedDomains (Anthropic rejects both on one tool definition). Values are lowercase DNS hostnames with at least two labels: no scheme, path, query, fragment, credentials, port, wildcard, CIDR, raw IP address, or localhost/Docker service alias. Duplicates are removed after normalization. A parent domain also covers its subdomains, matching Anthropic's semantics.", "items": { "type": "string", - "maxLength": 253, - "pattern": "^(?![0-9.]+$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(\\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$" + "maxLength": 253, + "pattern": "^(?![0-9.]+$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(\\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$" } }, "blockedDomains": { @@ -224,6 +224,10 @@ ] } ] + }, + "codex": { + "$ref": "#/properties/apiProxy/properties/hostedWeb/properties/claude", + "description": "Codex/OpenAI hosted web policy. Uses the same closed config contract, normalization, source precedence, and immutable-upper-bound rule as claude. Applies to Responses API hosted web-search tools and the standalone /v1/alpha/search route, including request filters, every per-query domains scope, and literal URL operations. enabled:false rejects any hosted-search intent, including external_web_access. Request filters may only narrow policy: same-mode allowlists intersect and blocklists union; cross-mode filters are combined because OpenAI can represent both simultaneously. Every retrieval is governed by the most restrictive applicable request, query, and URL scope. maxUses is enforced as max_uses on Responses tools; /v1/alpha/search has no equivalent cap and is rejected when maxUses is configured rather than silently ignoring it. Unknown hosted-search shapes fail closed. Requests without a hosted-web surface are unchanged. Omission preserves pass-through behavior and does NOT constrain Codex-hosted egress. JSON/YAML files and JSON/YAML via --config - behave identically; stdin errors identify stdin before containers start. Hosted-web domains are explicit and are never copied from network.allowDomains or network.sensitiveAllowDomains." } } }, diff --git a/src/awf-config-schema.json b/src/awf-config-schema.json index 6564f8cea..128868403 100644 --- a/src/awf-config-schema.json +++ b/src/awf-config-schema.json @@ -150,7 +150,8 @@ "description": "Provider allowlist upper bound sent to Anthropic as allowed_domains. Mutually exclusive with blockedDomains (Anthropic rejects both on one tool definition). Values are lowercase DNS hostnames with at least two labels: no scheme, path, query, fragment, credentials, port, wildcard, CIDR, raw IP address, or localhost/Docker service alias. Duplicates are removed after normalization. A parent domain also covers its subdomains, matching Anthropic's semantics.", "items": { "type": "string", - "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$" + "maxLength": 253, + "pattern": "^(?![0-9.]+$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(\\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$" } }, "blockedDomains": { @@ -223,6 +224,10 @@ ] } ] + }, + "codex": { + "$ref": "#/properties/apiProxy/properties/hostedWeb/properties/claude", + "description": "Codex/OpenAI hosted web policy. Uses the same closed config contract, normalization, source precedence, and immutable-upper-bound rule as claude. Applies to Responses API hosted web-search tools and the standalone /v1/alpha/search route, including request filters, every per-query domains scope, and literal URL operations. enabled:false rejects any hosted-search intent, including external_web_access. Request filters may only narrow policy: same-mode allowlists intersect and blocklists union; cross-mode filters are combined because OpenAI can represent both simultaneously. Every retrieval is governed by the most restrictive applicable request, query, and URL scope. maxUses is enforced as max_uses on Responses tools; /v1/alpha/search has no equivalent cap and is rejected when maxUses is configured rather than silently ignoring it. Unknown hosted-search shapes fail closed. Requests without a hosted-web surface are unchanged. Omission preserves pass-through behavior and does NOT constrain Codex-hosted egress. JSON/YAML files and JSON/YAML via --config - behave identically; stdin errors identify stdin before containers start. Hosted-web domains are explicit and are never copied from network.allowDomains or network.sensitiveAllowDomains." } } }, diff --git a/src/claude-hosted-web-policy.ts b/src/claude-hosted-web-policy.ts index 13224c6ed..ae160be12 100644 --- a/src/claude-hosted-web-policy.ts +++ b/src/claude-hosted-web-policy.ts @@ -1,143 +1,15 @@ -/** - * Claude hosted-web policy normalization. - * - * Anthropic's hosted `web_search_*` / `web_fetch_*` server tools execute on - * Anthropic infrastructure, so Squid never observes the searched or fetched - * destination. The trusted api-proxy sidecar therefore enforces an AWF-owned - * domain policy on those tool definitions before dispatching the request - * upstream (see docs/awf-config-spec.md §9.8). - * - * This module converts the validated `apiProxy.hostedWeb.claude` config object - * into the single normalized representation that is serialized into the sidecar - * environment. It fails closed: any ambiguous or unrepresentable policy throws - * before containers start. - */ +import { + HostedWebConfig, + NormalizedHostedWebPolicy, + normalizeHostedWebPolicy, +} from './hosted-web-policy'; -/** Raw `apiProxy.hostedWeb.claude` config object as authored by the user. */ -export interface ClaudeHostedWebConfig { - enabled?: boolean; - allowedDomains?: string[]; - blockedDomains?: string[]; - maxUses?: number; -} - -/** Normalized policy handed to the api-proxy sidecar. */ -export interface NormalizedClaudeHostedWebPolicy { - enabled: boolean; - /** `null` only when `enabled` is false (no domain mode is required then). */ - mode: 'allow' | 'block' | null; - domains: string[]; - maxUses?: number; -} - -/** - * Lowercase DNS label characters. Validation is done label-by-label (rather - * than with one nested-quantifier hostname regex) so the check stays linear on - * adversarial input. - */ -const LABEL_CHARS = /^[a-z0-9-]+$/; -const DIGITS_ONLY = /^\d+$/; -const MAX_DOMAIN_LENGTH = 253; -const MAX_LABEL_LENGTH = 63; - -/** - * Lowercase DNS hostname with at least two labels. Deliberately rejects - * schemes, paths, ports, credentials, wildcards, CIDRs, raw IPv4 addresses and - * single-label names such as `localhost` or Docker service aliases. - */ -function isValidDomain(value: string): boolean { - if (!value || value.length > MAX_DOMAIN_LENGTH) return false; - const labels = value.split('.'); - if (labels.length < 2) return false; - if (labels.every((label) => DIGITS_ONLY.test(label))) return false; // raw IPv4 - return labels.every((label) => - label.length > 0 && - label.length <= MAX_LABEL_LENGTH && - LABEL_CHARS.test(label) && - !label.startsWith('-') && - !label.endsWith('-'), - ); -} - -function normalizeDomains(values: unknown, field: string, source: string): string[] { - if (!Array.isArray(values) || values.length === 0) { - throw new Error(`${source}: apiProxy.hostedWeb.claude.${field} must be a non-empty array of domains`); - } - const normalized: string[] = []; - for (const value of values) { - if (typeof value !== 'string') { - throw new Error(`${source}: apiProxy.hostedWeb.claude.${field} entries must be strings`); - } - const domain = value.trim().toLowerCase(); - if (!isValidDomain(domain)) { - throw new Error( - `${source}: apiProxy.hostedWeb.claude.${field} entry "${value}" is not a valid domain. ` + - 'Use a lowercase DNS hostname with at least two labels and no scheme, port, path, wildcard or IP address.', - ); - } - if (!normalized.includes(domain)) normalized.push(domain); - } - return normalized; -} +export type ClaudeHostedWebConfig = HostedWebConfig; +export type NormalizedClaudeHostedWebPolicy = NormalizedHostedWebPolicy; -/** - * Validate and normalize a Claude hosted-web policy. - * - * @param config Raw config object (already schema-validated when loaded from a - * config file or stdin; re-checked here because the value may also arrive - * through programmatic callers). - * @param source Label used in error messages (e.g. `config`, `stdin`). - * @returns The normalized policy, or `undefined` when no policy is configured. - */ export function normalizeClaudeHostedWebPolicy( config: ClaudeHostedWebConfig | undefined, source = 'config', ): NormalizedClaudeHostedWebPolicy | undefined { - if (config === undefined || config === null) return undefined; - if (typeof config !== 'object' || Array.isArray(config)) { - throw new Error(`${source}: apiProxy.hostedWeb.claude must be an object`); - } - if (typeof config.enabled !== 'boolean') { - throw new Error(`${source}: apiProxy.hostedWeb.claude.enabled is required and must be a boolean`); - } - - const hasAllow = config.allowedDomains !== undefined; - const hasBlock = config.blockedDomains !== undefined; - if (hasAllow && hasBlock) { - throw new Error( - `${source}: apiProxy.hostedWeb.claude.allowedDomains and blockedDomains are mutually exclusive ` + - '(Anthropic rejects both filters on one tool definition)', - ); - } - - if (!config.enabled) { - if (hasAllow || hasBlock) { - throw new Error( - `${source}: apiProxy.hostedWeb.claude.allowedDomains/blockedDomains cannot be combined with enabled: false`, - ); - } - return { enabled: false, mode: null, domains: [] }; - } - - if (!hasAllow && !hasBlock) { - throw new Error( - `${source}: apiProxy.hostedWeb.claude requires exactly one of allowedDomains or blockedDomains when enabled is true`, - ); - } - - const mode: 'allow' | 'block' = hasAllow ? 'allow' : 'block'; - const domains = hasAllow - ? normalizeDomains(config.allowedDomains, 'allowedDomains', source) - : normalizeDomains(config.blockedDomains, 'blockedDomains', source); - - const policy: NormalizedClaudeHostedWebPolicy = { enabled: true, mode, domains }; - - if (config.maxUses !== undefined) { - if (typeof config.maxUses !== 'number' || !Number.isInteger(config.maxUses) || config.maxUses < 1) { - throw new Error(`${source}: apiProxy.hostedWeb.claude.maxUses must be a positive integer`); - } - policy.maxUses = config.maxUses; - } - - return policy; + return normalizeHostedWebPolicy(config, 'claude', source); } diff --git a/src/codex-hosted-web-policy.test.ts b/src/codex-hosted-web-policy.test.ts new file mode 100644 index 000000000..c6d7d5458 --- /dev/null +++ b/src/codex-hosted-web-policy.test.ts @@ -0,0 +1,114 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { loadAwfFileConfig } from './config-file'; +import { mapAwfFileConfigToCliOptions } from './config-mapper'; +import { normalizeCodexHostedWebPolicy } from './codex-hosted-web-policy'; +import { validateWithSchema } from './schema-validator'; +import { testHelpers } from './services/api-proxy-env-config'; +import { WrapperConfig } from './types'; + +const POLICY = { enabled: true, allowedDomains: ['docs.github.com', 'nodejs.org'], maxUses: 5 }; +const CONFIG = { apiProxy: { hostedWeb: { codex: POLICY } } }; + +describe('apiProxy.hostedWeb.codex schema', () => { + it.each([ + ['allowlist', POLICY], + ['blocklist', { enabled: true, blockedDomains: ['untrusted.example'] }], + ['disabled', { enabled: false }], + ])('accepts %s mode', (_name, codex) => { + expect(validateWithSchema({ apiProxy: { hostedWeb: { codex } } })).toEqual([]); + }); + + it.each([ + ['both lists', { enabled: true, allowedDomains: ['a.com'], blockedDomains: ['b.com'] }], + ['missing mode', { enabled: true }], + ['disabled with domains', { enabled: false, allowedDomains: ['a.com'] }], + ['empty list', { enabled: true, allowedDomains: [] }], + ['invalid domain', { enabled: true, allowedDomains: ['https://a.com'] }], + ['invalid maxUses', { enabled: true, allowedDomains: ['a.com'], maxUses: 0 }], + ['unknown property', { enabled: true, allowedDomains: ['a.com'], extra: true }], + ])('rejects %s', (_name, codex) => { + expect(validateWithSchema({ apiProxy: { hostedWeb: { codex } } }).length).toBeGreaterThan(0); + }); + + it('allows Claude and Codex policies together', () => { + expect(validateWithSchema({ + apiProxy: { + hostedWeb: { + claude: { enabled: true, blockedDomains: ['ads.example'] }, + codex: POLICY, + }, + }, + })).toEqual([]); + }); +}); + +describe('apiProxy.hostedWeb.codex config loading', () => { + let dir: string; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-codex-hosted-web-')); + }); + afterEach(() => fs.rmSync(dir, { recursive: true, force: true })); + + it('loads and maps identically from JSON/YAML files and stdin', () => { + const yaml = [ + 'apiProxy:', ' hostedWeb:', ' codex:', ' enabled: true', + ' allowedDomains:', ' - docs.github.com', ' - nodejs.org', + ' maxUses: 5', '', + ].join('\n'); + const jsonPath = path.join(dir, 'awf.json'); + const yamlPath = path.join(dir, 'awf.yaml'); + fs.writeFileSync(jsonPath, JSON.stringify(CONFIG)); + fs.writeFileSync(yamlPath, yaml); + + for (const loaded of [ + loadAwfFileConfig(jsonPath), + loadAwfFileConfig(yamlPath), + loadAwfFileConfig('-', () => JSON.stringify(CONFIG)), + loadAwfFileConfig('-', () => yaml), + ]) { + expect(loaded.apiProxy?.hostedWeb?.codex).toEqual(POLICY); + expect(mapAwfFileConfigToCliOptions(loaded).codexHostedWeb).toEqual(POLICY); + } + }); + + it('identifies stdin validation failures', () => { + expect(() => loadAwfFileConfig( + '-', + () => JSON.stringify({ apiProxy: { hostedWeb: { codex: { enabled: true } } } }), + )).toThrow('Invalid AWF config at stdin'); + }); +}); + +describe('Codex hosted-web normalization and sidecar environment', () => { + const env = (config: Partial) => + testHelpers.buildModelPolicyEnv(config as WrapperConfig); + + it('uses shared normalization and serializes only the explicit policy', () => { + const normalized = normalizeCodexHostedWebPolicy({ + enabled: true, + allowedDomains: ['Docs.GitHub.com', 'docs.github.com'], + maxUses: 3, + }); + expect(normalized).toEqual({ + enabled: true, mode: 'allow', domains: ['docs.github.com'], maxUses: 3, + }); + + const value = env({ + codexHostedWeb: POLICY, + allowedDomains: ['api.github.com'], + sensitiveAllowedDomains: ['secret.internal.example'], + }).AWF_CODEX_HOSTED_WEB_POLICY; + expect(JSON.parse(value)).toEqual({ + enabled: true, mode: 'allow', domains: ['docs.github.com', 'nodejs.org'], maxUses: 5, + }); + expect(value).not.toContain('api.github.com'); + expect(value).not.toContain('secret.internal.example'); + }); + + it('keeps omission as pass-through compatibility', () => { + expect(env({}).AWF_CODEX_HOSTED_WEB_POLICY).toBeUndefined(); + }); +}); diff --git a/src/codex-hosted-web-policy.ts b/src/codex-hosted-web-policy.ts new file mode 100644 index 000000000..34b31a3db --- /dev/null +++ b/src/codex-hosted-web-policy.ts @@ -0,0 +1,15 @@ +import { + HostedWebConfig, + NormalizedHostedWebPolicy, + normalizeHostedWebPolicy, +} from './hosted-web-policy'; + +export type CodexHostedWebConfig = HostedWebConfig; +export type NormalizedCodexHostedWebPolicy = NormalizedHostedWebPolicy; + +export function normalizeCodexHostedWebPolicy( + config: CodexHostedWebConfig | undefined, + source = 'config', +): NormalizedCodexHostedWebPolicy | undefined { + return normalizeHostedWebPolicy(config, 'codex', source); +} diff --git a/src/commands/build-config.ts b/src/commands/build-config.ts index 3fd7f3de7..ab9cd76a5 100644 --- a/src/commands/build-config.ts +++ b/src/commands/build-config.ts @@ -195,6 +195,7 @@ export function buildConfig(inputs: BuildConfigInputs): WrapperConfig { anthropicAutoCache: options.anthropicAutoCache as boolean, anthropicCacheTailTtl: options.anthropicCacheTailTtl as '5m' | '1h' | undefined, claudeHostedWeb: options.claudeHostedWeb as WrapperConfig['claudeHostedWeb'], + codexHostedWeb: options.codexHostedWeb as WrapperConfig['codexHostedWeb'], modelAliases, allowedModels, disallowedModels, diff --git a/src/config-file.ts b/src/config-file.ts index 5d3f851e1..f6f95b0c5 100644 --- a/src/config-file.ts +++ b/src/config-file.ts @@ -9,6 +9,7 @@ import type { } from './types/runtime-options'; import type { ModelRoutingConfig } from './types/api-proxy-routing-options'; import type { ClaudeHostedWebConfig } from './claude-hosted-web-policy'; +import type { CodexHostedWebConfig } from './codex-hosted-web-policy'; /** @internal Used only by config-file helpers — not part of public API */ // ts-prune-ignore-next @@ -35,6 +36,7 @@ export interface AwfFileConfig { anthropicCacheTailTtl?: string; hostedWeb?: { claude?: ClaudeHostedWebConfig; + codex?: CodexHostedWebConfig; }; maxEffectiveTokens?: number; maxAiCredits?: number; diff --git a/src/config-mapper.ts b/src/config-mapper.ts index 036969d8f..da23e1dd7 100644 --- a/src/config-mapper.ts +++ b/src/config-mapper.ts @@ -38,6 +38,7 @@ export function mapAwfFileConfigToCliOptions(config: AwfFileConfig): Record MAX_DOMAIN_LENGTH) return false; + const labels = value.split('.'); + if (labels.length < 2) return false; + if (labels.every((label) => DIGITS_ONLY.test(label))) return false; + return labels.every((label) => + label.length > 0 && + label.length <= MAX_LABEL_LENGTH && + LABEL_CHARS.test(label) && + !label.startsWith('-') && + !label.endsWith('-'), + ); +} + +function normalizeDomains(values: unknown, field: string, configPath: string, source: string): string[] { + if (!Array.isArray(values) || values.length === 0) { + throw new Error(`${source}: ${configPath}.${field} must be a non-empty array of domains`); + } + const normalized: string[] = []; + for (const value of values) { + if (typeof value !== 'string') { + throw new Error(`${source}: ${configPath}.${field} entries must be strings`); + } + const domain = value.trim().toLowerCase(); + if (!isValidHostedWebDomain(domain)) { + throw new Error( + `${source}: ${configPath}.${field} entry "${value}" is not a valid domain. ` + + 'Use a lowercase DNS hostname with at least two labels and no scheme, port, path, wildcard or IP address.', + ); + } + if (!normalized.includes(domain)) normalized.push(domain); + } + return normalized; +} + +export function normalizeHostedWebPolicy( + config: HostedWebConfig | undefined, + provider: 'claude' | 'codex', + source = 'config', +): NormalizedHostedWebPolicy | undefined { + const configPath = `apiProxy.hostedWeb.${provider}`; + if (config === undefined || config === null) return undefined; + if (typeof config !== 'object' || Array.isArray(config)) { + throw new Error(`${source}: ${configPath} must be an object`); + } + if (typeof config.enabled !== 'boolean') { + throw new Error(`${source}: ${configPath}.enabled is required and must be a boolean`); + } + + const hasAllow = config.allowedDomains !== undefined; + const hasBlock = config.blockedDomains !== undefined; + if (hasAllow && hasBlock) { + throw new Error(`${source}: ${configPath}.allowedDomains and blockedDomains are mutually exclusive`); + } + if (!config.enabled) { + if (hasAllow || hasBlock) { + throw new Error(`${source}: ${configPath}.allowedDomains/blockedDomains cannot be combined with enabled: false`); + } + return { enabled: false, mode: null, domains: [] }; + } + if (!hasAllow && !hasBlock) { + throw new Error(`${source}: ${configPath} requires exactly one of allowedDomains or blockedDomains when enabled is true`); + } + + const mode: 'allow' | 'block' = hasAllow ? 'allow' : 'block'; + const domains = normalizeDomains( + hasAllow ? config.allowedDomains : config.blockedDomains, + hasAllow ? 'allowedDomains' : 'blockedDomains', + configPath, + source, + ); + const policy: NormalizedHostedWebPolicy = { enabled: true, mode, domains }; + if (config.maxUses !== undefined) { + if (typeof config.maxUses !== 'number' || !Number.isInteger(config.maxUses) || config.maxUses < 1) { + throw new Error(`${source}: ${configPath}.maxUses must be a positive integer`); + } + policy.maxUses = config.maxUses; + } + return policy; +} diff --git a/src/services/api-proxy-env-config.ts b/src/services/api-proxy-env-config.ts index ef8587d2c..c6830b686 100644 --- a/src/services/api-proxy-env-config.ts +++ b/src/services/api-proxy-env-config.ts @@ -7,6 +7,7 @@ import { NetworkConfig } from './squid-service'; import { buildNoProxyEnv } from './no-proxy-utils'; import { resolveOpenAiBaseUrlFromEnv } from '../openai-base-url-env'; import { normalizeClaudeHostedWebPolicy } from '../claude-hosted-web-policy'; +import { normalizeCodexHostedWebPolicy } from '../codex-hosted-web-policy'; const DEFAULT_API_PROXY_SHUTDOWN_TIMEOUT_MS = 8000; export const API_PROXY_UPSTREAM_CA_CERT_CONTAINER_PATH = '/usr/local/share/ca-certificates/awf-upstream-ca.crt'; @@ -261,6 +262,7 @@ function buildModelPolicyEnv(config: WrapperConfig): Record { // Normalization throws on an invalid policy, which aborts the run before any // container starts rather than surfacing on the first Anthropic request. const claudeHostedWebPolicy = normalizeClaudeHostedWebPolicy(config.claudeHostedWeb); + const codexHostedWebPolicy = normalizeCodexHostedWebPolicy(config.codexHostedWeb); return { // Model alias configuration @@ -285,6 +287,9 @@ AWF_ROUTING_CONFIG: JSON.stringify(config.modelRouting), ...(claudeHostedWebPolicy && { AWF_CLAUDE_HOSTED_WEB_POLICY: JSON.stringify(claudeHostedWebPolicy), }), + ...(codexHostedWebPolicy && { + AWF_CODEX_HOSTED_WEB_POLICY: JSON.stringify(codexHostedWebPolicy), + }), // Anthropic prompt-cache optimizations ...(config.anthropicAutoCache && { AWF_ANTHROPIC_AUTO_CACHE: '1', diff --git a/src/types/api-proxy-diagnostics-options.ts b/src/types/api-proxy-diagnostics-options.ts index 1532974dd..7e155b3a4 100644 --- a/src/types/api-proxy-diagnostics-options.ts +++ b/src/types/api-proxy-diagnostics-options.ts @@ -138,4 +138,12 @@ export interface ApiProxyDiagnosticsOptions { * @default undefined (no enforcement — hosted web tools pass through) */ claudeHostedWeb?: ClaudeHostedWebConfig; + + /** + * Codex/OpenAI hosted web policy for Responses web-search tools and the + * standalone `/v1/alpha/search` route. Config-only. + * + * @default undefined (no enforcement — Codex hosted web passes through) + */ + codexHostedWeb?: import('../codex-hosted-web-policy').CodexHostedWebConfig; } From 687f6e1779541df5b347a163eda6fcbdee9f0298 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 24 Sep 2026 01:31:09 +0000 Subject: [PATCH 3/6] docs: document Codex hosted web policy --- README.md | 2 +- docs/api-proxy-sidecar.md | 44 ++++++++++++++ docs/awf-config-spec.md | 124 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 169 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 189ab62c3..8a0be9b28 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ use `awf --reflect`. It prints the `/reflect` JSON response to stdout. - **Declarative config support**: `--config ` with JSON/YAML + published JSON Schema - **Domain and URL controls**: allow/deny domain rules, SSL Bump (`--ssl-bump`), and URL patterns (`--allow-urls`, requires `--ssl-bump`) - **Data protection controls**: DLP scanning (`--enable-dlp`), DNS-over-HTTPS, and agent runtime limits (`--agent-timeout`) -- **API proxy capabilities**: OpenAI, Anthropic, Copilot, Gemini, and Google Vertex AI targets with rate limits, token steering, and Anthropic auto-cache +- **API proxy capabilities**: OpenAI, Anthropic, Copilot, Gemini, and Google Vertex AI targets with rate limits, token steering, Anthropic auto-cache, and `apiProxy.hostedWeb.{claude,codex}` domain policies - **Infrastructure flexibility**: upstream proxy chaining, host service access, Docker-in-Docker, custom mounts, memory limits, and TTY mode - **Operational tooling**: pre-download images and inspect logs/stats/summaries/audits from live or saved runs diff --git a/docs/api-proxy-sidecar.md b/docs/api-proxy-sidecar.md index 9cf40104d..c1380c2d2 100644 --- a/docs/api-proxy-sidecar.md +++ b/docs/api-proxy-sidecar.md @@ -165,6 +165,7 @@ The API proxy sidecar receives **real credentials** and routing configuration: | `GITHUB_RUN_ATTEMPT` | Forwarded GitHub Actions value | `GITHUB_RUN_ATTEMPT` set on host (GitHub Actions runs) | Paired with `GITHUB_RUN_ID` for the `X-Interaction-Id` derivation above; defaults to `1` if unset. | | `NODE_EXTRA_CA_CERTS` | `/usr/local/share/ca-certificates/awf-upstream-ca.crt` | `apiProxy.caCert` / `--api-proxy-ca-cert` set | Extends Node's trusted roots for private or corporate upstream gateways. | | `AWF_CLAUDE_HOSTED_WEB_POLICY` | Normalized policy JSON | `apiProxy.hostedWeb.claude` configured | AWF-owned domain policy for Anthropic-hosted `web_search_*`/`web_fetch_*` tools. Generated only from validated AWF config — never accepted from the agent. See [Claude hosted web search and fetch policy](#claude-hosted-web-search-and-fetch-policy). | +| `AWF_CODEX_HOSTED_WEB_POLICY` | Normalized policy JSON | `apiProxy.hostedWeb.codex` configured | AWF-owned domain policy for OpenAI Responses `web_search` tools and Codex `/v1/alpha/search`. Generated only from validated AWF config — never accepted from the agent. See [Codex hosted web policy](#codex-hosted-web-policy). | | `HTTP_PROXY` | `http://172.30.0.10:3128` | Always | Routes through Squid; sidecar traffic is exempt from domain ACLs | | `HTTPS_PROXY` | `http://172.30.0.10:3128` | Always | Routes through Squid; sidecar traffic is exempt from domain ACLs | @@ -454,6 +455,49 @@ This setting is config-only — there is no CLI flag. AWF serializes the validat See [§9.8 of the AWF config spec](./awf-config-spec.md#98-claude-hosted-web-search-and-fetch) for the complete precedence and error matrix. +### Codex hosted web policy + +Codex can retrieve hosted web content through two OpenAI surfaces: a Responses +`web_search` tool and the standalone `/v1/alpha/search` endpoint. The latter can +also scope individual `commands.search_query[]` entries and can fetch literal +URLs through `open`, `find`, and `screenshot` commands. In every case, OpenAI +performs retrieval outside AWF's network boundary, so Squid sees only the +OpenAI endpoint. + +Configure `apiProxy.hostedWeb.codex` to make the trusted sidecar enforce one +AWF-owned upper bound across both routes: + +```yaml +apiProxy: + hostedWeb: + codex: + enabled: true + allowedDomains: + - docs.github.com + - nodejs.org + maxUses: 5 +``` + +Responses tool filters are injected or narrowed after model/body transforms. +Standalone request filters are then applied to every query domain scope, and +literal URL hosts are checked against the effective result. OpenAI can +represent allowed and blocked filters together, so cross-mode request filters +are preserved and combined with the configured restriction rather than +rejected. The most restrictive applicable scope always governs. + +`maxUses` is emitted as `max_uses` on Responses tools. The standalone route has +no equivalent cap; when `maxUses` is configured, AWF rejects standalone search +instead of silently ignoring the limit. Disabled policies, empty intersections, +malformed filters/domains/URLs, unsupported access modes, and unknown hosted +search shapes fail closed with structured errors that exclude request content. +Ordinary OpenAI requests without a hosted-web surface remain unchanged. + +This setting is config-only. `AWF_CODEX_HOSTED_WEB_POLICY` is an internal value +generated from validated config, and invalid serialization fails sidecar +startup. Omitting `apiProxy.hostedWeb.codex` preserves pass-through behavior +and **does not constrain Codex-hosted egress**. See [§9.9 of the AWF config +spec](./awf-config-spec.md#99-codexopenai-hosted-web-policy). + ### Container configuration The sidecar container: diff --git a/docs/awf-config-spec.md b/docs/awf-config-spec.md index 106473f47..5a5ee9c70 100644 --- a/docs/awf-config-spec.md +++ b/docs/awf-config-spec.md @@ -225,6 +225,7 @@ AWF settings MAY be supplied via config files, including stdin (`--config -`). - `apiProxy.anthropicAutoCache` → `--anthropic-auto-cache` - `apiProxy.anthropicCacheTailTtl` → `--anthropic-cache-tail-ttl <5m|1h>` - `apiProxy.hostedWeb.claude` → *(config-only; maps to `AWF_CLAUDE_HOSTED_WEB_POLICY` — AWF-owned domain policy for Anthropic-hosted `web_search_*`/`web_fetch_*` server tools; see [§9.8 Claude Hosted Web Search and Fetch](#98-claude-hosted-web-search-and-fetch))* +- `apiProxy.hostedWeb.codex` → *(config-only; maps to `AWF_CODEX_HOSTED_WEB_POLICY` — AWF-owned domain policy for OpenAI Responses `web_search` and Codex `/v1/alpha/search`; see [§9.9 Codex/OpenAI Hosted Web Policy](#99-codexopenai-hosted-web-policy))* - `apiProxy.maxEffectiveTokens` → *(config-only; no CLI equivalent)* - `apiProxy.maxAiCredits` → *(config-only; maps to `AWF_MAX_AI_CREDITS`)* - `apiProxy.defaultAiCreditsPricing` → *(config-only; maps to `AWF_DEFAULT_AI_CREDITS_PRICING`)* @@ -990,6 +991,129 @@ A conforming implementation: definitions, domains, or `max_uses`), and MUST NOT include prompts, queries, URLs, or request bodies in those errors. +### 9.9 Codex/OpenAI Hosted Web Policy + +*This section is normative.* + +OpenAI-hosted retrieval executes beyond the AWF network boundary. Squid sees +the permitted OpenAI endpoint, not the searched or fetched destination. AWF +therefore enforces `apiProxy.hostedWeb.codex` inside the trusted API proxy on: + +1. Responses requests containing a `web_search` or dated + `web_search_YYYY_MM_DD` tool; and +2. every request to `/v1/alpha/search`. + +The config object is closed and references the same schema, normalization, and +source-precedence contract as `apiProxy.hostedWeb.claude`: `enabled` is +required; `enabled: true` requires exactly one non-empty `allowedDomains` or +`blockedDomains`; `enabled: false` permits neither; and `maxUses`, when present, +is a positive integer. Domains are normalized and validated identically for +both providers. The two policies may coexist in one config. + +```yaml +apiProxy: + hostedWeb: + claude: + enabled: true + blockedDomains: + - untrusted.example + codex: + enabled: true + allowedDomains: + - docs.github.com + - nodejs.org + maxUses: 5 +``` + +Configuration-source precedence is: + +1. an explicit CLI option, if one is added in the future; +2. the validated AWF document, including JSON/YAML via `--config -`; and +3. compatibility behavior. + +The initial implementation is config-only. `AWF_CODEX_HOSTED_WEB_POLICY` is an +internal transport generated from validated config and cannot independently +override it. Omission preserves pass-through behavior and **does not constrain +Codex-hosted egress**. + +#### 9.9.1 Request Precedence + +The configured policy is immutable. A request can only narrow it. OpenAI can +represent allowed and blocked filters together, so the canonical cross-mode +rule is to preserve the request's opposite-mode restriction while applying the +configured mode; no restriction is silently removed. + +| Config mode | Request filters | Effective filters | +|---|---|---| +| disabled | Any Responses hosted tool or standalone request | Reject with `codex_hosted_web_disabled` | +| allow | none | Inject configured `allowed_domains` | +| allow | `allowed_domains` | Intersect with configured allowlist; reject an empty result | +| allow | `blocked_domains` | Inject configured allowlist and preserve request blocklist | +| block | none | Inject configured `blocked_domains` | +| block | `blocked_domains` | Union with configured blocklist | +| block | `allowed_domains` | Preserve request allowlist and inject configured blocklist | + +Both request filter arrays, when present, must be non-empty valid domain lists. +On the standalone route, the effective `settings.filters` is applied again to +each `commands.search_query[].domains` and `commands.image_query[].domains` +list. An allowlist is intersected; blocked domains are removed; and an empty +effective query scope rejects with `codex_hosted_web_empty_query_scope` rather +than falling back to the request-level scope. + +Literal HTTP(S) URLs in `commands.open[].ref_id`, +`commands.find[].ref_id`, and `commands.screenshot[].ref_id` are host-checked +against both effective lists. Non-URL provider reference IDs remain valid. +A malformed URL/host rejects; a disallowed host rejects with +`codex_hosted_web_url_disallowed`. + +The **most restrictive** applicable configured, request, per-query, and URL +scope governs each retrieval. No scope can relocate or widen authority granted +by another. `network.allowDomains` and `network.sensitiveAllowDomains` are +never copied into provider policy or request bodies. + +#### 9.9.2 Access Modes, Limits, and Unknown Shapes + +Responses `external_web_access` and `indexed_web_access` values must be +booleans. Standalone `settings.external_web_access` accepts booleans or the +current `cached`, `indexed`, and `live` modes. Unknown future values fail closed +with `codex_hosted_web_access_invalid`; they are never interpreted as +permissive. + +For Responses tools, configured `maxUses` is injected as `max_uses`, or +resolved as `min(configured, requested)`. Malformed, zero, negative, or +non-integer request values reject. `/v1/alpha/search` exposes no equivalent +per-request cap, so a configured `maxUses` rejects that route with +`codex_hosted_web_max_uses_unsupported` rather than being silently ignored. + +Recognized standalone command families are `search_query`, `image_query`, +`open`, `click`, `find`, and `screenshot`. Unknown command shapes and malformed +dated hosted-tool names fail closed. Enforcement runs after existing OpenAI +body/model transforms and immediately before dispatch, so no later transform +can remove or expand the effective policy. + +#### 9.9.3 Stable Errors and Compiler Contract + +Policy failures use `invalid_request_error` envelopes with stable +`codex_hosted_web_*` codes: + +| Code | Meaning | +|---|---| +| `codex_hosted_web_disabled` | Hosted retrieval is prohibited | +| `codex_hosted_web_filter_invalid` / `codex_hosted_web_domain_invalid` | Malformed filters or domains | +| `codex_hosted_web_empty_intersection` / `codex_hosted_web_empty_query_scope` | Narrowing produced no permitted scope | +| `codex_hosted_web_url_invalid` / `codex_hosted_web_url_disallowed` | Literal URL is malformed or outside policy | +| `codex_hosted_web_access_invalid` | Unknown hosted-access mode | +| `codex_hosted_web_max_uses_invalid` / `codex_hosted_web_max_uses_unsupported` | Invalid cap or route cannot enforce it | +| `codex_hosted_web_tool_unrecognized` / `codex_hosted_web_command_unrecognized` / `codex_hosted_web_shape_invalid` | Unknown future or malformed hosted-search shape | + +Errors do not include prompts, queries, literal URLs, or request bodies. +Serialized internal policy is validated during sidecar startup. Ordinary +OpenAI-compatible requests without a hosted-web surface are unchanged. + +Compiler integrations only emit the stable config and pipe it to +`awf --config - -- `; they do not need to understand provider routes, +body fields, tool versions, or internal environment variables. + ## 10. Effective Token Budget Enforcement *This section is normative.* From d87697232ef1cd5875fdee747fb2631dfe36ab26 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 24 Sep 2026 01:37:24 +0000 Subject: [PATCH 4/6] fix: fail closed on unknown Codex search fields --- containers/api-proxy/codex-hosted-web.js | 49 ++++++++++++++++++- containers/api-proxy/codex-hosted-web.test.js | 20 ++++++++ containers/api-proxy/server.models.test.js | 9 ++++ 3 files changed, 77 insertions(+), 1 deletion(-) diff --git a/containers/api-proxy/codex-hosted-web.js b/containers/api-proxy/codex-hosted-web.js index e7a51b3cd..a8c5d7d5e 100644 --- a/containers/api-proxy/codex-hosted-web.js +++ b/containers/api-proxy/codex-hosted-web.js @@ -12,6 +12,23 @@ const WEB_SEARCH_CANDIDATE = /^web_search(?:_|$)/; const SEARCH_PATHS = new Set(['/v1/alpha/search', '/alpha/search']); const SEARCH_COMMANDS = new Set(['search_query', 'image_query', 'open', 'click', 'find', 'screenshot']); const URL_COMMANDS = new Set(['open', 'find', 'screenshot']); +const FILTER_FIELDS = new Set(['allowed_domains', 'blocked_domains']); +const TOOL_FIELDS = new Set([ + 'type', 'external_web_access', 'indexed_web_access', 'filters', 'user_location', + 'search_context_size', 'search_content_types', 'image_settings', 'max_uses', +]); +const SETTINGS_FIELDS = new Set([ + 'user_location', 'search_context_size', 'filters', 'image_settings', + 'allowed_callers', 'external_web_access', +]); +const COMMAND_FIELDS = { + search_query: new Set(['q', 'recency', 'domains']), + image_query: new Set(['q', 'recency', 'domains']), + open: new Set(['ref_id', 'lineno']), + click: new Set(['ref_id', 'id']), + find: new Set(['ref_id', 'pattern']), + screenshot: new Set(['ref_id', 'pageno']), +}; class CodexHostedWebPolicyError extends Error { constructor(code, message, statusCode = 403) { @@ -30,6 +47,12 @@ function hasField(value, field) { return Object.prototype.hasOwnProperty.call(value, field) && value[field] !== undefined; } +function rejectUnknownFields(value, allowed, code, description) { + if (Object.keys(value).some(field => !allowed.has(field))) { + throw new CodexHostedWebPolicyError(code, description, 400); + } +} + function readDomains(value, field) { if (!Array.isArray(value) || value.length === 0) { throw new CodexHostedWebPolicyError( @@ -62,6 +85,12 @@ function resolveFilters(policy, filters) { ); } const input = filters || {}; + rejectUnknownFields( + input, + FILTER_FIELDS, + 'codex_hosted_web_filter_invalid', + 'Codex hosted web filters contain an unrecognized field.', + ); const hasAllowed = hasField(input, 'allowed_domains'); const hasBlocked = hasField(input, 'blocked_domains'); const requestedAllowed = hasAllowed ? readDomains(input.allowed_domains, 'allowed_domains') : null; @@ -136,6 +165,12 @@ function enforceResponses(body, policy) { 400, ); } + rejectUnknownFields( + tool, + TOOL_FIELDS, + 'codex_hosted_web_tool_unrecognized', + 'Codex hosted web tool contains an unrecognized field.', + ); validateAccessMode(tool.external_web_access, 'external_web_access'); validateAccessMode(tool.indexed_web_access, 'indexed_web_access'); const result = { ...tool, filters: resolveFilters(policy, tool.filters) }; @@ -228,6 +263,12 @@ function enforceStandalone(body, policy) { 400, ); } + rejectUnknownFields( + settings, + SETTINGS_FIELDS, + 'codex_hosted_web_shape_invalid', + 'Codex standalone hosted search settings contain an unrecognized field.', + ); validateAccessMode(settings.external_web_access, 'external_web_access', ['cached', 'indexed', 'live']); const filters = resolveFilters(policy, settings.filters); const commands = body.commands === undefined ? {} : body.commands; @@ -255,6 +296,12 @@ function enforceStandalone(body, policy) { 400, ); } + rejectUnknownFields( + entry, + COMMAND_FIELDS[name], + 'codex_hosted_web_command_unrecognized', + 'Codex standalone hosted search contains an unrecognized command field.', + ); if (name === 'search_query' || name === 'image_query') return narrowQueryDomains(entry, filters); if (URL_COMMANDS.has(name)) checkLiteralUrl(entry.ref_id, filters); return entry; @@ -272,7 +319,7 @@ function makeCodexHostedWebTransform(policy) { return (bodyBuffer, req) => { let pathname = ''; try { - pathname = new URL(req?.url || '/', 'http://localhost').pathname; + pathname = new URL(req?.url || '/', 'http://localhost').pathname.replace(/\/+$/, '') || '/'; } catch {} const standalone = SEARCH_PATHS.has(pathname); let body; diff --git a/containers/api-proxy/codex-hosted-web.test.js b/containers/api-proxy/codex-hosted-web.test.js index 630de63c8..377441314 100644 --- a/containers/api-proxy/codex-hosted-web.test.js +++ b/containers/api-proxy/codex-hosted-web.test.js @@ -66,6 +66,15 @@ describe('Codex hosted-web policy', () => { }); }); + it('fails closed on unknown hosted tool and filter fields', () => { + expect(() => enforceResponses({ + tools: [{ type: 'web_search', filters: { include_domains: ['evil.example'] } }], + }, allow)).toThrow(expect.objectContaining({ code: 'codex_hosted_web_filter_invalid' })); + expect(() => enforceResponses({ + tools: [{ type: 'web_search', future_access: true }], + }, allow)).toThrow(expect.objectContaining({ code: 'codex_hosted_web_tool_unrecognized' })); + }); + it.each([ [{ enabled: false, mode: null, domains: [] }, 'codex_hosted_web_disabled'], [allow, 'codex_hosted_web_empty_intersection'], @@ -121,6 +130,10 @@ describe('Codex hosted-web policy', () => { commands: { future_fetch: [{}] }, }, { ...allow, maxUses: undefined })) .toThrow(expect.objectContaining({ code: 'codex_hosted_web_command_unrecognized' })); + expect(() => enforceStandalone({ + settings: { future_access: true }, + }, { ...allow, maxUses: undefined })) + .toThrow(expect.objectContaining({ code: 'codex_hosted_web_shape_invalid' })); }); it('rejects unsupported access modes and unsupported standalone maxUses', () => { @@ -142,5 +155,12 @@ describe('Codex hosted-web policy', () => { commands: {}, settings: { filters: { allowed_domains: ['docs.github.com'] } }, }); + expect(JSON.parse(transform( + Buffer.from(JSON.stringify({ commands: {} })), + { url: '/v1/alpha/search/' }, + ))).toEqual({ + commands: {}, + settings: { filters: { allowed_domains: ['docs.github.com'] } }, + }); }); }); diff --git a/containers/api-proxy/server.models.test.js b/containers/api-proxy/server.models.test.js index 0ceabd960..b1ec81bcc 100644 --- a/containers/api-proxy/server.models.test.js +++ b/containers/api-proxy/server.models.test.js @@ -701,4 +701,13 @@ describe('composeBodyTransforms', () => { const out = await composed(Buffer.from('hello')); expect(out.toString()).toBe('HELLO!'); }); + + it('forwards request context to every composed transform', () => { + const req = { url: '/v1/alpha/search' }; + const first = jest.fn(() => null); + const second = jest.fn(() => null); + composeBodyTransforms(first, second)(Buffer.from('hello'), req); + expect(first).toHaveBeenCalledWith(expect.any(Buffer), req); + expect(second).toHaveBeenCalledWith(expect.any(Buffer), req); + }); }); From a9e935cca9c7586487cf904def885c8eecc4e0d4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 24 Sep 2026 01:55:43 +0000 Subject: [PATCH 5/6] fix: harden Codex hosted web policy --- containers/api-proxy/Dockerfile | 2 +- containers/api-proxy/codex-hosted-web.js | 18 +++++++++++++++++- containers/api-proxy/codex-hosted-web.test.js | 14 ++++++++++++++ docs/awf-config.schema.json | 3 ++- src/awf-config-schema.json | 3 ++- src/codex-hosted-web-policy.test.ts | 2 ++ 6 files changed, 38 insertions(+), 4 deletions(-) diff --git a/containers/api-proxy/Dockerfile b/containers/api-proxy/Dockerfile index ee0bb6d5b..58da825fc 100644 --- a/containers/api-proxy/Dockerfile +++ b/containers/api-proxy/Dockerfile @@ -41,7 +41,7 @@ RUN npm ci --omit=dev COPY server.js logging.js metrics.js rate-limiter.js rate-limiter-window.js \ token-tracker.js token-persistence.js token-parsers.js \ token-tracker-http.js token-tracker-ws.js token-tracker-shared.js \ - model-resolver.js model-fallback.js model-utils.js model-body-rewriter.js proxy-utils.js oidc-adapter-utils.js adapter-factory.js anthropic-transforms.js claude-hosted-web.js \ + model-resolver.js model-fallback.js model-utils.js model-body-rewriter.js proxy-utils.js oidc-adapter-utils.js adapter-factory.js anthropic-transforms.js claude-hosted-web.js codex-hosted-web.js hosted-web-policy.js \ model-config.js key-validation.js server-factory.js startup.js \ proxy-request.js request-headers.js upstream-http.js proxy-guards.js proxy-error-handler.js http-client.js body-handler.js model-discovery.js management.js oidc-token-provider.js \ oidc-token-provider-base.js \ diff --git a/containers/api-proxy/codex-hosted-web.js b/containers/api-proxy/codex-hosted-web.js index a8c5d7d5e..1ffa8ee51 100644 --- a/containers/api-proxy/codex-hosted-web.js +++ b/containers/api-proxy/codex-hosted-web.js @@ -13,6 +13,7 @@ const SEARCH_PATHS = new Set(['/v1/alpha/search', '/alpha/search']); const SEARCH_COMMANDS = new Set(['search_query', 'image_query', 'open', 'click', 'find', 'screenshot']); const URL_COMMANDS = new Set(['open', 'find', 'screenshot']); const FILTER_FIELDS = new Set(['allowed_domains', 'blocked_domains']); +const STANDALONE_FIELDS = new Set(['settings', 'commands']); const TOOL_FIELDS = new Set([ 'type', 'external_web_access', 'indexed_web_access', 'filters', 'user_location', 'search_context_size', 'search_content_types', 'image_settings', 'max_uses', @@ -209,7 +210,16 @@ function narrowQueryDomains(query, filters) { } function checkLiteralUrl(value, filters) { - if (typeof value !== 'string' || !value.includes('://')) return; + if (typeof value !== 'string') return; + const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(value); + if (!scheme) return; + if (scheme[1].toLowerCase() !== 'http' && scheme[1].toLowerCase() !== 'https') { + throw new CodexHostedWebPolicyError( + 'codex_hosted_web_url_invalid', + 'A Codex hosted web command contains an invalid HTTP(S) URL host.', + 400, + ); + } let parsed; try { parsed = new URL(value); @@ -255,6 +265,12 @@ function enforceStandalone(body, policy) { 400, ); } + rejectUnknownFields( + body, + STANDALONE_FIELDS, + 'codex_hosted_web_shape_invalid', + 'Codex standalone hosted search body contains an unrecognized field.', + ); const settings = body.settings === undefined ? {} : body.settings; if (!settings || typeof settings !== 'object' || Array.isArray(settings)) { throw new CodexHostedWebPolicyError( diff --git a/containers/api-proxy/codex-hosted-web.test.js b/containers/api-proxy/codex-hosted-web.test.js index 377441314..df799503c 100644 --- a/containers/api-proxy/codex-hosted-web.test.js +++ b/containers/api-proxy/codex-hosted-web.test.js @@ -122,6 +122,16 @@ describe('Codex hosted-web policy', () => { .toThrow(expect.objectContaining({ code: 'codex_hosted_web_url_disallowed' })); }); + it.each(['https:evil.example/private', 'https:/evil.example/private'])( + 'checks normalized HTTP(S) URLs in command reference IDs: %s', + refId => { + expect(() => enforceStandalone({ + commands: { open: [{ ref_id: refId }] }, + }, { ...allow, maxUses: undefined })) + .toThrow(expect.objectContaining({ code: 'codex_hosted_web_url_disallowed' })); + }, + ); + it('allows non-URL reference IDs and rejects unknown command shapes', () => { expect(enforceStandalone({ commands: { open: [{ ref_id: 'turn0search0' }] }, @@ -134,6 +144,10 @@ describe('Codex hosted-web policy', () => { settings: { future_access: true }, }, { ...allow, maxUses: undefined })) .toThrow(expect.objectContaining({ code: 'codex_hosted_web_shape_invalid' })); + expect(() => enforceStandalone({ + future_retrieval: { url: 'https://evil.example' }, + }, { ...allow, maxUses: undefined })) + .toThrow(expect.objectContaining({ code: 'codex_hosted_web_shape_invalid' })); }); it('rejects unsupported access modes and unsupported standalone maxUses', () => { diff --git a/docs/awf-config.schema.json b/docs/awf-config.schema.json index 128868403..d7369fc5e 100644 --- a/docs/awf-config.schema.json +++ b/docs/awf-config.schema.json @@ -161,7 +161,8 @@ "description": "Provider blocklist lower bound sent to Anthropic as blocked_domains. Mutually exclusive with allowedDomains (Anthropic rejects both on one tool definition). Same domain syntax as allowedDomains. A request may add further blocked domains (union) but can never remove a configured one.", "items": { "type": "string", - "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$" + "maxLength": 253, + "pattern": "^(?![0-9.]+$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(\\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$" } }, "maxUses": { diff --git a/src/awf-config-schema.json b/src/awf-config-schema.json index 128868403..d7369fc5e 100644 --- a/src/awf-config-schema.json +++ b/src/awf-config-schema.json @@ -161,7 +161,8 @@ "description": "Provider blocklist lower bound sent to Anthropic as blocked_domains. Mutually exclusive with allowedDomains (Anthropic rejects both on one tool definition). Same domain syntax as allowedDomains. A request may add further blocked domains (union) but can never remove a configured one.", "items": { "type": "string", - "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$" + "maxLength": 253, + "pattern": "^(?![0-9.]+$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(\\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$" } }, "maxUses": { diff --git a/src/codex-hosted-web-policy.test.ts b/src/codex-hosted-web-policy.test.ts index c6d7d5458..5d98f005c 100644 --- a/src/codex-hosted-web-policy.test.ts +++ b/src/codex-hosted-web-policy.test.ts @@ -26,6 +26,8 @@ describe('apiProxy.hostedWeb.codex schema', () => { ['disabled with domains', { enabled: false, allowedDomains: ['a.com'] }], ['empty list', { enabled: true, allowedDomains: [] }], ['invalid domain', { enabled: true, allowedDomains: ['https://a.com'] }], + ['raw IPv4 blocked domain', { enabled: true, blockedDomains: ['192.0.2.1'] }], + ['overlong-label blocked domain', { enabled: true, blockedDomains: [`${'a'.repeat(64)}.example`] }], ['invalid maxUses', { enabled: true, allowedDomains: ['a.com'], maxUses: 0 }], ['unknown property', { enabled: true, allowedDomains: ['a.com'], extra: true }], ])('rejects %s', (_name, codex) => { From a46c89169230105d996331782ccd57d7bbad810f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 24 Sep 2026 02:17:09 +0000 Subject: [PATCH 6/6] fix: allow top-level id/model fields in Codex standalone search body --- containers/api-proxy/codex-hosted-web.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/containers/api-proxy/codex-hosted-web.js b/containers/api-proxy/codex-hosted-web.js index 1ffa8ee51..ee84b808e 100644 --- a/containers/api-proxy/codex-hosted-web.js +++ b/containers/api-proxy/codex-hosted-web.js @@ -13,7 +13,7 @@ const SEARCH_PATHS = new Set(['/v1/alpha/search', '/alpha/search']); const SEARCH_COMMANDS = new Set(['search_query', 'image_query', 'open', 'click', 'find', 'screenshot']); const URL_COMMANDS = new Set(['open', 'find', 'screenshot']); const FILTER_FIELDS = new Set(['allowed_domains', 'blocked_domains']); -const STANDALONE_FIELDS = new Set(['settings', 'commands']); +const STANDALONE_FIELDS = new Set(['id', 'model', 'settings', 'commands']); const TOOL_FIELDS = new Set([ 'type', 'external_web_access', 'indexed_web_access', 'filters', 'user_location', 'search_context_size', 'search_content_types', 'image_settings', 'max_uses',