diff --git a/containers/api-proxy/adapter-factory.js b/containers/api-proxy/adapter-factory.js index 86231dd78..8cc6999c0 100644 --- a/containers/api-proxy/adapter-factory.js +++ b/containers/api-proxy/adapter-factory.js @@ -139,7 +139,7 @@ function createAdapterMethods(opts) { if (skip) return skip; if (!credentialConfigured) return null; if (defaultTarget && rawTarget !== defaultTarget) { - return { skip: true, reason: `Custom target ${rawTarget}; validation skipped` }; + return { skip: true, reason: 'Custom target; validation skipped' }; } return { url: `https://${rawTarget}${validationPath}`, diff --git a/containers/api-proxy/logging.js b/containers/api-proxy/logging.js index 9741ac7c1..8da647336 100644 --- a/containers/api-proxy/logging.js +++ b/containers/api-proxy/logging.js @@ -39,11 +39,19 @@ function sanitizeForLog(str, maxLen = 200) { * @param {object} [fields] - Additional key/value pairs merged into the log line */ function logRequest(level, event, fields = {}) { + const sensitiveTarget = (process.env.AWF_SENSITIVE_OPENAI_TARGET || '').trim(); + const redact = (value) => { + if (!sensitiveTarget || typeof value !== 'string') return value; + return value.replace( + new RegExp(sensitiveTarget.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi'), + '[REDACTED]' + ); + }; const line = { timestamp: new Date().toISOString(), level, event, - ...fields, + ...Object.fromEntries(Object.entries(fields).map(([key, value]) => [key, redact(value)])), }; // Single JSON line to stdout — tee handles file persistence process.stdout.write(JSON.stringify(line) + '\n'); diff --git a/containers/api-proxy/logging.test.js b/containers/api-proxy/logging.test.js index ba80ee851..e7fb47e2b 100644 --- a/containers/api-proxy/logging.test.js +++ b/containers/api-proxy/logging.test.js @@ -90,6 +90,23 @@ describe('logging', () => { expect(parsed.provider).toBe('openai'); }); + it('redacts the sensitive OpenAI target from every logged string field', () => { + const previous = process.env.AWF_SENSITIVE_OPENAI_TARGET; + process.env.AWF_SENSITIVE_OPENAI_TARGET = ['lb', 'secret', 'example', 'com'].join('.'); + try { + logRequest('info', 'request_complete', { + upstream_host: 'LB.Secret.Example.Com', + message: 'Custom target lb.secret.example.com; validation skipped', + }); + const parsed = JSON.parse(stdoutSpy.mock.calls[0][0]); + expect(JSON.stringify(parsed)).not.toContain('secret.example.com'); + expect(parsed.upstream_host).toBe('[REDACTED]'); + } finally { + if (previous === undefined) delete process.env.AWF_SENSITIVE_OPENAI_TARGET; + else process.env.AWF_SENSITIVE_OPENAI_TARGET = previous; + } + }); + it('should not include undefined fields', () => { logRequest('info', 'test', { a: undefined, b: 'value' }); const parsed = JSON.parse(stdoutSpy.mock.calls[0][0]); diff --git a/docs-site/src/content/docs/reference/cli-reference.md b/docs-site/src/content/docs/reference/cli-reference.md index bc5284322..210645190 100644 --- a/docs-site/src/content/docs/reference/cli-reference.md +++ b/docs-site/src/content/docs/reference/cli-reference.md @@ -791,6 +791,23 @@ sudo -E awf --enable-api-proxy \ -- command ``` +### `--openai-base-url-env ` + +Name of a **runner** environment variable (typically bound to a secret) whose value is the base URL of a private OpenAI-compatible endpoint. AWF reads and validates the URL on the runner before any container starts, derives the upstream host and base path for the api-proxy sidecar, adds the host to the Squid policy, excludes the variable from the agent environment, and redacts the URL/host/`host:port` forms from logs and audit artifacts. + +The value must be an absolute `https://` URL without credentials, query string, fragment, or a non-default port. Invalid or missing values fail before agent startup with an error that does not echo the value. + +Config path: `apiProxy.targets.openai.baseUrlEnv`. Takes precedence over `--openai-api-target` / `--openai-api-base-path`. + +- **Default:** none +- **Requires:** `--enable-api-proxy` + +```bash +sudo -E awf --enable-api-proxy \ + --openai-base-url-env CODEX_LB_BASE_URL \ + -- codex exec "..." +``` + ### `--openai-api-base-path ` Base path prefix prepended to every upstream OpenAI API request path. Use this when the upstream endpoint requires a URL prefix (e.g., Databricks serving endpoints, Azure OpenAI deployments). Can also be set via the `OPENAI_API_BASE_PATH` environment variable. @@ -1057,6 +1074,7 @@ These variables provide an alternative to the corresponding CLI flags for config | `COPILOT_API_TARGET` | `api.githubcopilot.com` | Copilot API endpoint override | | `OPENAI_API_TARGET` | `api.openai.com` | OpenAI API endpoint override | | `OPENAI_API_BASE_PATH` | _(empty)_ | OpenAI API base path (e.g., `/serving-endpoints`) | +| `OPENAI_BASE_URL_ENV` | _(unset)_ | Name of the runner variable holding a secret OpenAI-compatible base URL (see `--openai-base-url-env`) | | `ANTHROPIC_API_TARGET` | `api.anthropic.com` | Anthropic API endpoint override | | `ANTHROPIC_API_BASE_PATH` | _(empty)_ | Anthropic API base path | diff --git a/docs/api-proxy-sidecar.md b/docs/api-proxy-sidecar.md index 050388b17..f68e81d34 100644 --- a/docs/api-proxy-sidecar.md +++ b/docs/api-proxy-sidecar.md @@ -355,6 +355,7 @@ These entries document and constrain agent-originated traffic. They do not const | Flag | Default | Description | |------|---------|-------------| | `--openai-api-target ` | `api.openai.com` | Custom upstream for OpenAI API requests (e.g. Azure OpenAI or an internal LLM router). Can also be set via `OPENAI_API_TARGET` env var (or `OPENAI_ENDPOINT_OVERRIDE` for runtime secret-backed endpoint injection). | +| `--openai-base-url-env ` | none | Name of a runner environment variable (typically a secret) holding the full OpenAI-compatible base URL. AWF validates it on the runner, derives the sidecar target and base path, allows the host through Squid, hides the variable from the agent, and redacts it from logs and audit artifacts. Config path: `apiProxy.targets.openai.baseUrlEnv`. | | `--anthropic-api-target ` | `api.anthropic.com` | Custom upstream for Anthropic API requests (e.g. an internal Claude router). Can also be set via `ANTHROPIC_API_TARGET` env var. | | `--copilot-api-target ` | auto-derived | Custom upstream for GitHub Copilot API requests (useful for GHES). Can also be set via `COPILOT_API_TARGET` env var. | | `--gemini-api-target ` | `generativelanguage.googleapis.com` | Custom upstream for Gemini API requests. Can also be set via `GEMINI_API_TARGET` env var. | diff --git a/docs/awf-config-spec.md b/docs/awf-config-spec.md index fa09dd221..a7b211982 100644 --- a/docs/awf-config-spec.md +++ b/docs/awf-config-spec.md @@ -190,6 +190,7 @@ AWF settings MAY be supplied via config files, including stdin (`--config -`). - `apiProxy.targets.copilot.sessionId` → *(config-only; opt-in `x-session-id` header / `session_id` body field for Copilot BYOK requests, maps to `AWF_PROVIDER_SESSION_ID`. Never auto-derived from `GITHUB_RUN_ID`.)* - `apiProxy.targets.openai.basePath` → `--openai-api-base-path` - `apiProxy.targets.openai.authHeader` → `--openai-api-auth-header` +- `apiProxy.targets.openai.baseUrlEnv` → `--openai-base-url-env` *(names a runner environment variable holding a secret OpenAI-compatible base URL; see [§9.7 Secret-Backed OpenAI Target](#97-secret-backed-openai-target))* - `apiProxy.targets.anthropic.basePath` → `--anthropic-api-base-path` - `apiProxy.targets.anthropic.authHeader` → `--anthropic-api-auth-header` - `apiProxy.targets.gemini.basePath` → `--gemini-api-base-path` @@ -664,6 +665,50 @@ host and port and never receives `GITHUB_TOKEN` or `GH_TOKEN`. When the DIFC proxy is an attached sibling container already reachable on `awf-net`, no relay is created. +### 9.7 Secret-Backed OpenAI Target + +`apiProxy.targets.openai.baseUrlEnv` names a **runner** environment variable +(typically bound to `${{ secrets.* }}` by the gh-aw compiler) whose value is the +base URL of a private OpenAI-compatible endpoint. This allows `engine: codex` +workflows to route through a sensitive endpoint without writing the URL into +workflow source or generated lockfiles. + +```json +{ + "apiProxy": { + "targets": { + "openai": { + "baseUrlEnv": "CODEX_LB_BASE_URL" + } + } + } +} +``` + +A conforming implementation: + +1. MUST read the named variable only in runner-side configuration code, before + any container starts. +2. MUST require an absolute `https://` URL and MUST reject embedded credentials + (`user:pass@`), query strings, fragments, malformed hosts, unsupported + schemes, and non-default ports (the sidecar connects on port 443). +3. MUST derive the host, `host:port`, and optional base path from the URL. +4. MUST add the derived destination to the effective Squid policy without + persisting it in repository configuration, and MUST keep it out of + `allowedDomains` (it is carried in the sensitive allowlist instead). +5. MUST configure the OpenAI api-proxy adapter with the derived host + (`OPENAI_API_TARGET`) and base path (`OPENAI_API_BASE_PATH`); the derived + values take precedence over `apiProxy.targets.openai.host`/`basePath`. +6. MUST exclude the named variable from the primary agent environment, including + under `--env-all`. +7. MUST redact the URL, host, and `host:port` forms from logs, diagnostics, and + uploaded audit artifacts (`squid.conf`, `docker-compose.redacted.yml`). +8. MUST fail before agent startup, with an error message that does not contain + the value, when the variable is unset or invalid. + +The same rules apply to agent and detection phases, which share this +configuration path. + ## 10. Effective Token Budget Enforcement *This section is normative.* diff --git a/docs/awf-config.schema.json b/docs/awf-config.schema.json index 60d95db24..98233a86c 100644 --- a/docs/awf-config.schema.json +++ b/docs/awf-config.schema.json @@ -235,7 +235,7 @@ "additionalProperties": false, "properties": { "openai": { - "$ref": "#/$defs/providerTarget", + "$ref": "#/$defs/openaiTarget", "description": "OpenAI API target override." }, "anthropic": { @@ -1135,6 +1135,30 @@ } } }, + "openaiTarget": { + "type": "object", + "description": "OpenAI API target override, optionally resolved from a runner environment variable at runtime.", + "additionalProperties": false, + "properties": { + "host": { + "type": "string", + "description": "Override the OpenAI API host." + }, + "basePath": { + "type": "string", + "description": "Override the OpenAI API base path." + }, + "authHeader": { + "type": "string", + "description": "Override the auth header name used for API requests. Replaces 'Authorization: ******' with ': '." + }, + "baseUrlEnv": { + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", + "description": "Name of a runner environment variable (typically bound to a secret) holding the OpenAI-compatible base URL. AWF resolves and validates it before containers start, derives the host for network policy, keeps it out of the agent environment, and redacts it from logs and artifacts. Takes precedence over host/basePath." + } + } + }, "providerHostOnlyTarget": { "type": "object", "description": "API provider target override (host only; basePath not supported).", diff --git a/src/api-proxy-config-domains.test.ts b/src/api-proxy-config-domains.test.ts index ae7dd3120..c5b5c8ffe 100644 --- a/src/api-proxy-config-domains.test.ts +++ b/src/api-proxy-config-domains.test.ts @@ -64,6 +64,21 @@ describe('resolveApiTargetsToAllowedDomains', () => { expect(sensitive).toContain('https://secret.openai.internal'); }); + it('prefers a secret-backed OpenAI base URL over configured and environment targets', () => { + const domains: string[] = []; + const sensitive: string[] = []; + resolveApiTargetsToAllowedDomains( + { openaiBaseUrl: 'https://secret.openai.internal/v1', openaiApiTarget: 'configured.openai.com' }, + domains, + { OPENAI_API_TARGET: 'env.openai.com' }, + () => {}, + sensitive, + ); + expect(sensitive).toContain('https://secret.openai.internal'); + expect(domains).not.toContain('https://configured.openai.com'); + expect(domains).not.toContain('https://env.openai.com'); + }); + it('should use pre-resolved openaiEndpointOverride param over env fallback', () => { const domains: string[] = []; const sensitive: string[] = []; diff --git a/src/api-proxy-config-domains.ts b/src/api-proxy-config-domains.ts index 0870c4b1e..0d65d4201 100644 --- a/src/api-proxy-config-domains.ts +++ b/src/api-proxy-config-domains.ts @@ -117,6 +117,8 @@ function extractGhesDomainsFromEngineApiTarget( export function resolveApiTargetsToAllowedDomains( options: { copilotApiTarget?: string; + /** Secret-backed OpenAI endpoint, which takes precedence over every other target source. */ + openaiBaseUrl?: string; openaiApiTarget?: string; anthropicApiTarget?: string; geminiApiTarget?: string; @@ -136,7 +138,9 @@ export function resolveApiTargetsToAllowedDomains( apiTargets.push({ value: env['COPILOT_API_TARGET'] }); } - if (options.openaiApiTarget) { + if (options.openaiBaseUrl) { + apiTargets.push({ value: options.openaiBaseUrl, sensitive: true }); + } else if (options.openaiApiTarget) { apiTargets.push({ value: options.openaiApiTarget }); } else if (env['OPENAI_API_TARGET']) { apiTargets.push({ value: env['OPENAI_API_TARGET'] }); diff --git a/src/awf-config-schema.json b/src/awf-config-schema.json index 60d95db24..98233a86c 100644 --- a/src/awf-config-schema.json +++ b/src/awf-config-schema.json @@ -235,7 +235,7 @@ "additionalProperties": false, "properties": { "openai": { - "$ref": "#/$defs/providerTarget", + "$ref": "#/$defs/openaiTarget", "description": "OpenAI API target override." }, "anthropic": { @@ -1135,6 +1135,30 @@ } } }, + "openaiTarget": { + "type": "object", + "description": "OpenAI API target override, optionally resolved from a runner environment variable at runtime.", + "additionalProperties": false, + "properties": { + "host": { + "type": "string", + "description": "Override the OpenAI API host." + }, + "basePath": { + "type": "string", + "description": "Override the OpenAI API base path." + }, + "authHeader": { + "type": "string", + "description": "Override the auth header name used for API requests. Replaces 'Authorization: ******' with ': '." + }, + "baseUrlEnv": { + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", + "description": "Name of a runner environment variable (typically bound to a secret) holding the OpenAI-compatible base URL. AWF resolves and validates it before containers start, derives the host for network policy, keeps it out of the agent environment, and redacts it from logs and artifacts. Takes precedence over host/basePath." + } + } + }, "providerHostOnlyTarget": { "type": "object", "description": "API provider target override (host only; basePath not supported).", diff --git a/src/cli-options.ts b/src/cli-options.ts index 03c173b46..bc8dae9e1 100644 --- a/src/cli-options.ts +++ b/src/cli-options.ts @@ -348,6 +348,10 @@ program '--openai-api-target ', 'Target hostname for OpenAI API requests (default: api.openai.com)', ) + .option( + '--openai-base-url-env ', + 'Name of a runner environment variable holding a secret OpenAI-compatible base URL', + ) .option( '--openai-api-base-path ', 'Base path prefix for OpenAI API requests (e.g. /serving-endpoints for Databricks)', diff --git a/src/commands/preflight.test.ts b/src/commands/preflight.test.ts index 0f7b2e935..de483b847 100644 --- a/src/commands/preflight.test.ts +++ b/src/commands/preflight.test.ts @@ -410,6 +410,66 @@ describe('resolveAllowedDomains', () => { ); }); + it('passes the secret-backed base URL from openaiBaseUrlEnv to resolveApiTargetsToAllowedDomains', () => { + const saved = process.env.CODEX_LB_BASE_URL; + process.env.CODEX_LB_BASE_URL = 'https://lb.internal.example.com/v1'; + try { + resolveAllowedDomains({ openaiBaseUrlEnv: 'CODEX_LB_BASE_URL' }); + expect(mockedApiProxyConfig.resolveApiTargetsToAllowedDomains).toHaveBeenCalledWith( + expect.objectContaining({ openaiBaseUrl: 'https://lb.internal.example.com/v1' }), + expect.any(Array), + expect.any(Object), + expect.any(Function), + expect.any(Array), + undefined, + ); + } finally { + if (saved !== undefined) process.env.CODEX_LB_BASE_URL = saved; + else delete process.env.CODEX_LB_BASE_URL; + } + }); + + it('uses OPENAI_BASE_URL_ENV as the secret-backed URL variable-name fallback', () => { + const saved = { + envName: process.env.OPENAI_BASE_URL_ENV, + baseUrl: process.env.CODEX_LB_BASE_URL, + }; + process.env.OPENAI_BASE_URL_ENV = 'CODEX_LB_BASE_URL'; + process.env.CODEX_LB_BASE_URL = 'https://lb.internal.example.com/v1'; + try { + resolveAllowedDomains({}); + expect(mockedApiProxyConfig.resolveApiTargetsToAllowedDomains).toHaveBeenCalledWith( + expect.objectContaining({ openaiBaseUrl: 'https://lb.internal.example.com/v1' }), + expect.any(Array), + expect.any(Object), + expect.any(Function), + expect.any(Array), + undefined, + ); + } finally { + if (saved.envName !== undefined) process.env.OPENAI_BASE_URL_ENV = saved.envName; + else delete process.env.OPENAI_BASE_URL_ENV; + if (saved.baseUrl !== undefined) process.env.CODEX_LB_BASE_URL = saved.baseUrl; + else delete process.env.CODEX_LB_BASE_URL; + } + }); + + it('exits before startup when the openaiBaseUrlEnv value is invalid', () => { + const saved = process.env.CODEX_LB_BASE_URL; + process.env.CODEX_LB_BASE_URL = 'ftp://lb.internal.example.com'; + const exitSpy = jest.spyOn(process, 'exit').mockImplementation(((): never => { + throw new Error('process.exit called'); + }) as never); + try { + expect(() => resolveAllowedDomains({ openaiBaseUrlEnv: 'CODEX_LB_BASE_URL' })).toThrow('process.exit called'); + expect(exitSpy).toHaveBeenCalledWith(1); + } finally { + exitSpy.mockRestore(); + if (saved !== undefined) process.env.CODEX_LB_BASE_URL = saved; + else delete process.env.CODEX_LB_BASE_URL; + } + }); + it('passes undefined openaiEndpointOverride when not set in any source', () => { resolveAllowedDomains({}); expect(mockedApiProxyConfig.resolveApiTargetsToAllowedDomains).toHaveBeenCalledWith( diff --git a/src/commands/preflight.ts b/src/commands/preflight.ts index da872dc99..c2e5880aa 100644 --- a/src/commands/preflight.ts +++ b/src/commands/preflight.ts @@ -10,6 +10,7 @@ import { resolveCopilotApiRouting } from '../copilot-api-resolver'; import { resolveApiTargetsToAllowedDomains } from '../api-proxy-config'; import { resolveTopologyPeerHosts } from '../topology-peers'; import { readEnvFile } from '../github-env'; +import { resolveOpenAiBaseUrlFromEnv } from '../openai-base-url-env'; /** * Resolves the Commander option-value source for a given option name. @@ -19,8 +20,7 @@ type OptionSourceResolver = (optionName: string) => string | undefined; /** * The result produced by {@link resolveAllowedDomains}. - */ -interface AllowedDomainsResult { + */interface AllowedDomainsResult { allowedDomains: string[]; sensitiveAllowedDomains: string[]; localhostResult: ReturnType; @@ -106,6 +106,26 @@ function validateAllowedDomains(domains: string[]): void { } } +/** + * Resolves the secret-backed OpenAI base URL named by `--openai-base-url-env` + * (config path `apiProxy.targets.openai.baseUrlEnv`). + * + * Validation failures abort before any container starts. Error messages never + * contain the resolved value. + * + * @param options - Parsed CLI/config options. + * @returns The normalized base URL, or undefined when the feature is not configured. + */ +function resolveSecretOpenAiBaseUrl(options: Record): string | undefined { + const envVarName = (options.openaiBaseUrlEnv as string | undefined) ?? process.env.OPENAI_BASE_URL_ENV; + try { + return resolveOpenAiBaseUrlFromEnv(envVarName)?.url; + } catch (error) { + logger.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} + /** * Resolves the final set of allowed domains by: * 1. Parsing `--allow-domains` and `--allow-domains-file` flags @@ -169,6 +189,7 @@ export function resolveAllowedDomains(options: Record): Allowed // Priority matches getConfigEnvValue: additionalEnv > envFile > process.env. const additionalEnv = options.additionalEnv as Record | undefined; const envFilePath = options.envFile as string | undefined; + const secretOpenAiBaseUrl = resolveSecretOpenAiBaseUrl(options); const openaiEndpointOverride: string | undefined = ( additionalEnv?.['OPENAI_ENDPOINT_OVERRIDE'] ?? (envFilePath ? readEnvFile(envFilePath)['OPENAI_ENDPOINT_OVERRIDE'] : undefined) @@ -182,6 +203,7 @@ export function resolveAllowedDomains(options: Record): Allowed resolveApiTargetsToAllowedDomains( { copilotApiTarget: resolvedCopilotApiTarget, + openaiBaseUrl: secretOpenAiBaseUrl, openaiApiTarget: options.openaiApiTarget as string | undefined, anthropicApiTarget: options.anthropicApiTarget as string | undefined, geminiApiTarget: options.geminiApiTarget as string | undefined, diff --git a/src/commands/resolve-credentials.ts b/src/commands/resolve-credentials.ts index 71eedf8ea..8dca3a364 100644 --- a/src/commands/resolve-credentials.ts +++ b/src/commands/resolve-credentials.ts @@ -20,6 +20,7 @@ type ApiCredentials = Pick)[key] = '[REDACTED]'; + continue; + } + const value = (service.environment as Record)[key]; + if (typeof value === 'string' && sensitiveValues.length > 0) { + (service.environment as Record)[key] = redactSensitiveValues(value, sensitiveValues); } } } diff --git a/src/config-file-mapping.test.ts b/src/config-file-mapping.test.ts index bc47ee5aa..4869f3714 100644 --- a/src/config-file-mapping.test.ts +++ b/src/config-file-mapping.test.ts @@ -73,6 +73,14 @@ describe('mapAwfFileConfigToCliOptions', () => { expect(result.vertexApiBasePath).toBe('/v1'); }); + it('maps apiProxy.targets.openai.baseUrlEnv to openaiBaseUrlEnv', () => { + const result = mapAwfFileConfigToCliOptions({ + apiProxy: { targets: { openai: { baseUrlEnv: 'CODEX_LB_BASE_URL' } } }, + }); + + expect(result.openaiBaseUrlEnv).toBe('CODEX_LB_BASE_URL'); + }); + it('maps authHeader fields for openai and anthropic targets', () => { const result = mapAwfFileConfigToCliOptions({ apiProxy: { diff --git a/src/config-file.ts b/src/config-file.ts index 484842a10..80b2b4672 100644 --- a/src/config-file.ts +++ b/src/config-file.ts @@ -45,7 +45,7 @@ export interface AwfFileConfig { baseUrl?: string; }; targets?: { - openai?: { host?: string; basePath?: string; authHeader?: string }; + openai?: { host?: string; basePath?: string; authHeader?: string; baseUrlEnv?: string }; anthropic?: { host?: string; basePath?: string; authHeader?: string }; copilot?: { host?: string; diff --git a/src/config-mapper.ts b/src/config-mapper.ts index c5f1a8258..a2a995b8f 100644 --- a/src/config-mapper.ts +++ b/src/config-mapper.ts @@ -48,6 +48,7 @@ export function mapAwfFileConfigToCliOptions(config: AwfFileConfig): Record { existsSync: jest.fn(), lstatSync: jest.fn(), statSync: jest.fn(), + openSync: jest.fn(), + fchmodSync: jest.fn(), + fsyncSync: jest.fn(), + closeSync: jest.fn(), writeFileSync: jest.fn(), copyFileSync: jest.fn(), readFileSync: jest.fn(), @@ -95,6 +99,7 @@ beforeEach(() => { // Default: not a symlink, is a directory fsMock.lstatSync.mockReturnValue({ isSymbolicLink: () => false } as fs.Stats); fsMock.statSync.mockReturnValue({ isDirectory: () => true } as fs.Stats); + fsMock.openSync.mockReturnValue(42); }); // ─── validateAndPrepareWorkDir — non-directory guard ───────────────────────── @@ -170,6 +175,27 @@ describe('config-writer: writeAuditArtifacts — non-directory auditDir (line 16 ).toThrow(`Expected directory but found non-directory path: ${auditPath}`); }); + it('redacts secret-derived endpoint hosts from the audited squid.conf', () => { + fsMock.lstatSync.mockReturnValue({ isSymbolicLink: () => false } as fs.Stats); + fsMock.statSync.mockReturnValue({ isDirectory: () => true } as fs.Stats); + fsMock.writeFileSync.mockClear(); + + const squidConfig = 'acl allowed_domains dstdomain .lb.secret.example.com'; + writeAuditArtifacts( + makeConfig('/tmp/test-workdir', { sensitiveAllowedDomains: ['https://lb.secret.example.com'] }), + {} as any, + { services: {}, version: '3' } as any, + squidConfig + ); + + const squidWrite = fsMock.writeFileSync.mock.calls.find( + (call) => typeof call[1] === 'string' && (call[1] as string).includes('allowed_domains') + ); + expect(squidWrite).toBeDefined(); + expect(squidWrite![1]).not.toContain('lb.secret.example.com'); + expect(squidWrite![1]).toContain('[REDACTED]'); + }); + it('writes audit artifacts when auditDir is valid', () => { fsMock.lstatSync.mockReturnValue({ isSymbolicLink: () => false } as fs.Stats); fsMock.statSync.mockReturnValue({ isDirectory: () => true } as fs.Stats); @@ -179,5 +205,13 @@ describe('config-writer: writeAuditArtifacts — non-directory auditDir (line 16 ).not.toThrow(); expect(fsMock.writeFileSync).toHaveBeenCalled(); + expect(fsMock.openSync).toHaveBeenCalledWith( + expect.stringContaining('squid.conf'), + expect.any(Number), + 0o600 + ); + expect(fsMock.fchmodSync).toHaveBeenCalledWith(42, 0o600); + expect(fsMock.fchmodSync).toHaveBeenCalledWith(42, 0o644); + expect(fsMock.closeSync).toHaveBeenCalledWith(42); }); }); diff --git a/src/config-writer.ts b/src/config-writer.ts index 7692a8ed2..e5ba8b3ab 100644 --- a/src/config-writer.ts +++ b/src/config-writer.ts @@ -10,6 +10,7 @@ import { generateSessionCa, initSslDb, isOpenSslAvailable } from './ssl-bump'; import { parseUrlPatterns } from './domain-matchers'; import { SslConfig, SQUID_PORT } from './host-env'; import { generateDockerCompose, redactDockerComposeSecrets } from './compose-generator'; +import { deriveSensitiveEndpointForms, redactSensitiveValues } from './redact-secrets'; import { resolveLogPaths } from './log-paths'; import { DEFAULT_DNS_SERVERS, filterForNetworkIsolation } from './dns-resolver'; import { getSafeHostGid, getSafeHostUid } from './host-identity'; @@ -290,15 +291,24 @@ function writeAuditArtifacts( } fs.chmodSync(auditDir, 0o755); - // Save squid.conf for audit (no secrets — just domain ACLs and proxy config) - fs.writeFileSync(path.join(auditDir, 'squid.conf'), squidConfig, { mode: 0o644 }); + // Secret-derived endpoints (e.g. an OpenAI base URL supplied through + // `apiProxy.targets.openai.baseUrlEnv`) must never appear in audit artifacts, + // so redact their URL/host/host:port forms from the snapshots below. + const sensitiveEndpointForms = deriveSensitiveEndpointForms(config.sensitiveAllowedDomains); + + // Save squid.conf for audit (domain ACLs and proxy config, sensitive hosts redacted) + writeAuditArtifact( + auditDir, + 'squid.conf', + redactSensitiveValues(squidConfig, sensitiveEndpointForms) + ); // Save redacted docker-compose.yml (strip env vars that may contain secrets) - const redactedCompose = redactDockerComposeSecrets(dockerCompose); - fs.writeFileSync( - path.join(auditDir, 'docker-compose.redacted.yml'), - yaml.dump(redactedCompose, { lineWidth: -1 }), - { mode: 0o644 } + const redactedCompose = redactDockerComposeSecrets(dockerCompose, sensitiveEndpointForms); + writeAuditArtifact( + auditDir, + 'docker-compose.redacted.yml', + yaml.dump(redactedCompose, { lineWidth: -1 }) ); // Generate and save policy manifest (structured description of all firewall rules) @@ -319,15 +329,39 @@ function writeAuditArtifacts( // rather than misidentifying them as "unknown" or blocked. topologyPeers: resolveTopologyPeerHosts(config), }); - fs.writeFileSync( - path.join(auditDir, 'policy-manifest.json'), - JSON.stringify(policyManifest, null, 2), - { mode: 0o644 } + writeAuditArtifact( + auditDir, + 'policy-manifest.json', + JSON.stringify(policyManifest, null, 2) ); logger.debug(`Audit artifacts written to: ${auditDir}`); } +function writeAuditArtifact(auditDir: string, filename: string, contents: string): void { + const artifactPath = path.join(auditDir, filename); + const flags = + fs.constants.O_WRONLY | + fs.constants.O_CREAT | + fs.constants.O_TRUNC | + (fs.constants.O_NOFOLLOW ?? 0); + let fd: number | undefined; + + try { + // Create privately and refuse a symlink target. Existing artifacts are + // tightened before truncation so readers cannot observe partial content. + fd = fs.openSync(artifactPath, flags, 0o600); + fs.fchmodSync(fd, 0o600); + fs.writeFileSync(fd, contents, { encoding: 'utf8' }); + fs.fsyncSync(fd); + fs.fchmodSync(fd, 0o644); + } finally { + if (fd !== undefined) { + fs.closeSync(fd); + } + } +} + /** * Writes all configuration files to disk. * @@ -394,6 +428,7 @@ export async function writeConfigs(config: WrapperConfig): Promise { // all necessary egress without exposing the sensitive hostnames in logs or // the audit artifact (where only config.allowedDomains is serialised). domains: [...config.allowedDomains, ...(config.sensitiveAllowedDomains ?? [])], + sensitiveDomains: config.sensitiveAllowedDomains, blockedDomains: config.blockedDomains, port: SQUID_PORT, sslBump: config.sslBump, diff --git a/src/coverage-branch-gaps-3.test.ts b/src/coverage-branch-gaps-3.test.ts index 047d6ecf4..7d0c0c44c 100644 --- a/src/coverage-branch-gaps-3.test.ts +++ b/src/coverage-branch-gaps-3.test.ts @@ -202,6 +202,7 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { generateDockerCompose, redactDockerComposeSecrets } from './compose-generator'; +import { deriveSensitiveEndpointForms } from './redact-secrets'; import { baseConfig, mockNetworkConfig } from './test-helpers/docker-test-fixtures.test-utils'; import type { WrapperConfig } from './types'; @@ -265,6 +266,34 @@ describe('redactDockerComposeSecrets — service without environment (line 152)' }); }); +describe('redactDockerComposeSecrets — secret-derived endpoint values', () => { + it('redacts sensitive endpoint values from non-secret-named env vars', () => { + const compose = { + services: { + 'api-proxy': { + image: 'api-proxy:latest', + environment: { + OPENAI_API_TARGET: 'lb.internal.example.com', + OPENAI_ENDPOINT_OVERRIDE: 'https://lb.internal.example.com/v1', + NORMAL_VAR: 'kept', + }, + }, + }, + networks: {}, + }; + + const result = redactDockerComposeSecrets( + compose as any, + deriveSensitiveEndpointForms(['https://lb.internal.example.com']) + ); + + const environment = (result.services['api-proxy'] as any).environment; + expect(environment['OPENAI_API_TARGET']).toBe('[REDACTED]'); + expect(environment['OPENAI_ENDPOINT_OVERRIDE']).not.toContain('lb.internal.example.com'); + expect(environment['NORMAL_VAR']).toBe('kept'); + }); +}); + // ─── compose-network.ts — squidService.networks truthy (line 37) ───────────── import { buildComposeNetworks } from './compose-network'; diff --git a/src/openai-base-url-env.test.ts b/src/openai-base-url-env.test.ts new file mode 100644 index 000000000..ac5ca8296 --- /dev/null +++ b/src/openai-base-url-env.test.ts @@ -0,0 +1,122 @@ +import { resolveOpenAiBaseUrlFromEnv } from './openai-base-url-env'; + +describe('resolveOpenAiBaseUrlFromEnv', () => { + const VAR = 'CODEX_LB_BASE_URL'; + + it('returns undefined when no env var name is configured', () => { + expect(resolveOpenAiBaseUrlFromEnv(undefined, {})).toBeUndefined(); + }); + + it('derives url, host, host:port and base path from a valid https URL', () => { + const resolved = resolveOpenAiBaseUrlFromEnv(VAR, { + [VAR]: 'https://lb.internal.example.com/v1/', + }); + expect(resolved).toEqual({ + envVarName: VAR, + url: 'https://lb.internal.example.com/v1', + scheme: 'https', + host: 'lb.internal.example.com', + hostPort: 'lb.internal.example.com:443', + basePath: '/v1', + }); + }); + + it('derives an empty base path for root URLs', () => { + const resolved = resolveOpenAiBaseUrlFromEnv(VAR, { [VAR]: 'https://lb.internal/' }); + expect(resolved).toMatchObject({ + url: 'https://lb.internal', + scheme: 'https', + host: 'lb.internal', + hostPort: 'lb.internal:443', + basePath: '', + }); + }); + + it('accepts an explicit default port', () => { + const resolved = resolveOpenAiBaseUrlFromEnv(VAR, { [VAR]: 'https://lb.internal:443/v1' }); + expect(resolved?.host).toBe('lb.internal'); + expect(resolved?.hostPort).toBe('lb.internal:443'); + }); + + it('fails when the named variable is unset', () => { + expect(() => resolveOpenAiBaseUrlFromEnv(VAR, {})).toThrow(/is not set/); + }); + + it('fails when the named variable is blank', () => { + expect(() => resolveOpenAiBaseUrlFromEnv(VAR, { [VAR]: ' ' })).toThrow(/is not set/); + }); + + it('rejects an invalid environment variable name', () => { + expect(() => resolveOpenAiBaseUrlFromEnv('1BAD NAME', {})).toThrow( + /not a valid environment variable name/ + ); + }); + + it('rejects a malformed URL without echoing the value', () => { + const value = 'not a url secret-host.internal'; + expect(() => resolveOpenAiBaseUrlFromEnv(VAR, { [VAR]: value })).toThrow( + /does not contain a valid absolute URL/ + ); + try { + resolveOpenAiBaseUrlFromEnv(VAR, { [VAR]: value }); + } catch (error) { + expect((error as Error).message).not.toContain('secret-host.internal'); + } + }); + + it('rejects unsupported schemes', () => { + expect(() => resolveOpenAiBaseUrlFromEnv(VAR, { [VAR]: 'ftp://lb.internal' })).toThrow( + /unsupported URL scheme/ + ); + }); + + it('rejects http because the sidecar only supports HTTPS upstreams', () => { + expect(() => resolveOpenAiBaseUrlFromEnv(VAR, { [VAR]: 'http://lb.internal' })).toThrow( + /Only https:\/\/ endpoints are supported/ + ); + }); + + const credentialUrl = ['https://svc-user', ':', 's3cr3t-pw', '@lb.internal/v1'].join(''); + + it('rejects embedded credentials', () => { + expect(() => + resolveOpenAiBaseUrlFromEnv(VAR, { [VAR]: credentialUrl }) + ).toThrow(/embedded credentials/); + }); + + it('does not leak the value when rejecting embedded credentials', () => { + try { + resolveOpenAiBaseUrlFromEnv(VAR, { [VAR]: credentialUrl }); + throw new Error('expected rejection'); + } catch (error) { + expect((error as Error).message).not.toContain('s3cr3t-pw'); + expect((error as Error).message).not.toContain('lb.internal'); + } + }); + + it('rejects query strings and fragments', () => { + expect(() => resolveOpenAiBaseUrlFromEnv(VAR, { [VAR]: 'https://lb.internal/v1?a=1' })).toThrow( + /query string or fragment/ + ); + expect(() => resolveOpenAiBaseUrlFromEnv(VAR, { [VAR]: 'https://lb.internal/v1#x' })).toThrow( + /query string or fragment/ + ); + }); + + it('rejects non-default ports because the sidecar cannot route them', () => { + expect(() => resolveOpenAiBaseUrlFromEnv(VAR, { [VAR]: 'https://lb.internal:8443/v1' })).toThrow( + /non-default port/ + ); + }); + + it('reads from process.env by default', () => { + const previous = process.env[VAR]; + process.env[VAR] = 'https://from-process-env.internal/v1'; + try { + expect(resolveOpenAiBaseUrlFromEnv(VAR)?.host).toBe('from-process-env.internal'); + } finally { + if (previous === undefined) delete process.env[VAR]; + else process.env[VAR] = previous; + } + }); +}); diff --git a/src/openai-base-url-env.ts b/src/openai-base-url-env.ts new file mode 100644 index 000000000..f840e41a2 --- /dev/null +++ b/src/openai-base-url-env.ts @@ -0,0 +1,152 @@ +/** + * Runner-side resolution of a secret-backed OpenAI-compatible endpoint. + * + * `apiProxy.targets.openai.baseUrlEnv` names a runner environment variable + * (typically bound to `${{ secrets.* }}` by the gh-aw compiler) whose value is + * the base URL of a private OpenAI-compatible endpoint. The URL is read and + * validated here — on the runner, before any container starts — so that: + * + * - the endpoint never has to be written into workflow source or lockfiles; + * - the derived host can be added to the sensitive (never logged) Squid + * allowlist and to the api-proxy sidecar target; and + * - the value is never placed in the untrusted agent's environment. + * + * Every error message below is deliberately free of the resolved value so that + * a misconfigured endpoint cannot leak through logs or diagnostics. + */ + +/** Derived, validated forms of a secret-backed OpenAI base URL. */ +export interface ResolvedOpenAiBaseUrl { + /** Name of the runner environment variable the value was read from. */ + envVarName: string; + /** Normalized base URL (scheme + host + optional base path, no trailing slash). */ + url: string; + /** URL scheme (`https`). */ + scheme: 'https'; + /** Hostname without port. */ + host: string; + /** Hostname with the effective port (explicit or scheme default). */ + hostPort: string; + /** Normalized base path (e.g. `/v1`), or `''` when the URL has no path. */ + basePath: string; +} + +const ENV_VAR_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; + +/** + * Validates a configured environment variable name for `baseUrlEnv`. + * @param rawName - Raw configured name. + * @returns The trimmed name. + * @throws Error when the name is empty or is not a valid environment variable name. + */ +function validateEnvVarName(rawName: string): string { + const name = rawName.trim(); + if (!name) { + throw new Error( + 'apiProxy.targets.openai.baseUrlEnv is empty. Set it to the name of the runner ' + + 'environment variable that holds the OpenAI-compatible base URL.' + ); + } + if (!ENV_VAR_NAME_PATTERN.test(name)) { + throw new Error( + `apiProxy.targets.openai.baseUrlEnv "${name}" is not a valid environment variable name ` + + '(expected letters, digits and underscores, not starting with a digit).' + ); + } + return name; +} + +/** + * Resolves and validates the OpenAI base URL named by `baseUrlEnv`. + * + * @param envVarName - Configured environment variable name, or undefined when the feature is unused. + * @param env - Environment to read from (defaults to `process.env`). + * @returns The derived endpoint forms, or `undefined` when `envVarName` is not configured. + * @throws Error with a value-free message when the variable is unset or holds an invalid URL. + */ +export function resolveOpenAiBaseUrlFromEnv( + envVarName: string | undefined, + env: Record = process.env +): ResolvedOpenAiBaseUrl | undefined { + if (envVarName === undefined) return undefined; + + const name = validateEnvVarName(envVarName); + const rawValue = env[name]; + const value = typeof rawValue === 'string' ? rawValue.trim() : ''; + if (!value) { + throw new Error( + `Environment variable "${name}" (apiProxy.targets.openai.baseUrlEnv) is not set. ` + + 'Bind it to the secret holding the OpenAI-compatible base URL before starting awf.' + ); + } + + let parsed: URL; + try { + parsed = new URL(value); + } catch { + throw new Error( + `Environment variable "${name}" does not contain a valid absolute URL ` + + '(expected e.g. https://host.example.com/v1). The value is not shown to avoid leaking it.' + ); + } + + if (parsed.protocol !== 'https:') { + throw new Error( + `Environment variable "${name}" uses an unsupported URL scheme. ` + + 'Only https:// endpoints are supported.' + ); + } + + if (parsed.username || parsed.password) { + throw new Error( + `Environment variable "${name}" contains embedded credentials (user:pass@), which are not supported. ` + + 'Provide the API key separately via OPENAI_API_KEY.' + ); + } + + if (parsed.search || parsed.hash) { + throw new Error( + `Environment variable "${name}" must not contain a query string or fragment.` + ); + } + + const host = parsed.hostname; + if (!host || /[\s#;'"\\]/.test(host)) { + throw new Error( + `Environment variable "${name}" has a missing or malformed hostname.` + ); + } + + const scheme = 'https'; + const defaultPort = '443'; + if (parsed.port && parsed.port !== defaultPort) { + // The api-proxy sidecar always connects to the target host on the scheme's + // default port, so a custom port would be silently dropped and misrouted. + throw new Error( + `Environment variable "${name}" specifies a non-default port, which the OpenAI API proxy ` + + 'target does not support. Expose the endpoint on the default port for its scheme.' + ); + } + + const basePath = normalizeBasePath(parsed.pathname); + + return { + envVarName: name, + url: `${scheme}://${host}${basePath}`, + scheme, + host, + hostPort: `${host}:${defaultPort}`, + basePath, + }; +} + +/** + * Normalizes a URL pathname into an api-proxy base path (no trailing slash, `''` for root). + * @param pathname - Raw URL pathname. + * @returns Normalized base path. + */ +function normalizeBasePath(pathname: string): string { + const trimmed = (pathname || '').replace(/\/+$/, ''); + if (!trimmed || trimmed === '/') return ''; + return trimmed.startsWith('/') ? trimmed : `/${trimmed}`; +} diff --git a/src/redact-secrets.test.ts b/src/redact-secrets.test.ts new file mode 100644 index 000000000..1b7f21879 --- /dev/null +++ b/src/redact-secrets.test.ts @@ -0,0 +1,58 @@ +import { deriveSensitiveEndpointForms, redactSensitiveValues } from './redact-secrets'; + +describe('deriveSensitiveEndpointForms', () => { + it('returns an empty list when there are no sensitive domains', () => { + expect(deriveSensitiveEndpointForms()).toEqual([]); + expect(deriveSensitiveEndpointForms([])).toEqual([]); + expect(deriveSensitiveEndpointForms([' '])).toEqual([]); + }); + + it('derives url, host and host:port forms for https entries', () => { + const forms = deriveSensitiveEndpointForms(['https://lb.internal.example.com']); + expect(forms).toEqual(expect.arrayContaining([ + 'https://lb.internal.example.com', + 'lb.internal.example.com', + 'lb.internal.example.com:443', + ])); + }); + + it('uses port 80 for http entries', () => { + expect(deriveSensitiveEndpointForms(['http://lb.internal'])).toEqual( + expect.arrayContaining(['http://lb.internal', 'lb.internal', 'lb.internal:80']) + ); + }); + + it('orders longest forms first so the most specific match is redacted', () => { + const forms = deriveSensitiveEndpointForms(['https://lb.internal']); + const lengths = forms.map((form) => form.length); + expect(lengths).toEqual([...lengths].sort((a, b) => b - a)); + }); +}); + +describe('redactSensitiveValues', () => { + it('replaces every occurrence of each sensitive value', () => { + const forms = deriveSensitiveEndpointForms(['https://lb.internal.example.com']); + const text = [ + 'acl allowed_domains dstdomain .lb.internal.example.com', + 'OPENAI_API_TARGET=lb.internal.example.com', + 'CONNECT lb.internal.example.com:443', + ].join('\n'); + + const redacted = redactSensitiveValues(text, forms); + + expect(redacted).not.toContain('lb.internal.example.com'); + expect(redacted).toContain('[REDACTED]'); + }); + + it('returns the input unchanged when no values are supplied', () => { + expect(redactSensitiveValues('nothing to redact', [])).toBe('nothing to redact'); + }); + + it('redacts endpoint forms irrespective of URL or hostname casing', () => { + const forms = deriveSensitiveEndpointForms(['https://lb.secret.example.com']); + expect(redactSensitiveValues( + 'OPENAI_ENDPOINT_OVERRIDE=https://LB.Secret.Example.Com/v1', + forms + )).not.toContain('LB.Secret.Example.Com'); + }); +}); diff --git a/src/redact-secrets.ts b/src/redact-secrets.ts index 236935d41..f5f14d39f 100644 --- a/src/redact-secrets.ts +++ b/src/redact-secrets.ts @@ -12,3 +12,47 @@ export function redactSecrets(command: string): string { // Redact GitHub tokens (ghp_, gho_, ghu_, ghs_, ghr_) .replace(/\b(gh[pousr]_[A-Za-z0-9._-]{36,})/g, '***REDACTED***'); } + +/** + * Derives every textual form of a secret-derived endpoint that must be kept out + * of logs, diagnostics and uploaded artifacts. + * + * `sensitiveAllowedDomains` entries are stored as `://`; the host + * and `host:port` forms are derived here so that artifacts referencing the bare + * hostname (e.g. `OPENAI_API_TARGET`) are redacted too. + * + * @param sensitiveAllowedDomains - Secret-derived allowlist entries. + * @returns URL, host and host:port forms, longest first (so the most specific match wins). + */ +export function deriveSensitiveEndpointForms(sensitiveAllowedDomains?: string[]): string[] { + const forms = new Set(); + for (const entry of sensitiveAllowedDomains ?? []) { + const value = entry.trim(); + if (!value) continue; + const host = value.replace(/^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//, '').replace(/\/.*$/, ''); + if (!host) continue; + forms.add(value); + forms.add(host); + forms.add(`${host}:${/^http:\/\//i.test(value) ? '80' : '443'}`); + } + return [...forms].sort((a, b) => b.length - a.length); +} + +/** + * Replaces every occurrence of the supplied sensitive values with `[REDACTED]`. + * + * @param text - Text to redact. + * @param values - Sensitive values (see {@link deriveSensitiveEndpointForms}). + * @returns The redacted text. + */ +export function redactSensitiveValues(text: string, values: string[]): string { + let result = text; + for (const value of values) { + if (!value) continue; + result = result.replace( + new RegExp(value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi'), + '[REDACTED]' + ); + } + return result; +} diff --git a/src/services/agent-environment/excluded-vars.test.ts b/src/services/agent-environment/excluded-vars.test.ts index 049f07283..fcb3a5bd2 100644 --- a/src/services/agent-environment/excluded-vars.test.ts +++ b/src/services/agent-environment/excluded-vars.test.ts @@ -184,6 +184,18 @@ describe('buildExclusionSet', () => { }); }); + describe('when openaiBaseUrlEnv names a secret-backed endpoint', () => { + it('excludes the named variable from the agent environment', () => { + const config = makeConfig({ enableApiProxy: true, openaiBaseUrlEnv: 'CODEX_LB_BASE_URL' }); + expect(buildExclusionSet(config).has('CODEX_LB_BASE_URL')).toBe(true); + }); + + it('excludes the named variable even when the API proxy is disabled', () => { + const config = makeConfig({ enableApiProxy: false, openaiBaseUrlEnv: ' CODEX_LB_BASE_URL ' }); + expect(buildExclusionSet(config).has('CODEX_LB_BASE_URL')).toBe(true); + }); + }); + describe('when difcProxyHost is set (DIFC proxy security)', () => { const config = makeConfig({ difcProxyHost: 'host.docker.internal:18443' }); diff --git a/src/services/agent-environment/excluded-vars.ts b/src/services/agent-environment/excluded-vars.ts index 98a06889c..23bde476b 100644 --- a/src/services/agent-environment/excluded-vars.ts +++ b/src/services/agent-environment/excluded-vars.ts @@ -58,6 +58,13 @@ export function buildExclusionSet(config: WrapperConfig): Set { excludedEnvVars.add('OPENAI_ENDPOINT_OVERRIDE'); } + // A secret-backed OpenAI base URL supplied through + // `apiProxy.targets.openai.baseUrlEnv` is resolved on the runner and passed to + // the api-proxy sidecar only; the untrusted agent must never see the variable. + if (config.openaiBaseUrlEnv?.trim()) { + excludedEnvVars.add(config.openaiBaseUrlEnv.trim()); + } + if (config.difcProxyHost) { // Redundant with enableApiProxy block above, kept for explicit documentation: // when DIFC proxy handles GitHub auth, tokens must never reach the agent. diff --git a/src/services/api-proxy-env-config.test.ts b/src/services/api-proxy-env-config.test.ts index aaed297ac..c319ec821 100644 --- a/src/services/api-proxy-env-config.test.ts +++ b/src/services/api-proxy-env-config.test.ts @@ -164,6 +164,38 @@ describe('buildProviderRoutingEnv', () => { } }); + it('routes a secret-backed base URL (openaiBaseUrlEnv) to the sidecar target and base path', () => { + const saved = process.env.CODEX_LB_BASE_URL; + process.env.CODEX_LB_BASE_URL = 'https://lb.internal.example.com/v1'; + try { + const env = buildProviderRoutingEnv({ + ...baseConfig, + workDir: '/tmp/awf-test', + openaiBaseUrlEnv: 'CODEX_LB_BASE_URL', + }); + expect(env.OPENAI_API_TARGET).toBe('lb.internal.example.com'); + expect(env.OPENAI_API_BASE_PATH).toBe('/v1'); + expect(env.AWF_SENSITIVE_OPENAI_TARGET).toBe('lb.internal.example.com'); + } finally { + if (saved !== undefined) process.env.CODEX_LB_BASE_URL = saved; + else delete process.env.CODEX_LB_BASE_URL; + } + }); + + it('fails fast when the configured openaiBaseUrlEnv variable is unset', () => { + const saved = process.env.CODEX_LB_BASE_URL; + delete process.env.CODEX_LB_BASE_URL; + try { + expect(() => buildProviderRoutingEnv({ + ...baseConfig, + workDir: '/tmp/awf-test', + openaiBaseUrlEnv: 'CODEX_LB_BASE_URL', + })).toThrow(/is not set/); + } finally { + if (saved !== undefined) process.env.CODEX_LB_BASE_URL = saved; + } + }); + it('prefers OPENAI_ENDPOINT_OVERRIDE from additionalEnv over process.env', () => { const saved = process.env.OPENAI_ENDPOINT_OVERRIDE; process.env.OPENAI_ENDPOINT_OVERRIDE = 'https://process-env-router.example.com'; diff --git a/src/services/api-proxy-env-config.ts b/src/services/api-proxy-env-config.ts index 65d110726..f15f60e13 100644 --- a/src/services/api-proxy-env-config.ts +++ b/src/services/api-proxy-env-config.ts @@ -5,6 +5,7 @@ import { getConfigEnvValue, getLowerCaseProcessEnvValue, pickEnvVars } from '../ import { OPENAI_ENV, ANTHROPIC_ENV, GEMINI_ENV, COPILOT_ENV, VERTEX_ENV, OIDC_AUTH_ENV_VARS, OIDC_AUTH_ENV_MAPPING } from '../api-proxy-env-constants'; import { NetworkConfig } from './squid-service'; import { buildNoProxyEnv } from './no-proxy-utils'; +import { resolveOpenAiBaseUrlFromEnv } from '../openai-base-url-env'; const DEFAULT_API_PROXY_SHUTDOWN_TIMEOUT_MS = 8000; @@ -15,7 +16,11 @@ const DEFAULT_API_PROXY_SHUTDOWN_TIMEOUT_MS = 8000; */ export function buildProviderTargetEnv(config: WrapperConfig): Record { const openAiEndpointOverride = resolveOpenAiEndpointOverride(config); - const resolvedOpenAiTarget = config.openaiApiTarget ?? openAiEndpointOverride; + const secretOpenAiBaseUrl = resolveOpenAiBaseUrlFromEnv(config.openaiBaseUrlEnv); + const resolvedOpenAiTarget = secretOpenAiBaseUrl?.host ?? config.openaiApiTarget ?? openAiEndpointOverride; + const resolvedOpenAiBasePath = secretOpenAiBaseUrl + ? (secretOpenAiBaseUrl.basePath || undefined) + : config.openaiApiBasePath; const copilotProviderType = config.copilotProviderType || getConfigEnvValue(config, COPILOT_ENV.PROVIDER_TYPE); const copilotProviderBaseUrl = config.copilotProviderBaseUrl || getConfigEnvValue(config, COPILOT_ENV.PROVIDER_BASE_URL); const copilotProviderApiKey = config.copilotProviderApiKey; @@ -24,7 +29,7 @@ export function buildProviderTargetEnv(config: WrapperConfig): Record = [ { target: config.copilotApiTarget, basePath: config.copilotApiBasePath, envTarget: COPILOT_ENV.API_TARGET, envBasePath: COPILOT_ENV.API_BASE_PATH, stripTarget: true }, - { target: resolvedOpenAiTarget, basePath: config.openaiApiBasePath, envTarget: OPENAI_ENV.TARGET, envBasePath: OPENAI_ENV.BASE_PATH, stripTarget: true }, + { target: resolvedOpenAiTarget, basePath: resolvedOpenAiBasePath, envTarget: OPENAI_ENV.TARGET, envBasePath: OPENAI_ENV.BASE_PATH, stripTarget: true }, { target: config.anthropicApiTarget, basePath: config.anthropicApiBasePath, envTarget: ANTHROPIC_ENV.TARGET, envBasePath: ANTHROPIC_ENV.BASE_PATH, stripTarget: true }, { target: config.geminiApiTarget, basePath: config.geminiApiBasePath, envTarget: GEMINI_ENV.TARGET, envBasePath: GEMINI_ENV.BASE_PATH, stripTarget: true }, { target: config.vertexApiTarget, basePath: config.vertexApiBasePath, envTarget: VERTEX_ENV.TARGET, envBasePath: VERTEX_ENV.BASE_PATH, stripTarget: true }, @@ -34,6 +39,9 @@ export function buildProviderTargetEnv(config: WrapperConfig): Record { expect(result).not.toContain('log_access'); }); + it('excludes sensitive domains from both access log formats', () => { + const result = generateSquidConfig({ + domains: ['example.com', 'https://lb.secret.example.com'], + sensitiveDomains: ['https://lb.secret.example.com'], + port: defaultPort, + }); + expect(result).toContain('acl sensitive_log_domains dstdomain .lb.secret.example.com'); + expect(result).toContain( + 'access_log /var/log/squid/access.log firewall_detailed !healthcheck_localhost !sensitive_log_domains' + ); + expect(result).toContain( + 'access_log /var/log/squid/audit.jsonl audit_jsonl !healthcheck_localhost !sensitive_log_domains' + ); + }); + it('should place healthcheck ACL before access_log directive', () => { const config: SquidConfig = { domains: ['example.com'], diff --git a/src/squid/config-generator.ts b/src/squid/config-generator.ts index e5ce04797..f2f87bf47 100644 --- a/src/squid/config-generator.ts +++ b/src/squid/config-generator.ts @@ -32,11 +32,12 @@ const { version: AWF_VERSION } = require('../../package.json') as { version: str * // Blocked: internal.example.com -> acl blocked_domains dstdomain .internal.example.com */ export function generateSquidConfig(config: SquidConfig): string { - const { domains, blockedDomains, port, sslBump, caFiles, sslDbPath, urlPatterns, enableHostAccess, allowHostPorts, enableDlp, dnsServers, upstreamProxy, apiProxyIp, apiProxyPorts, topologyPeers } = config; + const { domains, sensitiveDomains, blockedDomains, port, sslBump, caFiles, sslDbPath, urlPatterns, enableHostAccess, allowHostPorts, enableDlp, dnsServers, upstreamProxy, apiProxyIp, apiProxyPorts, topologyPeers } = config; validateApiProxyIp(apiProxyIp); const { domainsByProto, patternsByProto } = parseDomainConfig(domains); + const { domainsByProto: sensitiveDomainsByProto } = parseDomainConfig(sensitiveDomains ?? []); const { aclLines, blockedDomainConfig } = generateAclSections(domainsByProto, patternsByProto, blockedDomains); const { accessRulesSection, denyRule } = generateAccessRules( domainsByProto, @@ -51,6 +52,14 @@ export function generateSquidConfig(config: SquidConfig): string { allAclLines.push(...aclLines); const aclSection = allAclLines.length > 0 ? allAclLines.join('\n') : '# No domains configured'; + const sensitiveLogAcl = [ + ...sensitiveDomainsByProto.http, + ...sensitiveDomainsByProto.https, + ...sensitiveDomainsByProto.both, + ].map((domain) => `.${domain.replace(/^\./, '')}`); + const sensitiveLogAclSection = sensitiveLogAcl.length > 0 + ? `acl sensitive_log_domains dstdomain ${sensitiveLogAcl.join(' ')}` + : ''; const { dlpAclSection, dlpAccessSection, @@ -103,8 +112,9 @@ logformat audit_jsonl {"_schema":"audit/v${AWF_VERSION}","timestamp":"%{%Y-%m-%d # Access log and cache configuration # Don't log healthcheck probes from localhost (using ACL filter on access_log) acl healthcheck_localhost src 127.0.0.1 ::1 -access_log /var/log/squid/access.log firewall_detailed !healthcheck_localhost -access_log /var/log/squid/audit.jsonl audit_jsonl !healthcheck_localhost +${sensitiveLogAclSection} +access_log /var/log/squid/access.log firewall_detailed !healthcheck_localhost ${sensitiveLogAcl.length > 0 ? '!sensitive_log_domains' : ''} +access_log /var/log/squid/audit.jsonl audit_jsonl !healthcheck_localhost ${sensitiveLogAcl.length > 0 ? '!sensitive_log_domains' : ''} cache_log /var/log/squid/cache.log cache deny all diff --git a/src/types/api-proxy-routing-options.ts b/src/types/api-proxy-routing-options.ts index 31e6850ef..df08a6dbd 100644 --- a/src/types/api-proxy-routing-options.ts +++ b/src/types/api-proxy-routing-options.ts @@ -136,6 +136,26 @@ export interface ApiProxyRoutingOptions { */ openaiApiTarget?: string; + /** + * Name of a runner environment variable holding the OpenAI-compatible base URL. + * + * The variable is read on the runner (never inside the agent container) and the + * resolved URL is validated before containers start. Its host is added to the + * sensitive Squid allowlist and configured as the api-proxy OpenAI target, while + * the value itself is kept out of the agent environment, logs and artifacts. + * + * Takes precedence over `openaiApiTarget` / `openaiApiBasePath` when set. + * + * Can be set via: + * - Config path: `apiProxy.targets.openai.baseUrlEnv` + * - CLI flag: `--openai-base-url-env ` + * - Environment variable: `OPENAI_BASE_URL_ENV` + * + * @default undefined + * @example 'CODEX_LB_BASE_URL' + */ + openaiBaseUrlEnv?: string; + /** * Base path prefix for OpenAI API requests (used by API proxy sidecar) * diff --git a/src/types/squid.ts b/src/types/squid.ts index 96cc8f700..4a3475882 100644 --- a/src/types/squid.ts +++ b/src/types/squid.ts @@ -21,6 +21,11 @@ export interface SquidConfig { */ domains: string[]; + /** + * Sensitive domains which are allowed but excluded from Squid access logs. + */ + sensitiveDomains?: string[]; + /** * List of blocked domains for proxy access *