Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion docs/awf-config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -493,10 +493,15 @@
"description": "Security and isolation configuration.",
"additionalProperties": false,
"properties": {
"legacySecurity": {
"type": "boolean",
"description": "Enable legacy security mode (sudo, host-access, iptables). When omitted or false, strict security is enforced (network-isolation + API proxy credential injection)."
},
"securityMode": {
"type": "string",
"enum": ["strict", "compat"],
"description": "Security enforcement mode. 'strict' (default) enforces network-isolation, API proxy credential injection, and rejects host-access/DinD. 'compat' preserves legacy iptables-based mode (requires sudo)."
"description": "[DEPRECATED] Use legacySecurity instead. Will be removed in a future release.",
"deprecated": true
},
"sslBump": {
"type": "boolean",
Expand Down
7 changes: 6 additions & 1 deletion src/awf-config-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -493,10 +493,15 @@
"description": "Security and isolation configuration.",
"additionalProperties": false,
"properties": {
"legacySecurity": {
"type": "boolean",
"description": "Enable legacy security mode (sudo, host-access, iptables). When omitted or false, strict security is enforced (network-isolation + API proxy credential injection)."
},
"securityMode": {
"type": "string",
"enum": ["strict", "compat"],
"description": "Security enforcement mode. 'strict' (default) enforces network-isolation, API proxy credential injection, and rejects host-access/DinD. 'compat' preserves legacy iptables-based mode (requires sudo)."
"description": "[DEPRECATED] Use legacySecurity instead. Will be removed in a future release.",
"deprecated": true
},
"sslBump": {
"type": "boolean",
Expand Down
38 changes: 23 additions & 15 deletions src/cli-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const optionGroupHeaders: Record<string, string> = {
'env': 'Container Configuration:',
'dns-servers': 'Network & Security:',
'upstream-proxy': 'Network & Security:',
'enable-api-proxy': 'API Proxy:',
'copilot-api-target': 'API Proxy:',
'log-level': 'Logging & Debug:',
};

Expand Down Expand Up @@ -245,11 +245,11 @@ program
'Enforce egress via Docker network topology (internal network +\n' +
' dual-homed proxy) instead of iptables. Requires no sudo/NET_ADMIN.\n' +
' Not yet supported with --dns-over-https or --enable-host-access.\n' +
' Enabled by default in --security-mode strict.'
' Enabled by default (strict security).'
)
.option(
'--no-network-isolation',
'Disable network-isolation mode (requires --security-mode compat in strict mode).'
'Disable network-isolation mode (requires --legacy-security).'
)
.option(
'--topology-attach <name>',
Expand Down Expand Up @@ -278,13 +278,18 @@ program
' WARNING: allows firewall bypass via docker run',
false
)
.addOption(
new Option(
'--legacy-security',
'Enable legacy security mode (sudo, host-access, iptables).\n' +
' Default behavior is strict security (network-isolation + api-proxy).',
)
)
.addOption(
new Option(
'--security-mode <mode>',
'Security enforcement mode (default: strict).\n' +
' strict: network-isolation + api-proxy, no sudo/iptables.\n' +
' compat: legacy iptables mode, requires sudo.',
).choices(['strict', 'compat']).default('strict')
'[DEPRECATED] Use --legacy-security instead.',
).choices(['strict', 'compat']).hideHelp()
)
.option(
'--enable-dlp',
Expand All @@ -293,15 +298,18 @@ program
false
)

// -- API Proxy --
.option(
'--enable-api-proxy',
'Enable API proxy sidecar for secure credential injection.\n' +
' Supports OpenAI (Codex) and Anthropic (Claude) APIs.'
// -- API Proxy (always enabled, flags retained for backward compatibility) --
.addOption(
new Option(
'--enable-api-proxy',
'[DEPRECATED] The API proxy is always enabled. This flag is ignored.'
).hideHelp()
)
.option(
'--no-enable-api-proxy',
'Disable the API proxy sidecar (requires --security-mode compat in strict mode).'
.addOption(
new Option(
'--no-enable-api-proxy',
'[REMOVED] The API proxy cannot be disabled. Passing this flag is an error.'
).hideHelp()
)
.option(
'--copilot-api-target <host>',
Expand Down
63 changes: 63 additions & 0 deletions src/commands/build-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ const ENV_KEYS = [
'GEMINI_API_BASE_PATH',
'AWF_CAPTURE_BLOCKED_LLM_REQUESTS',
'AWF_MAX_BLOCKED_CAPTURE_BYTES',
'AWF_DEBUG_TOKENS',
] as const;

describe('buildConfig', () => {
Expand Down Expand Up @@ -470,4 +471,66 @@ describe('buildConfig', () => {
expect(config.maxCapturedBytes).toBeUndefined();
});
});

describe('debugTokens via AWF_DEBUG_TOKENS', () => {
it('should set debugTokens true when AWF_DEBUG_TOKENS=1', () => {
process.env.AWF_DEBUG_TOKENS = '1';
const config = buildConfig(makeInputs());
expect(config.debugTokens).toBe(true);
});

it('should leave debugTokens undefined when AWF_DEBUG_TOKENS is not set', () => {
const config = buildConfig(makeInputs());
expect(config.debugTokens).toBeUndefined();
});

it('should leave debugTokens undefined for non-1 AWF_DEBUG_TOKENS', () => {
process.env.AWF_DEBUG_TOKENS = '0';
const config = buildConfig(makeInputs());
expect(config.debugTokens).toBeUndefined();
});
});

describe('resolveLegacySecurity (via options)', () => {
it('should set legacySecurity true when --legacy-security is passed', () => {
const config = buildConfig(makeInputs({
options: { ...makeInputs().options, legacySecurity: true },
}));
expect(config.legacySecurity).toBe(true);
});

it('should leave legacySecurity undefined when --legacy-security is not passed', () => {
const config = buildConfig(makeInputs());
expect(config.legacySecurity).toBeUndefined();
});

it('should map deprecated --security-mode compat to legacySecurity true', () => {
const config = buildConfig(makeInputs({
options: { ...makeInputs().options, securityMode: 'compat' },
}));
expect(config.legacySecurity).toBe(true);
});

it('should leave legacySecurity undefined for deprecated --security-mode strict', () => {
const config = buildConfig(makeInputs({
options: { ...makeInputs().options, securityMode: 'strict' },
}));
expect(config.legacySecurity).toBeUndefined();
});

it('should prefer --legacy-security over deprecated --security-mode', () => {
const config = buildConfig(makeInputs({
options: { ...makeInputs().options, legacySecurity: true, securityMode: 'strict' },
}));
// --legacy-security takes precedence over deprecated --security-mode
expect(config.legacySecurity).toBe(true);
});

it('should treat explicit --legacy-security false as undefined', () => {
const config = buildConfig(makeInputs({
options: { ...makeInputs().options, legacySecurity: false },
}));
expect(config.legacySecurity).toBeUndefined();
});
});
});
35 changes: 34 additions & 1 deletion src/commands/build-config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,38 @@
import { WrapperConfig, LogLevel, UpstreamProxyConfig } from '../types';
import { resolveApiCredentials } from './resolve-credentials';
import { logger } from '../logger';

/**
* Resolves the effective `legacySecurity` value from CLI options.
*
* Sources (in priority order):
* 1. `--legacy-security` boolean flag (preferred)
* 2. `--security-mode compat` (deprecated, maps to legacySecurity=true)
*/
function resolveLegacySecurity(options: Record<string, unknown>): boolean | undefined {
// Preferred new flag takes precedence
const legacySecurity = options.legacySecurity as boolean | undefined;
if (legacySecurity !== undefined) {
return legacySecurity || undefined;
}

// Handle deprecated --security-mode flag (only if --legacy-security not specified)
const securityMode = options.securityMode as string | undefined;
if (securityMode === 'compat') {
logger.warn(
'⚠️ --security-mode compat is deprecated. Use --legacy-security instead.',
);
return true;
}
if (securityMode === 'strict') {
logger.warn(
'⚠️ --security-mode is deprecated. Strict security is the default; remove the flag.',
);
return undefined;
}

return undefined;
}

/**
* Inputs required to assemble a {@link WrapperConfig}.
Expand Down Expand Up @@ -116,7 +149,7 @@ export function buildConfig(inputs: BuildConfigInputs): WrapperConfig {
sslBump: options.sslBump as boolean,
enableDind: options.enableDind as boolean,
enableDlp: options.enableDlp as boolean,
securityMode: options.securityMode as 'strict' | 'compat' | undefined,
legacySecurity: resolveLegacySecurity(options),
allowedUrls,
enableApiProxy: options.enableApiProxy as boolean | undefined,
modelFallback:
Expand Down
28 changes: 7 additions & 21 deletions src/commands/validate-options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const STUB_CONFIG = {
blockedDomains: undefined,
agentCommand: 'echo hi',
logLevel: 'info',
legacySecurity: true,
keepContainers: false,
tty: false,
workDir: '/tmp/workdir',
Expand Down Expand Up @@ -64,7 +65,7 @@ const STUB_CONFIG = {
enableDind: false,
enableDlp: false,
allowedUrls: undefined,
enableApiProxy: false,
enableApiProxy: undefined,
anthropicAutoCache: false,
anthropicCacheTailTtl: undefined,
modelAliases: undefined,
Expand Down Expand Up @@ -514,29 +515,14 @@ describe('validateOptions', () => {
);
});

it('exits when rate limit flags are used without --enable-api-proxy', () => {
mockedOptionParsers.validateRateLimitFlags.mockReturnValue({
valid: false,
error: '--rpm requires --enable-api-proxy',
});
expect(() => validateOptions(validOptions(), 'echo hi')).toThrow('process.exit called');
expect(mockedLogger.error).toHaveBeenCalledWith(
expect.stringContaining('--rpm requires --enable-api-proxy'),
);
});
// Note: "rate limit flags without --enable-api-proxy" is no longer testable
// because applySecurityMode always forces enableApiProxy=true.
// The validateRateLimitFlags check is now dead code for this scenario.
});

describe('feature flag compatibility', () => {
it('exits when --enable-token-steering is used without --enable-api-proxy', () => {
mockedOptionParsers.validateEnableTokenSteeringFlag.mockReturnValue({
valid: false,
error: '--enable-token-steering requires --enable-api-proxy',
});
expect(() => validateOptions(validOptions(), 'echo hi')).toThrow('process.exit called');
expect(mockedLogger.error).toHaveBeenCalledWith(
expect.stringContaining('--enable-token-steering requires --enable-api-proxy'),
);
});
// Note: "--enable-token-steering without --enable-api-proxy" is no longer testable
// because applySecurityMode always forces enableApiProxy=true.

it('exits when --skip-pull and --build-local are combined', () => {
mockedOptionParsers.validateSkipPullWithBuildLocal.mockReturnValue({
Expand Down
17 changes: 10 additions & 7 deletions src/commands/validators/config-assembly-api-proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,26 +29,29 @@ describe('config-assembly', () => {
);
});

it('should exit if rate limit flags are used without --enable-api-proxy', () => {
// Note: "rate limit flags without --enable-api-proxy" scenario cannot occur
// in production (API proxy is always enabled), but we test the validation
// path for completeness.
it('should exit if rate limit flags are invalid', () => {
mockBuildConfigOnce({
enableApiProxy: true,
});

(validateRateLimitFlags as jest.Mock).mockReturnValueOnce({
valid: false,
error: 'Rate limit flags require --enable-api-proxy',
error: '--rate-limit-rpm requires --enable-api-proxy',
});

expect(() => {
callAssembleWith();
}).toThrow('process.exit(1)');

expect(logger.error).toHaveBeenCalledWith(
'Rate limit flags require --enable-api-proxy',
expect.stringContaining('--rate-limit-rpm requires --enable-api-proxy'),
);
});

it('should set rate limit config when API proxy is enabled', () => {
mockBuildConfigOnce({
enableApiProxy: true,
});

const mockRateLimitConfig = {
enabled: true,
rpm: 100,
Expand Down
10 changes: 5 additions & 5 deletions src/commands/validators/config-assembly.test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ jest.mock('../../option-parsers', () => {
validateSkipPullWithBuildLocal: jest.fn(),
validateAllowHostPorts: jest.fn(),
applyHostServicePortsConfig: jest.fn(),
buildRateLimitConfig: jest.fn(),
buildRateLimitConfig: jest.fn().mockReturnValue({ config: { enabled: false, rpm: 0, rph: 0, bytesPm: 0 } }),
applyAgentTimeout: jest.fn(),
isLoopbackTcpDockerHostUri: actual.isLoopbackTcpDockerHostUri,
};
Expand All @@ -57,8 +57,8 @@ jest.mock('../build-config', () => ({
logLevel: args.logLevel,
allowedDomains: args.allowedDomains,
blockedDomains: args.blockedDomains,
securityMode: 'compat',
enableApiProxy: false,
legacySecurity: true,
enableApiProxy: undefined,
enableTokenSteering: false,
envAll: false,
envFile: undefined,
Expand Down Expand Up @@ -150,8 +150,8 @@ export const createBuildConfigResult = (
logLevel: 'info',
allowedDomains: ['example.com'],
blockedDomains: [],
securityMode: 'compat',
enableApiProxy: false,
legacySecurity: true,
enableApiProxy: undefined,
enableTokenSteering: false,
envAll: false,
envFile: undefined,
Expand Down
Loading
Loading