diff --git a/.github/workflows/test-chroot.yml b/.github/workflows/test-chroot.yml index 2eca54161..959c89ebf 100644 --- a/.github/workflows/test-chroot.yml +++ b/.github/workflows/test-chroot.yml @@ -185,7 +185,6 @@ jobs: echo "Captured GOROOT: ${GOROOT_VALUE}" # Rust/Cargo: CARGO_HOME is needed so entrypoint can add $CARGO_HOME/bin to PATH - # The rust-toolchain action sets CARGO_HOME but sudo may not preserve it if [ -n "$CARGO_HOME" ]; then echo "CARGO_HOME=${CARGO_HOME}" >> $GITHUB_ENV echo "Captured CARGO_HOME: ${CARGO_HOME}" @@ -198,7 +197,6 @@ jobs: fi # Java: JAVA_HOME is needed so entrypoint can add $JAVA_HOME/bin to PATH - # The setup-java action sets JAVA_HOME but sudo may not preserve it if [ -n "$JAVA_HOME" ]; then echo "JAVA_HOME=${JAVA_HOME}" >> $GITHUB_ENV echo "Captured JAVA_HOME: ${JAVA_HOME}" diff --git a/docs/awf-config.schema.json b/docs/awf-config.schema.json index 0ad0345cf..6ff70bd27 100644 --- a/docs/awf-config.schema.json +++ b/docs/awf-config.schema.json @@ -493,6 +493,11 @@ "description": "Security and isolation configuration.", "additionalProperties": false, "properties": { + "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)." + }, "sslBump": { "type": "boolean", "description": "Enable SSL bumping (TLS interception) in the Squid proxy. Requires a custom CA certificate." diff --git a/src/awf-config-schema.json b/src/awf-config-schema.json index 0ad0345cf..6ff70bd27 100644 --- a/src/awf-config-schema.json +++ b/src/awf-config-schema.json @@ -493,6 +493,11 @@ "description": "Security and isolation configuration.", "additionalProperties": false, "properties": { + "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)." + }, "sslBump": { "type": "boolean", "description": "Enable SSL bumping (TLS interception) in the Squid proxy. Requires a custom CA certificate." diff --git a/src/cli-options.ts b/src/cli-options.ts index 9208f47f9..468584519 100644 --- a/src/cli-options.ts +++ b/src/cli-options.ts @@ -1,4 +1,4 @@ -import { Command } from 'commander'; +import { Command, Option } from 'commander'; import * as path from 'path'; import * as os from 'os'; import { version } from '../package.json'; @@ -242,10 +242,14 @@ program ) .option( '--network-isolation', - 'Experimental: enforce egress via Docker network topology (internal network +\n' + + '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.', - false + ' Not yet supported with --dns-over-https or --enable-host-access.\n' + + ' Enabled by default in --security-mode strict.' + ) + .option( + '--no-network-isolation', + 'Disable network-isolation mode (requires --security-mode compat in strict mode).' ) .option( '--topology-attach ', @@ -274,6 +278,14 @@ program ' WARNING: allows firewall bypass via docker run', false ) + .addOption( + new Option( + '--security-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') + ) .option( '--enable-dlp', 'Enable DLP (Data Loss Prevention) scanning to block credential\n' + @@ -285,8 +297,11 @@ program .option( '--enable-api-proxy', 'Enable API proxy sidecar for secure credential injection.\n' + - ' Supports OpenAI (Codex) and Anthropic (Claude) APIs.', - false + ' Supports OpenAI (Codex) and Anthropic (Claude) APIs.' + ) + .option( + '--no-enable-api-proxy', + 'Disable the API proxy sidecar (requires --security-mode compat in strict mode).' ) .option( '--copilot-api-target ', diff --git a/src/commands/build-config.ts b/src/commands/build-config.ts index 1b4d193fd..8c58654c7 100644 --- a/src/commands/build-config.ts +++ b/src/commands/build-config.ts @@ -108,7 +108,7 @@ export function buildConfig(inputs: BuildConfigInputs): WrapperConfig { (options.sessionStateDir as string | undefined) || process.env.AWF_SESSION_STATE_DIR, runnerToolCachePath: options.runnerToolCachePath as string | undefined, enableHostAccess: options.enableHostAccess as boolean, - networkIsolation: options.networkIsolation as boolean, + networkIsolation: options.networkIsolation as boolean | undefined, topologyAttach: options.topologyAttach as string[] | undefined, localhostDetected, allowHostPorts: options.allowHostPorts as string | undefined, @@ -116,8 +116,9 @@ 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, allowedUrls, - enableApiProxy: options.enableApiProxy as boolean, + enableApiProxy: options.enableApiProxy as boolean | undefined, modelFallback: options.modelFallback as { enabled?: boolean; strategy?: 'middle_power' } | undefined, requestedModel: options.requestedModel as string | undefined, diff --git a/src/commands/validators/config-assembly-flags.test.ts b/src/commands/validators/config-assembly-flags.test.ts index ec5dccde3..bd68f8839 100644 --- a/src/commands/validators/config-assembly-flags.test.ts +++ b/src/commands/validators/config-assembly-flags.test.ts @@ -29,16 +29,6 @@ describe('config-assembly', () => { }); describe('network-isolation validation', () => { - it('should warn that network-isolation is experimental', () => { - mockBuildConfigOnce({ networkIsolation: true }); - - callAssembleWith(); - - expect(logger.warn).toHaveBeenCalledWith( - expect.stringContaining('--network-isolation is experimental'), - ); - }); - it('should exit if --network-isolation is combined with --dns-over-https', () => { mockBuildConfigOnce({ networkIsolation: true, dnsOverHttps: true }); @@ -47,7 +37,7 @@ describe('config-assembly', () => { }).toThrow('process.exit(1)'); expect(logger.error).toHaveBeenCalledWith( - expect.stringContaining('--network-isolation is not yet supported with --dns-over-https'), + expect.stringContaining('--network-isolation is not supported with --dns-over-https'), ); }); @@ -59,7 +49,7 @@ describe('config-assembly', () => { }).toThrow('process.exit(1)'); expect(logger.error).toHaveBeenCalledWith( - expect.stringContaining('--network-isolation is not yet supported with --enable-host-access'), + expect.stringContaining('--network-isolation is not supported with --enable-host-access'), ); }); diff --git a/src/commands/validators/config-assembly.test-utils.ts b/src/commands/validators/config-assembly.test-utils.ts index ebb2b485f..39230ad52 100644 --- a/src/commands/validators/config-assembly.test-utils.ts +++ b/src/commands/validators/config-assembly.test-utils.ts @@ -57,6 +57,7 @@ jest.mock('../build-config', () => ({ logLevel: args.logLevel, allowedDomains: args.allowedDomains, blockedDomains: args.blockedDomains, + securityMode: 'compat', enableApiProxy: false, enableTokenSteering: false, envAll: false, @@ -149,6 +150,7 @@ export const createBuildConfigResult = ( logLevel: 'info', allowedDomains: ['example.com'], blockedDomains: [], + securityMode: 'compat', enableApiProxy: false, enableTokenSteering: false, envAll: false, diff --git a/src/commands/validators/config-assembly.ts b/src/commands/validators/config-assembly.ts index a8bda7309..326186e49 100644 --- a/src/commands/validators/config-assembly.ts +++ b/src/commands/validators/config-assembly.ts @@ -6,6 +6,7 @@ import { LogAndLimitsResult } from './log-and-limits'; import { NetworkOptionsResult } from './network-options'; import { AgentOptionsResult } from './agent-options'; import { validateInfrastructureOptions, applyRateLimitConfig, validateFeatureFlagCompatibility } from './infrastructure-validator'; +import { applySecurityMode } from './security-mode'; import { validateHostAccessConfig } from './network-access-validator'; import { validateApiProxyOptions, validateCopilotModelOption } from './api-proxy-validator'; @@ -64,6 +65,7 @@ export function assembleAndValidateConfig( }); validateInfrastructureOptions(config); + applySecurityMode(config); applyAgentTimeout(options.agentTimeout as string | undefined, config, logger); applyRateLimitConfig(config, options); validateFeatureFlagCompatibility(config); diff --git a/src/commands/validators/infrastructure-validator.ts b/src/commands/validators/infrastructure-validator.ts index 8d8af25bd..470ae3bcb 100644 --- a/src/commands/validators/infrastructure-validator.ts +++ b/src/commands/validators/infrastructure-validator.ts @@ -100,21 +100,20 @@ export function validateFeatureFlagCompatibility(config: WrapperConfig): void { logger.debug(`Loading environment variables from file: ${config.envFile}`); } - // Network-isolation (topology) mode: reject combinations that are not yet + // Network-isolation (topology) mode: reject combinations that are not // supported because they depend on host-iptables or a sidecar that needs // direct external connectivity bypassing the dual-homed proxy. if (config.networkIsolation) { if (config.dnsOverHttps) { - logger.error('❌ --network-isolation is not yet supported with --dns-over-https.'); + logger.error('❌ --network-isolation is not supported with --dns-over-https.'); logger.error(' The DoH proxy needs direct external connectivity, which the internal network does not provide.'); process.exit(1); } if (config.enableHostAccess) { - logger.error('❌ --network-isolation is not yet supported with --enable-host-access.'); + logger.error('❌ --network-isolation is not supported with --enable-host-access.'); logger.error(' Host access relies on host-level iptables, which network-isolation mode does not configure.'); process.exit(1); } - logger.warn('⚠️ --network-isolation is experimental: egress is enforced via Docker network topology instead of iptables.'); } else if (config.topologyAttach && config.topologyAttach.length > 0) { logger.error('❌ --topology-attach requires --network-isolation.'); logger.error(' Trusted containers can only be attached to the internal topology network in network-isolation mode.'); diff --git a/src/commands/validators/security-mode.test.ts b/src/commands/validators/security-mode.test.ts new file mode 100644 index 000000000..1393b09b0 --- /dev/null +++ b/src/commands/validators/security-mode.test.ts @@ -0,0 +1,212 @@ +import { WrapperConfig } from '../../types'; +import { applySecurityMode } from './security-mode'; + +// Suppress logger output in tests +jest.mock('../../logger', () => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, +})); + +jest.mock('../../container-runtime', () => ({ + runtimeUsesComposeAgent: jest.fn().mockReturnValue(true), +})); + +import { logger } from '../../logger'; +import { runtimeUsesComposeAgent } from '../../container-runtime'; + +function makeConfig(overrides: Partial = {}): WrapperConfig { + return { + agentCommand: 'echo test', + logLevel: 'info', + allowedDomains: ['github.com'], + blockedDomains: [], + proxyLogsDir: '/tmp/logs', + dnsServers: ['8.8.8.8'], + enableHostAccess: false, + // networkIsolation and enableApiProxy are intentionally left undefined here + // to match the CLI default behaviour — users who do not explicitly pass + // --network-isolation or --enable-api-proxy will have undefined, not false. + enableDind: false, + sslBump: false, + enableDlp: false, + envAll: false, + buildLocal: false, + skipPull: false, + keepContainers: false, + imageRegistry: 'ghcr.io/github/gh-aw-firewall', + imageTag: 'latest', + localhostDetected: false, + ...overrides, + } as WrapperConfig; +} + +describe('applySecurityMode', () => { + beforeEach(() => { + jest.clearAllMocks(); + (runtimeUsesComposeAgent as jest.Mock).mockReturnValue(true); + }); + + describe('strict mode (default)', () => { + it('should force networkIsolation on when undefined (not explicitly set)', () => { + const config = makeConfig({ securityMode: 'strict', networkIsolation: undefined }); + applySecurityMode(config); + expect(config.networkIsolation).toBe(true); + expect(logger.warn).not.toHaveBeenCalledWith( + expect.stringContaining('--no-network-isolation'), + ); + }); + + it('should force networkIsolation on and warn when explicitly disabled', () => { + const config = makeConfig({ securityMode: 'strict', networkIsolation: false }); + applySecurityMode(config); + expect(config.networkIsolation).toBe(true); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('--no-network-isolation was ignored'), + ); + }); + + it('should force enableApiProxy on when undefined (not explicitly set)', () => { + const config = makeConfig({ securityMode: 'strict', enableApiProxy: undefined }); + applySecurityMode(config); + expect(config.enableApiProxy).toBe(true); + expect(logger.warn).not.toHaveBeenCalledWith( + expect.stringContaining('--no-enable-api-proxy'), + ); + }); + + it('should force enableApiProxy on and warn when explicitly disabled', () => { + const config = makeConfig({ securityMode: 'strict', enableApiProxy: false }); + applySecurityMode(config); + expect(config.enableApiProxy).toBe(true); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('--no-enable-api-proxy was ignored'), + ); + }); + + it('should be the default when securityMode is undefined', () => { + const config = makeConfig({ securityMode: undefined }); + applySecurityMode(config); + expect(config.networkIsolation).toBe(true); + expect(config.enableApiProxy).toBe(true); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it('should override enableHostAccess with warning', () => { + const config = makeConfig({ enableHostAccess: true }); + applySecurityMode(config); + expect(config.enableHostAccess).toBe(false); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('--enable-host-access was ignored'), + ); + }); + + it('should clear allowHostServicePorts when set (prevents downstream re-enable of host access)', () => { + const config = makeConfig({ allowHostServicePorts: '5432,6379' }); + applySecurityMode(config); + expect(config.allowHostServicePorts).toBeUndefined(); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('--allow-host-service-ports was ignored'), + ); + }); + + it('should clear allowHostServicePorts and allowHostPorts set alongside enableHostAccess', () => { + const config = makeConfig({ + enableHostAccess: true, + allowHostPorts: '3000,8080', + allowHostServicePorts: '5432', + }); + applySecurityMode(config); + expect(config.enableHostAccess).toBe(false); + expect(config.allowHostPorts).toBeUndefined(); + expect(config.allowHostServicePorts).toBeUndefined(); + }); + + it('should override enableDind with warning', () => { + const config = makeConfig({ enableDind: true }); + applySecurityMode(config); + expect(config.enableDind).toBe(false); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('--enable-dind was ignored'), + ); + }); + + it('should override dnsOverHttps with warning', () => { + const config = makeConfig({ dnsOverHttps: 'https://dns.google/dns-query' }); + applySecurityMode(config); + expect(config.dnsOverHttps).toBeUndefined(); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('--dns-over-https was ignored'), + ); + }); + + it('should warn that --security-mode compat is required for overridden options', () => { + const config = makeConfig({ enableHostAccess: true, enableDind: true }); + applySecurityMode(config); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('--security-mode compat'), + ); + }); + + it('should not warn when compatible options are already set', () => { + const config = makeConfig({ + securityMode: 'strict', + networkIsolation: true, + enableApiProxy: true, + enableHostAccess: false, + enableDind: false, + }); + applySecurityMode(config); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + describe('microVM runtime (sbx)', () => { + beforeEach(() => { + (runtimeUsesComposeAgent as jest.Mock).mockReturnValue(false); + }); + + it('should skip network-isolation enforcement for microVM runtimes', () => { + const config = makeConfig({ securityMode: 'strict', containerRuntime: 'sbx' }); + applySecurityMode(config); + expect(config.networkIsolation).toBeUndefined(); + expect(logger.warn).not.toHaveBeenCalledWith( + expect.stringContaining('network-isolation'), + ); + }); + + it('should still enforce api-proxy for microVM runtimes', () => { + const config = makeConfig({ securityMode: 'strict', containerRuntime: 'sbx' }); + applySecurityMode(config); + expect(config.enableApiProxy).toBe(true); + }); + }); + }); + + describe('compat mode', () => { + it('should not modify any config values', () => { + const config = makeConfig({ + securityMode: 'compat', + networkIsolation: false, + enableApiProxy: false, + enableHostAccess: true, + enableDind: true, + }); + applySecurityMode(config); + expect(config.networkIsolation).toBe(false); + expect(config.enableApiProxy).toBe(false); + expect(config.enableHostAccess).toBe(true); + expect(config.enableDind).toBe(true); + }); + + it('should log info about compat mode', () => { + const config = makeConfig({ securityMode: 'compat' }); + applySecurityMode(config); + expect(logger.info).toHaveBeenCalledWith( + expect.stringContaining('compat security mode'), + ); + }); + }); +}); diff --git a/src/commands/validators/security-mode.ts b/src/commands/validators/security-mode.ts new file mode 100644 index 000000000..69a0f2733 --- /dev/null +++ b/src/commands/validators/security-mode.ts @@ -0,0 +1,105 @@ +import { WrapperConfig } from '../../types'; +import { logger } from '../../logger'; +import { runtimeUsesComposeAgent } from '../../container-runtime'; + +/** + * Applies security-mode enforcement to the assembled config. + * + * In strict mode (the default), incompatible options are overridden with + * warnings and bundled defaults (network-isolation, api-proxy) are forced on. + * + * In compat mode, the legacy iptables-based configuration is preserved and + * no overrides are applied. + * + * Must be called **after** `buildConfig()` assembles the raw config from CLI + * options and config file, but **before** the downstream validators that + * check for mutual exclusions (since strict mode resolves those conflicts). + */ +export function applySecurityMode(config: WrapperConfig): void { + const mode = config.securityMode ?? 'strict'; + + if (mode === 'compat') { + logger.info('Running in compat security mode (legacy iptables-based enforcement).'); + return; + } + + // --- strict mode (default) --- + + // MicroVM runtimes (e.g. sbx) enforce isolation at the hypervisor layer via + // DOCKER_SANDBOXES_PROXY; Docker network topology does not apply to them. + const isMicroVmRuntime = !runtimeUsesComposeAgent(config.containerRuntime); + + if (!isMicroVmRuntime) { + // Force network-isolation on. + // Only warn when explicitly disabled (=== false); undefined means "not set by user". + if (!config.networkIsolation) { + if (config.networkIsolation === false) { + logger.warn( + '⚠️ --no-network-isolation was ignored (incompatible with --security-mode strict, the default).\n' + + ' Pass --security-mode compat to disable network isolation.', + ); + } + config.networkIsolation = true; + } + } + + // Force api-proxy on. + // Only warn when explicitly disabled (=== false); undefined means "not set by user". + if (!config.enableApiProxy) { + if (config.enableApiProxy === false) { + logger.warn( + '⚠️ --no-enable-api-proxy was ignored (incompatible with --security-mode strict, the default).\n' + + ' Pass --security-mode compat to disable the API proxy.', + ); + } + config.enableApiProxy = true; + } + + // Override incompatible options + if (config.enableHostAccess) { + logger.warn( + '⚠️ --enable-host-access was ignored (incompatible with --security-mode strict, the default).\n' + + ' Pass --security-mode compat to enable host access.', + ); + config.enableHostAccess = false; + // Also clear allowHostServicePorts: it auto-enables host access in + // applyHostServicePortsConfig() which runs later in the validator pipeline. + if (config.allowHostServicePorts) { + logger.warn( + '⚠️ --allow-host-service-ports was ignored (incompatible with --security-mode strict, the default).\n' + + ' Pass --security-mode compat to use host service ports.', + ); + config.allowHostServicePorts = undefined; + } + // Clear allowHostPorts that may have been auto-set by localhost keyword + if (config.allowHostPorts) { + config.allowHostPorts = undefined; + } + } + + // Similarly, allowHostServicePorts alone (without enableHostAccess) would + // auto-enable host access downstream — suppress it in strict mode. + if (config.allowHostServicePorts) { + logger.warn( + '⚠️ --allow-host-service-ports was ignored (incompatible with --security-mode strict, the default).\n' + + ' Pass --security-mode compat to use host service ports.', + ); + config.allowHostServicePorts = undefined; + } + + if (config.enableDind) { + logger.warn( + '⚠️ --enable-dind was ignored (incompatible with --security-mode strict, the default).\n' + + ' Pass --security-mode compat to enable Docker-in-Docker.', + ); + config.enableDind = false; + } + + if (config.dnsOverHttps) { + logger.warn( + '⚠️ --dns-over-https was ignored (incompatible with --security-mode strict, the default).\n' + + ' Pass --security-mode compat to use DNS-over-HTTPS.', + ); + config.dnsOverHttps = undefined; + } +} diff --git a/src/config-file.ts b/src/config-file.ts index 9c024589a..e20165bc5 100644 --- a/src/config-file.ts +++ b/src/config-file.ts @@ -88,6 +88,7 @@ export interface AwfFileConfig { }; }; security?: { + securityMode?: 'strict' | 'compat'; sslBump?: boolean; enableDlp?: boolean; enableHostAccess?: boolean; diff --git a/src/config-mapper.ts b/src/config-mapper.ts index 332de2aa9..e1bbd231c 100644 --- a/src/config-mapper.ts +++ b/src/config-mapper.ts @@ -85,6 +85,7 @@ export function mapAwfFileConfigToCliOptions(config: AwfFileConfig): Record 0) { args.push('--allow-domains', options.allowDomains.join(',')); diff --git a/tests/fixtures/batch-runner.ts b/tests/fixtures/batch-runner.ts index 532c2eab4..cf064cba2 100644 --- a/tests/fixtures/batch-runner.ts +++ b/tests/fixtures/batch-runner.ts @@ -1,7 +1,7 @@ /** * Batch Runner - runs multiple commands in a single AWF container invocation. * - * Each test that calls runner.runWithSudo() spawns a full Docker container + * Each test that calls runner.run() spawns a full Docker container * lifecycle (~15-25s overhead). This utility batches commands that share the * same allowDomains config into one invocation, cutting container startups * from ~73 to ~27 across the chroot test suite. @@ -101,7 +101,7 @@ export async function runBatch( options: AwfOptions, ): Promise { const script = generateScript(commands); - const result = await runner.runWithSudo(script, options); + const result = await runner.run(script, options); const parsed = parseResults(result.stdout, commands); return { diff --git a/tests/integration/api-proxy-observability.test.ts b/tests/integration/api-proxy-observability.test.ts index ce69dae44..5bc27e1dc 100644 --- a/tests/integration/api-proxy-observability.test.ts +++ b/tests/integration/api-proxy-observability.test.ts @@ -28,7 +28,7 @@ describe('API Proxy Observability', () => { }); test('should return valid JSON metrics from /metrics endpoint', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( `curl -s http://${API_PROXY_IP}:10000/metrics`, { allowDomains: ['api.anthropic.com'], @@ -52,7 +52,7 @@ describe('API Proxy Observability', () => { }, 180000); test('should include metrics_summary in /health response', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( `curl -s http://${API_PROXY_IP}:10000/health`, { allowDomains: ['api.anthropic.com'], @@ -74,7 +74,7 @@ describe('API Proxy Observability', () => { test('should return X-Request-ID header in proxy responses', async () => { // Make a request to the Anthropic proxy and check for x-request-id in response headers - const result = await runner.runWithSudo( + const result = await runner.run( `bash -c 'curl -s -i -X POST http://${API_PROXY_IP}:10001/v1/messages -H "Content-Type: application/json" -d "{\\"model\\":\\"test\\"}"'`, { allowDomains: ['api.anthropic.com'], @@ -102,7 +102,7 @@ describe('API Proxy Observability', () => { `curl -s http://${API_PROXY_IP}:10000/metrics`, ].join(' && '); - const result = await runner.runWithSudo( + const result = await runner.run( `bash -c '${script}'`, { allowDomains: ['api.anthropic.com'], @@ -122,7 +122,7 @@ describe('API Proxy Observability', () => { }, 180000); test('should include rate_limits in /health when rate limiting is active', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( `bash -c 'curl -s -X POST http://${API_PROXY_IP}:10001/v1/messages -H "Content-Type: application/json" -d "{\\"model\\":\\"test\\"}" > /dev/null && curl -s http://${API_PROXY_IP}:10000/health'`, { allowDomains: ['api.anthropic.com'], @@ -142,7 +142,7 @@ describe('API Proxy Observability', () => { }, 180000); test('should preserve custom X-Request-ID when valid', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( `bash -c 'curl -s -i -X POST http://${API_PROXY_IP}:10001/v1/messages -H "Content-Type: application/json" -H "X-Request-ID: my-custom-trace-abc123" -d "{\\"model\\":\\"test\\"}"'`, { allowDomains: ['api.anthropic.com'], @@ -162,7 +162,7 @@ describe('API Proxy Observability', () => { }, 180000); test('should reject invalid X-Request-ID and generate a new one', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( `bash -c 'curl -s -i -X POST http://${API_PROXY_IP}:10001/v1/messages -H "Content-Type: application/json" -H "X-Request-ID: " -d "{\\"model\\":\\"test\\"}"'`, { allowDomains: ['api.anthropic.com'], @@ -193,7 +193,7 @@ describe('API Proxy Observability', () => { `curl -s http://${API_PROXY_IP}:10000/metrics`, ].join(' && '); - const result = await runner.runWithSudo( + const result = await runner.run( `bash -c '${script}'`, { allowDomains: ['api.anthropic.com'], @@ -224,7 +224,7 @@ describe('API Proxy Observability', () => { `curl -s http://${API_PROXY_IP}:10000/metrics`, ].join(' && '); - const result = await runner.runWithSudo( + const result = await runner.run( `bash -c '${script}'`, { allowDomains: ['api.anthropic.com'], diff --git a/tests/integration/api-proxy-rate-limit.test.ts b/tests/integration/api-proxy-rate-limit.test.ts index eb8794d3a..6a8b52099 100644 --- a/tests/integration/api-proxy-rate-limit.test.ts +++ b/tests/integration/api-proxy-rate-limit.test.ts @@ -38,7 +38,7 @@ describe('API Proxy Rate Limiting', () => { 'if [ "$ALL_OK" = "true" ]; then echo "NO_RATE_LIMITS"; else echo "GOT_429"; fi', ].join('\n'); - const result = await runner.runWithSudo( + const result = await runner.run( `bash -c '${script}'`, { allowDomains: ['api.anthropic.com'], @@ -68,7 +68,7 @@ describe('API Proxy Rate Limiting', () => { 'echo "$RESULTS"', ].join('\n'); - const result = await runner.runWithSudo( + const result = await runner.run( `bash -c '${script}'`, { allowDomains: ['api.anthropic.com'], @@ -94,7 +94,7 @@ describe('API Proxy Rate Limiting', () => { `curl -s -X POST http://${API_PROXY_IP}:10001/v1/messages -H "Content-Type: application/json" -d "{\\"model\\":\\"test\\"}"`, ].join(' && '); - const result = await runner.runWithSudo( + const result = await runner.run( `bash -c '${script}'`, { allowDomains: ['api.anthropic.com'], @@ -121,7 +121,7 @@ describe('API Proxy Rate Limiting', () => { `curl -s -X POST http://${API_PROXY_IP}:10001/v1/messages -H "Content-Type: application/json" -d "{\\"model\\":\\"test\\"}"`, ].join(' && '); - const result = await runner.runWithSudo( + const result = await runner.run( `bash -c '${script}'`, { allowDomains: ['api.anthropic.com'], @@ -157,7 +157,7 @@ describe('API Proxy Rate Limiting', () => { 'if [ "$ALL_OK" = "true" ]; then echo "NO_RATE_LIMITS_HIT"; else echo "RATE_LIMIT_429_DETECTED"; fi', ].join('\n'); - const result = await runner.runWithSudo( + const result = await runner.run( `bash -c '${script}'`, { allowDomains: ['api.anthropic.com'], @@ -182,7 +182,7 @@ describe('API Proxy Rate Limiting', () => { `curl -s http://${API_PROXY_IP}:10000/health`, ].join(' && '); - const result = await runner.runWithSudo( + const result = await runner.run( `bash -c '${script}'`, { allowDomains: ['api.anthropic.com'], @@ -215,7 +215,7 @@ describe('API Proxy Rate Limiting', () => { `curl -s http://${API_PROXY_IP}:10000/metrics`, ].join(' && '); - const result = await runner.runWithSudo( + const result = await runner.run( `bash -c '${script}'`, { allowDomains: ['api.anthropic.com'], diff --git a/tests/integration/api-proxy.test.ts b/tests/integration/api-proxy.test.ts index 56891b8c1..312e479cc 100644 --- a/tests/integration/api-proxy.test.ts +++ b/tests/integration/api-proxy.test.ts @@ -30,7 +30,7 @@ describe('API Proxy Sidecar', () => { // This is the first test to run and may trigger a cold Docker build for the // api-proxy / iptables-init images (not pre-built in the CI "Build local containers" // step). Allow up to 5 minutes for the build + startup + run + teardown. - const result = await runner.runWithSudo( + const result = await runner.run( `curl -s http://${API_PROXY_IP}:10001/health`, { allowDomains: ['api.anthropic.com'], @@ -50,7 +50,7 @@ describe('API Proxy Sidecar', () => { }, 360000); test('should start api-proxy sidecar with OpenAI key and pass healthcheck', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( `curl -s http://${API_PROXY_IP}:10000/health`, { allowDomains: ['api.openai.com'], @@ -70,7 +70,7 @@ describe('API Proxy Sidecar', () => { }, 180000); test('should set ANTHROPIC_BASE_URL in agent when Anthropic key is provided', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'bash -c "echo ANTHROPIC_BASE_URL=$ANTHROPIC_BASE_URL"', { allowDomains: ['api.anthropic.com'], @@ -89,7 +89,7 @@ describe('API Proxy Sidecar', () => { }, 180000); test('should set ANTHROPIC_AUTH_TOKEN to placeholder in agent when Anthropic key is provided', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'bash -c "echo ANTHROPIC_AUTH_TOKEN=$ANTHROPIC_AUTH_TOKEN"', { allowDomains: ['api.anthropic.com'], @@ -108,7 +108,7 @@ describe('API Proxy Sidecar', () => { }, 180000); test('should set OPENAI_BASE_URL in agent when OpenAI key is provided', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'bash -c "echo OPENAI_BASE_URL=$OPENAI_BASE_URL"', { allowDomains: ['api.openai.com'], @@ -129,7 +129,7 @@ describe('API Proxy Sidecar', () => { test('should route Anthropic API requests through Squid', async () => { // Use a fake API key — the request will reach api.anthropic.com via Squid // and get an auth error (401), but that proves the proxy routes through Squid. - const result = await runner.runWithSudo( + const result = await runner.run( `bash -c "curl -s -X POST http://${API_PROXY_IP}:10001/v1/messages -H 'Content-Type: application/json' -d '{\"model\":\"claude-3-haiku-20240307\",\"max_tokens\":10,\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}'"`, { allowDomains: ['api.anthropic.com'], @@ -154,7 +154,7 @@ describe('API Proxy Sidecar', () => { test('should set both health and Anthropic endpoints with Anthropic key only', async () => { // When only Anthropic key is provided, port 10000 should still serve /health // (needed for Docker healthcheck) and port 10001 should serve the Anthropic proxy - const result = await runner.runWithSudo( + const result = await runner.run( `bash -c "curl -s http://${API_PROXY_IP}:10000/health && echo && curl -s http://${API_PROXY_IP}:10001/health"`, { allowDomains: ['api.anthropic.com'], @@ -177,7 +177,7 @@ describe('API Proxy Sidecar', () => { }, 180000); test('should start api-proxy sidecar with Copilot key and pass healthcheck', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( `curl -s http://${API_PROXY_IP}:10002/health`, { allowDomains: ['api.githubcopilot.com'], @@ -197,7 +197,7 @@ describe('API Proxy Sidecar', () => { }, 180000); test('should set COPILOT_API_URL in agent when Copilot token is provided', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'bash -c "echo COPILOT_API_URL=$COPILOT_API_URL"', { allowDomains: ['api.githubcopilot.com'], @@ -216,7 +216,7 @@ describe('API Proxy Sidecar', () => { }, 180000); test('should set COPILOT_TOKEN to placeholder in agent when Copilot token is provided', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'bash -c "echo COPILOT_TOKEN=$COPILOT_TOKEN"', { allowDomains: ['api.githubcopilot.com'], @@ -236,7 +236,7 @@ describe('API Proxy Sidecar', () => { test('should report copilot in health providers when Copilot token is provided', async () => { // When Copilot token is provided, the main health endpoint should report copilot: true - const result = await runner.runWithSudo( + const result = await runner.run( `curl -s http://${API_PROXY_IP}:10000/health`, { allowDomains: ['api.githubcopilot.com'], @@ -261,7 +261,7 @@ describe('API Proxy Sidecar', () => { // Instead, the agent should use COPILOT_API_URL pointing to the proxy, which correctly // routes to api.enterprise.githubcopilot.com. // See: github/gh-aw#20875 - const result = await runner.runWithSudo( + const result = await runner.run( 'bash -c "if [ -z \\"$GITHUB_API_URL\\" ]; then echo GITHUB_API_URL_NOT_SET; else echo GITHUB_API_URL=$GITHUB_API_URL; fi"', { allowDomains: ['api.githubcopilot.com'], @@ -286,7 +286,7 @@ describe('API Proxy Sidecar', () => { test('should pass GITHUB_API_URL to agent when api-proxy is NOT enabled', async () => { // When api-proxy is disabled, GITHUB_API_URL should be passed through normally - const result = await runner.runWithSudo( + const result = await runner.run( 'bash -c "echo GITHUB_API_URL=$GITHUB_API_URL"', { allowDomains: ['api.githubcopilot.com'], diff --git a/tests/integration/api-target-allowlist.test.ts b/tests/integration/api-target-allowlist.test.ts index 546565432..50f6e46d0 100644 --- a/tests/integration/api-target-allowlist.test.ts +++ b/tests/integration/api-target-allowlist.test.ts @@ -24,7 +24,7 @@ describe('API Target Allowlist', () => { }); test('should automatically add copilot-api-target to allowlist', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'curl -s https://example.com', { allowDomains: ['github.com'], // Note: NOT including example.com @@ -44,7 +44,7 @@ describe('API Target Allowlist', () => { }, 120000); test('should automatically add openai-api-target to allowlist', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'curl -s https://custom.openai-router.internal', { allowDomains: ['github.com'], // Note: NOT including custom.openai-router.internal @@ -64,7 +64,7 @@ describe('API Target Allowlist', () => { }, 120000); test('should automatically add anthropic-api-target to allowlist', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'curl -s https://example.net', { allowDomains: ['github.com'], // Note: NOT including example.net @@ -84,7 +84,7 @@ describe('API Target Allowlist', () => { }, 120000); test('should add api-target from environment variable to allowlist', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'curl -s https://example.org', { allowDomains: ['github.com'], // Note: NOT including example.org @@ -104,7 +104,7 @@ describe('API Target Allowlist', () => { }, 120000); test('should not add default api-targets to allowlist automatically', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'curl -s https://api.githubcopilot.com', { allowDomains: ['github.com'], // Note: NOT including default api.githubcopilot.com @@ -120,7 +120,7 @@ describe('API Target Allowlist', () => { }, 120000); test('should not duplicate domains if api-target is already in allowlist', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'curl -s https://api.custom.com', { allowDomains: ['api.custom.com', 'github.com'], // Already includes api.custom.com @@ -150,7 +150,7 @@ describe('API Target Allowlist', () => { }, 120000); test('should add multiple api-targets when multiple are specified', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'echo "Testing multiple api-targets"', { allowDomains: ['github.com'], diff --git a/tests/integration/blocked-domains.test.ts b/tests/integration/blocked-domains.test.ts index bc4de891a..a20308263 100644 --- a/tests/integration/blocked-domains.test.ts +++ b/tests/integration/blocked-domains.test.ts @@ -33,7 +33,7 @@ describe('Blocked Domains Functionality', () => { // Allow github.com but block a specific subdomain // Note: Currently blocked domains are checked against the ACL, so this tests // that the blocking mechanism is properly configured - const result = await runner.runWithSudo( + const result = await runner.run( 'curl --max-time 10 https://api.github.com/zen', { allowDomains: ['github.com'], @@ -47,7 +47,7 @@ describe('Blocked Domains Functionality', () => { }, 120000); test('should allow requests to allowed domains', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'curl --max-time 10 https://api.github.com/zen', { allowDomains: ['github.com'], @@ -60,7 +60,7 @@ describe('Blocked Domains Functionality', () => { }, 120000); test('should block requests to non-allowed domains', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'curl -f --max-time 5 https://example.com', { allowDomains: ['github.com'], @@ -75,7 +75,7 @@ describe('Blocked Domains Functionality', () => { test('should handle multiple blocked domains', async () => { // Test that multiple allowed domains work together - const result = await runner.runWithSudo( + const result = await runner.run( 'bash -c "curl --max-time 10 https://api.github.com/zen && echo success"', { allowDomains: ['github.com', 'npmjs.org'], @@ -89,7 +89,7 @@ describe('Blocked Domains Functionality', () => { }, 120000); test('should show allowed domains in debug output', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'echo "test"', { allowDomains: ['github.com', 'example.com'], @@ -130,7 +130,7 @@ describe('Domain Allowlist Edge Cases', () => { test('should handle case-insensitive domain matching', async () => { // Test that domains are matched case-insensitively - const result = await runner.runWithSudo( + const result = await runner.run( 'curl --max-time 10 https://API.GITHUB.COM/zen', { allowDomains: ['github.com'], @@ -146,7 +146,7 @@ describe('Domain Allowlist Edge Cases', () => { // Trailing dots in FQDN format (e.g., "github.com.") are not currently // normalized by the domain allowlist. Squid treats "github.com." and // "github.com" as different domains, so the request is blocked. - const result = await runner.runWithSudo( + const result = await runner.run( 'curl -f --max-time 10 https://api.github.com/zen', { allowDomains: ['github.com.'], @@ -159,7 +159,7 @@ describe('Domain Allowlist Edge Cases', () => { }, 120000); test('should handle domains with leading/trailing whitespace in config', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'curl --max-time 10 https://api.github.com/zen', { allowDomains: [' github.com '], @@ -173,7 +173,7 @@ describe('Domain Allowlist Edge Cases', () => { test('should block IP address access when only domain is allowed', async () => { // Direct IP access should be blocked when only domain is in allowlist - const result = await runner.runWithSudo( + const result = await runner.run( 'bash -c "ip=$(dig +short api.github.com | head -1); curl -fk --max-time 5 https://$ip 2>&1 || echo blocked"', { allowDomains: ['github.com'], @@ -200,7 +200,7 @@ describe('Block Domains Deny-List (--block-domains)', () => { }); test('should block specific subdomain while allowing parent domain', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'curl -f --max-time 10 https://api.github.com/zen', { allowDomains: ['github.com'], @@ -213,7 +213,7 @@ describe('Block Domains Deny-List (--block-domains)', () => { }, 120000); test('should still allow non-blocked subdomains when parent is allowed', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'curl -f --retry 3 --retry-all-errors --retry-delay 1 --max-time 10 https://github.com', { allowDomains: ['github.com'], @@ -226,7 +226,7 @@ describe('Block Domains Deny-List (--block-domains)', () => { }, 120000); test('should block domain that is also in the allow list (block takes precedence)', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'curl -f --max-time 5 https://example.com', { allowDomains: ['example.com'], @@ -239,7 +239,7 @@ describe('Block Domains Deny-List (--block-domains)', () => { }, 120000); test('should block wildcard pattern while allowing parent domain', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'curl -f --max-time 10 https://api.github.com/zen', { allowDomains: ['github.com'], @@ -252,7 +252,7 @@ describe('Block Domains Deny-List (--block-domains)', () => { }, 120000); test('should handle multiple blocked domains', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'bash -c "' + 'curl -f --max-time 10 https://api.github.com/zen 2>&1; api_exit=$?; ' + 'curl -f --max-time 10 https://raw.githubusercontent.com 2>&1; raw_exit=$?; ' + @@ -270,7 +270,7 @@ describe('Block Domains Deny-List (--block-domains)', () => { }, 120000); test('should show blocked domains in debug output', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'echo "test"', { allowDomains: ['github.com'], diff --git a/tests/integration/chroot-edge-cases.test.ts b/tests/integration/chroot-edge-cases.test.ts index 64778fb6e..5e65c4d80 100644 --- a/tests/integration/chroot-edge-cases.test.ts +++ b/tests/integration/chroot-edge-cases.test.ts @@ -83,7 +83,7 @@ describe('Chroot Edge Cases', () => { fs.writeFileSync(markerPath, 'toolcache-ok\n'); try { - const result = await runner.runWithSudo(`cat "${markerPath}"`, { + const result = await runner.run(`cat "${markerPath}"`, { allowDomains: ['localhost'], logLevel: 'debug', timeout: 120000, @@ -267,7 +267,7 @@ describe('Chroot Edge Cases', () => { // ---------- Individual: Working directory tests (different containerWorkDir options) ---------- describe('Working Directory Handling', () => { test('should respect container-workdir in chroot mode', async () => { - const result = await runner.runWithSudo('pwd', { + const result = await runner.run('pwd', { allowDomains: ['localhost'], logLevel: 'debug', timeout: 60000, @@ -279,7 +279,7 @@ describe('Chroot Edge Cases', () => { }, 120000); test('should fall back to home directory if workdir does not exist', async () => { - const result = await runner.runWithSudo('pwd', { + const result = await runner.run('pwd', { allowDomains: ['localhost'], logLevel: 'debug', timeout: 60000, @@ -295,7 +295,7 @@ describe('Chroot Edge Cases', () => { // ---------- Individual: Exit code propagation (tests AWF process exit code) ---------- describe('Exit Code Propagation', () => { test('should propagate exit code 0', async () => { - const result = await runner.runWithSudo('exit 0', { + const result = await runner.run('exit 0', { allowDomains: ['localhost'], logLevel: 'debug', timeout: 60000, @@ -305,7 +305,7 @@ describe('Chroot Edge Cases', () => { }, 120000); test('should propagate exit code 1', async () => { - const result = await runner.runWithSudo('exit 1', { + const result = await runner.run('exit 1', { allowDomains: ['localhost'], logLevel: 'debug', timeout: 60000, @@ -315,7 +315,7 @@ describe('Chroot Edge Cases', () => { }, 120000); test('should propagate exit code from failed command', async () => { - const result = await runner.runWithSudo('false', { + const result = await runner.run('false', { allowDomains: ['localhost'], logLevel: 'debug', timeout: 60000, @@ -325,7 +325,7 @@ describe('Chroot Edge Cases', () => { }, 120000); test('should propagate exit code 127 for command not found', async () => { - const result = await runner.runWithSudo('nonexistent_command_xyz123', { + const result = await runner.run('nonexistent_command_xyz123', { allowDomains: ['localhost'], logLevel: 'debug', timeout: 60000, @@ -338,7 +338,7 @@ describe('Chroot Edge Cases', () => { // ---------- Individual: Network tests (different domains per test) ---------- describe('Network Firewall Enforcement', () => { test('should allow HTTPS to whitelisted domains', async () => { - const result = await runner.runWithSudo('curl -s -o /dev/null -w "%{http_code}" https://api.github.com', { + const result = await runner.run('curl -s -o /dev/null -w "%{http_code}" https://api.github.com', { allowDomains: ['api.github.com'], logLevel: 'debug', timeout: 60000, @@ -353,7 +353,7 @@ describe('Chroot Edge Cases', () => { }, 120000); test('should block HTTPS to non-whitelisted domains', async () => { - const result = await runner.runWithSudo('curl -s --connect-timeout 5 https://example.com 2>&1', { + const result = await runner.run('curl -s --connect-timeout 5 https://example.com 2>&1', { allowDomains: ['github.com'], logLevel: 'debug', timeout: 30000, @@ -364,7 +364,7 @@ describe('Chroot Edge Cases', () => { }, 60000); test('should block HTTP to non-whitelisted domains', async () => { - const result = await runner.runWithSudo('curl -f --connect-timeout 5 http://example.com 2>&1', { + const result = await runner.run('curl -f --connect-timeout 5 http://example.com 2>&1', { allowDomains: ['github.com'], logLevel: 'debug', timeout: 30000, diff --git a/tests/integration/chroot-languages.test.ts b/tests/integration/chroot-languages.test.ts index 60cfeca96..b1ed3150e 100644 --- a/tests/integration/chroot-languages.test.ts +++ b/tests/integration/chroot-languages.test.ts @@ -178,7 +178,7 @@ describe('Chroot Language Support', () => { // ---------- Individual: Java compile tests (longer timeout) ---------- describe('Java', () => { test('should compile and run Java Hello World', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'TESTDIR=$(mktemp -d) && ' + 'echo \'public class Hello { public static void main(String[] args) { System.out.println("Hello from Java"); } }\' > $TESTDIR/Hello.java && ' + 'cd $TESTDIR && javac Hello.java && java Hello && rm -rf $TESTDIR', @@ -194,7 +194,7 @@ describe('Chroot Language Support', () => { }, 180000); test('should access Java standard library (java.util)', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'TESTDIR=$(mktemp -d) && ' + 'cat > $TESTDIR/TestUtil.java << \'EOF\'\n' + 'import java.util.Arrays;\n' + @@ -223,7 +223,7 @@ describe('Chroot Language Support', () => { // ---------- Individual: .NET compile test (different domains, long timeout) ---------- describe('.NET', () => { test('should create and run a .NET console app', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'TESTDIR=$(mktemp -d) && cd $TESTDIR && ' + 'dotnet new console -o testapp --no-restore && ' + 'cd testapp && dotnet restore && dotnet run && ' + diff --git a/tests/integration/chroot-package-managers.test.ts b/tests/integration/chroot-package-managers.test.ts index bed977dc0..7969684b5 100644 --- a/tests/integration/chroot-package-managers.test.ts +++ b/tests/integration/chroot-package-managers.test.ts @@ -60,7 +60,7 @@ describe('Chroot Package Manager Support', () => { // Individual: localhost-only test test('should show package info without network', async () => { - const result = await runner.runWithSudo('pip3 show pip', { + const result = await runner.run('pip3 show pip', { allowDomains: ['localhost'], logLevel: 'debug', timeout: 60000, @@ -99,7 +99,7 @@ describe('Chroot Package Manager Support', () => { // Individual: blocking test (different domain) test('should be blocked from npm registry without domain', async () => { - const result = await runner.runWithSudo('npm view chalk version 2>&1', { + const result = await runner.run('npm view chalk version 2>&1', { allowDomains: ['localhost'], logLevel: 'debug', timeout: 60000, @@ -140,7 +140,7 @@ describe('Chroot Package Manager Support', () => { // Individual: localhost test test('should execute rustc from host via chroot', async () => { - const result = await runner.runWithSudo('rustc --version', { + const result = await runner.run('rustc --version', { allowDomains: ['localhost'], logLevel: 'debug', timeout: 60000, @@ -182,7 +182,7 @@ describe('Chroot Package Manager Support', () => { // Individual: maven (different domain) test('should execute maven from host via chroot', async () => { - const result = await runner.runWithSudo('mvn --version 2>&1', { + const result = await runner.run('mvn --version 2>&1', { allowDomains: ['repo.maven.apache.org', 'repo1.maven.org'], logLevel: 'debug', timeout: 60000, @@ -224,7 +224,7 @@ describe('Chroot Package Manager Support', () => { // Individual: NuGet restore (different domains, long timeout) test('should create and build a .NET project with NuGet restore', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'TESTDIR=$(mktemp -d) && cd $TESTDIR && ' + 'dotnet new console -o buildtest --no-restore && ' + 'cd buildtest && dotnet restore && dotnet build --no-restore && ' + @@ -243,7 +243,7 @@ describe('Chroot Package Manager Support', () => { // Individual: blocking test (localhost only) test('should be blocked from NuGet without domain whitelisting', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'TESTDIR=$(mktemp -d) && cd $TESTDIR && ' + 'dotnet new console -o blocktest --no-restore 2>&1 && ' + 'cd blocktest && ' + @@ -326,7 +326,7 @@ describe('Chroot Package Manager Support', () => { // ---------- Go modules ---------- describe('Go modules', () => { test('should show go env', async () => { - const result = await runner.runWithSudo('go env GOPATH GOPROXY', { + const result = await runner.run('go env GOPATH GOPROXY', { allowDomains: ['proxy.golang.org', 'sum.golang.org'], logLevel: 'debug', timeout: 60000, @@ -336,7 +336,7 @@ describe('Chroot Package Manager Support', () => { }, 120000); test('should list go modules (no network needed for empty list)', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'cd /tmp && mkdir -p gotest && cd gotest && go mod init test 2>&1 && go mod tidy 2>&1 && cat go.mod', { allowDomains: ['localhost'], @@ -353,7 +353,7 @@ describe('Chroot Package Manager Support', () => { // ---------- Package Installation ---------- describe('Package Installation', () => { test('should install a Python package via pip and verify import', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'PIPDIR=$(mktemp -d) && ' + 'pip3 install --no-cache-dir --target $PIPDIR requests 2>&1 && ' + 'PYTHONPATH=$PIPDIR python3 -c "import requests; print(requests.__version__)" && ' + @@ -372,7 +372,7 @@ describe('Chroot Package Manager Support', () => { }, 180000); test('should install an npm package and verify require', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'NPMDIR=$(mktemp -d) && cd $NPMDIR && npm init -y 2>&1 && ' + 'npm install chalk@4 2>&1 && ' + 'NODE_PATH=$NPMDIR/node_modules node -e "require(\'chalk\')" && echo "npm_install_ok" && ' + @@ -389,7 +389,7 @@ describe('Chroot Package Manager Support', () => { }, 180000); test('should build a Rust project with a dependency via cargo', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'TESTDIR=$(mktemp -d) && cd $TESTDIR && ' + 'CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse ' + 'cargo init --name awftest 2>&1 && ' + diff --git a/tests/integration/cli-proxy.test.ts b/tests/integration/cli-proxy.test.ts index 22c40ad0e..d33ee2a7b 100644 --- a/tests/integration/cli-proxy.test.ts +++ b/tests/integration/cli-proxy.test.ts @@ -54,7 +54,7 @@ describe('CLI Proxy Sidecar', () => { describe('Token Isolation', () => { test('should not expose GITHUB_TOKEN in agent environment', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'bash -c "if [ -z \\"$GITHUB_TOKEN\\" ]; then echo GITHUB_TOKEN_NOT_SET; else echo GITHUB_TOKEN=$GITHUB_TOKEN; fi"', cliProxyDefaults, ); @@ -65,7 +65,7 @@ describe('CLI Proxy Sidecar', () => { }, 180000); test('should not expose GH_TOKEN in agent environment', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'bash -c "if [ -z \\"$GH_TOKEN\\" ]; then echo GH_TOKEN_NOT_SET; else echo GH_TOKEN=$GH_TOKEN; fi"', { ...cliProxyDefaults, @@ -81,7 +81,7 @@ describe('CLI Proxy Sidecar', () => { }, 180000); test('should set AWF_CLI_PROXY_URL in agent environment', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'bash -c "echo AWF_CLI_PROXY_URL=$AWF_CLI_PROXY_URL"', cliProxyDefaults, ); @@ -95,7 +95,7 @@ describe('CLI Proxy Sidecar', () => { test('should install gh wrapper that routes to cli-proxy', async () => { // The gh wrapper should be at /usr/local/bin/gh or accessible via PATH. // Running 'which gh' should find it. - const result = await runner.runWithSudo( + const result = await runner.run( 'bash -c "which gh && head -3 $(which gh)"', cliProxyDefaults, ); @@ -109,7 +109,7 @@ describe('CLI Proxy Sidecar', () => { test('should execute gh commands through the wrapper', async () => { // gh --version should work through the proxy (it runs locally in the sidecar) // Note: this tests that the wrapper → HTTP POST → server.js → execFile chain works - const result = await runner.runWithSudo( + const result = await runner.run( 'gh --version', cliProxyDefaults, ); @@ -125,7 +125,7 @@ describe('CLI Proxy Sidecar', () => { approvedIntegrityLiveTest( 'should preserve array JSON responses for gh api issue comment endpoints under approved integrity', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'bash -o pipefail -c \'gh api "repos/github/gh-aw-firewall/issues/1/comments?per_page=1" | jq -er type\'', { ...cliProxyDefaults, @@ -145,7 +145,7 @@ describe('CLI Proxy Sidecar', () => { describe('Meta-command Denial', () => { test('should block auth subcommand', async () => { // 'auth' is always denied (meta-command) - const result = await runner.runWithSudo( + const result = await runner.run( `bash -c 'curl -s -w "\\nHTTP_STATUS:%{http_code}" -X POST http://${CLI_PROXY_IP}:${CLI_PROXY_PORT}/exec -H "Content-Type: application/json" -d "{\\"args\\":[\\"auth\\",\\"status\\"]}"'`, cliProxyDefaults, ); diff --git a/tests/integration/container-workdir.test.ts b/tests/integration/container-workdir.test.ts index cb9fc5ef7..cfe24577f 100644 --- a/tests/integration/container-workdir.test.ts +++ b/tests/integration/container-workdir.test.ts @@ -29,7 +29,7 @@ describe('Container Working Directory', () => { }); test('should use default working directory (user home in chroot mode)', async () => { - const result = await runner.runWithSudo('pwd', { + const result = await runner.run('pwd', { allowDomains: ['github.com'], logLevel: 'debug', timeout: 60000, @@ -44,7 +44,7 @@ describe('Container Working Directory', () => { }, 120000); test('should use custom working directory when --container-workdir is specified', async () => { - const result = await runner.runWithSudo('pwd', { + const result = await runner.run('pwd', { allowDomains: ['github.com'], logLevel: 'debug', timeout: 60000, @@ -57,7 +57,7 @@ describe('Container Working Directory', () => { test('should execute commands in the specified working directory', async () => { // Create a file in /tmp and verify we can list it from /tmp working directory - const result = await runner.runWithSudo( + const result = await runner.run( 'bash -c "touch testfile.txt && ls -la | grep testfile"', { allowDomains: ['github.com'], @@ -72,7 +72,7 @@ describe('Container Working Directory', () => { }, 120000); test('should work with home directory as working directory', async () => { - const result = await runner.runWithSudo('pwd', { + const result = await runner.run('pwd', { allowDomains: ['github.com'], logLevel: 'debug', timeout: 60000, @@ -86,7 +86,7 @@ describe('Container Working Directory', () => { test('should allow relative path access from custom working directory', async () => { // Verify that relative paths work correctly from the custom workdir - const result = await runner.runWithSudo( + const result = await runner.run( 'bash -c "cd .. && pwd"', { allowDomains: ['github.com'], diff --git a/tests/integration/dns-servers.test.ts b/tests/integration/dns-servers.test.ts index ee19469e5..0a2584323 100644 --- a/tests/integration/dns-servers.test.ts +++ b/tests/integration/dns-servers.test.ts @@ -29,8 +29,8 @@ describe('DNS Resolution via Docker Embedded DNS', () => { test('should resolve DNS for allowed domains via Docker embedded DNS', async () => { // DNS resolution uses Docker embedded DNS (127.0.0.11) which forwards // to upstream servers configured via docker-compose dns: field - const result = await runner.runWithSudo( - 'nslookup github.com', + const result = await runner.run( + 'dig github.com +short', { allowDomains: ['github.com'], logLevel: 'debug', @@ -39,12 +39,12 @@ describe('DNS Resolution via Docker Embedded DNS', () => { ); expect(result).toSucceed(); - expect(result.stdout).toContain('Address'); + expect(result.stdout.trim()).toMatch(/\d+\.\d+\.\d+\.\d+/); }, 120000); test('should resolve multiple domains sequentially', async () => { - const result = await runner.runWithSudo( - 'bash -c "nslookup github.com && nslookup api.github.com"', + const result = await runner.run( + 'bash -c "dig github.com +short && dig api.github.com +short"', { allowDomains: ['github.com'], logLevel: 'debug', @@ -53,11 +53,11 @@ describe('DNS Resolution via Docker Embedded DNS', () => { ); expect(result).toSucceed(); - expect(result.stdout).toContain('github.com'); + expect(result.stdout.trim()).toMatch(/\d+\.\d+\.\d+\.\d+/); }, 120000); test('should resolve DNS with dig command via Docker embedded DNS', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'dig github.com +short', { allowDomains: ['github.com'], @@ -72,7 +72,7 @@ describe('DNS Resolution via Docker Embedded DNS', () => { }, 120000); test('should show DNS configuration in debug output', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'echo "test"', { allowDomains: ['github.com'], @@ -88,8 +88,8 @@ describe('DNS Resolution via Docker Embedded DNS', () => { test('should work with custom DNS servers for Docker forwarding', async () => { // Custom --dns-servers configures Docker embedded DNS upstream forwarding - const result = await runner.runWithSudo( - 'nslookup github.com', + const result = await runner.run( + 'dig github.com +short', { allowDomains: ['github.com'], dnsServers: ['1.1.1.1'], @@ -99,7 +99,7 @@ describe('DNS Resolution via Docker Embedded DNS', () => { ); expect(result).toSucceed(); - expect(result.stdout).toContain('Address'); + expect(result.stdout.trim()).toMatch(/\d+\.\d+\.\d+\.\d+/); }, 120000); }); @@ -121,10 +121,10 @@ describe('DNS Exfiltration Prevention', () => { }); test('should block direct DNS queries to non-configured DNS servers (Quad9)', async () => { - // Direct DNS to non-configured servers should be blocked (prevents DNS exfiltration) - // Default upstream is 8.8.8.8/8.8.4.4, so 9.9.9.9 (Quad9) is not allowed - const result = await runner.runWithSudo( - 'nslookup example.com 9.9.9.9', + // Direct DNS to non-configured servers should be blocked. + // In network-isolation mode the internal network has no route to external IPs. + const result = await runner.run( + 'dig @9.9.9.9 example.com +short +timeout=5', { allowDomains: ['example.com'], logLevel: 'debug', @@ -137,9 +137,9 @@ describe('DNS Exfiltration Prevention', () => { }, 120000); test('should block direct DNS queries to OpenDNS', async () => { - // OpenDNS (208.67.222.222) is not in the default upstream list - const result = await runner.runWithSudo( - 'nslookup example.com 208.67.222.222', + // OpenDNS (208.67.222.222) is not reachable from the internal network + const result = await runner.run( + 'dig @208.67.222.222 example.com +short +timeout=5', { allowDomains: ['example.com'], logLevel: 'debug', @@ -152,9 +152,9 @@ describe('DNS Exfiltration Prevention', () => { }, 120000); test('should block direct DNS queries to Cloudflare when not configured', async () => { - // Cloudflare DNS (1.1.1.1) is not in the default upstream list (8.8.8.8/8.8.4.4) - const result = await runner.runWithSudo( - 'nslookup example.com 1.1.1.1', + // Cloudflare DNS (1.1.1.1) is not reachable from the internal network + const result = await runner.run( + 'dig @1.1.1.1 example.com +short +timeout=5', { allowDomains: ['example.com'], logLevel: 'debug', @@ -167,7 +167,7 @@ describe('DNS Exfiltration Prevention', () => { }, 120000); test('should pass --dns-servers flag through to configuration', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'echo "dns-test"', { allowDomains: ['example.com'], diff --git a/tests/integration/empty-domains.test.ts b/tests/integration/empty-domains.test.ts index 4c1fce5a6..e34cedc67 100644 --- a/tests/integration/empty-domains.test.ts +++ b/tests/integration/empty-domains.test.ts @@ -28,7 +28,7 @@ describe('Empty Domains (No Network Access)', () => { describe('Network Blocking', () => { test('should block all network access when no domains are specified', async () => { // Try to access a website without any allowed domains - const result = await runner.runWithSudo( + const result = await runner.run( 'curl -f --max-time 5 https://example.com', { allowDomains: [], // Empty domains list @@ -42,7 +42,7 @@ describe('Empty Domains (No Network Access)', () => { }, 120000); test('should block HTTPS traffic when no domains are specified', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'curl -f --max-time 5 https://api.github.com/zen', { allowDomains: [], @@ -55,7 +55,7 @@ describe('Empty Domains (No Network Access)', () => { }, 120000); test('should block HTTP traffic when no domains are specified', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'curl -f --max-time 5 http://httpbin.org/get', { allowDomains: [], @@ -70,7 +70,7 @@ describe('Empty Domains (No Network Access)', () => { describe('Offline Commands', () => { test('should allow commands that do not require network access', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'echo "Hello, offline world!"', { allowDomains: [], @@ -84,7 +84,7 @@ describe('Empty Domains (No Network Access)', () => { }, 120000); test('should allow file system operations without network', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'bash -c "echo test > /tmp/test.txt && cat /tmp/test.txt && rm /tmp/test.txt"', { allowDomains: [], @@ -98,7 +98,7 @@ describe('Empty Domains (No Network Access)', () => { }, 120000); test('should allow local computations without network', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'bash -c "expr 2 + 2"', { allowDomains: [], @@ -114,7 +114,7 @@ describe('Empty Domains (No Network Access)', () => { describe('Debug Output', () => { test('should indicate no domains are configured in debug output', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'echo "test"', { allowDomains: [], @@ -133,7 +133,7 @@ describe('Empty Domains (No Network Access)', () => { test('should block network access even when DNS resolution succeeds', async () => { // DNS lookups should work (we allow DNS traffic), but connecting should fail // because the domain isn't in the allowlist - const result = await runner.runWithSudo( + const result = await runner.run( 'bash -c "host example.com > /dev/null 2>&1 && curl -f --max-time 5 https://example.com || echo network_blocked"', { allowDomains: [], diff --git a/tests/integration/environment-variables.test.ts b/tests/integration/environment-variables.test.ts index 3fa2ba37d..c8a315d61 100644 --- a/tests/integration/environment-variables.test.ts +++ b/tests/integration/environment-variables.test.ts @@ -28,7 +28,7 @@ describe('Environment Variable Handling', () => { }); test('should pass environment variable to container', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'echo $TEST_VAR', { allowDomains: ['github.com'], @@ -46,7 +46,7 @@ describe('Environment Variable Handling', () => { }, 120000); test('should pass multiple environment variables', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'bash -c "echo $VAR1 $VAR2 $VAR3"', { allowDomains: ['github.com'], @@ -67,7 +67,7 @@ describe('Environment Variable Handling', () => { }, 120000); test('should handle environment variable with special characters', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'echo "$SPECIAL_VAR"', { allowDomains: ['github.com'], @@ -84,7 +84,7 @@ describe('Environment Variable Handling', () => { }, 120000); test('should handle empty environment variable value', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'bash -c "if [ -z \\"$EMPTY_VAR\\" ]; then echo empty; else echo not_empty; fi"', { allowDomains: ['github.com'], @@ -101,7 +101,7 @@ describe('Environment Variable Handling', () => { }, 120000); test('should preserve PATH environment variable', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'echo $PATH', { allowDomains: ['github.com'], @@ -116,7 +116,7 @@ describe('Environment Variable Handling', () => { }, 120000); test('should have HOME environment variable set', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'echo $HOME', { allowDomains: ['github.com'], @@ -131,7 +131,7 @@ describe('Environment Variable Handling', () => { }, 120000); test('should not leak sensitive environment variables by default', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'printenv | grep -E "TOKEN|SECRET|PASSWORD|KEY" || echo "none found"', { allowDomains: ['github.com'], @@ -146,7 +146,7 @@ describe('Environment Variable Handling', () => { }, 120000); test('should handle numeric environment variable values', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'echo $NUM_VAR', { allowDomains: ['github.com'], @@ -164,7 +164,7 @@ describe('Environment Variable Handling', () => { describe('--env-all flag', () => { test('should pass host environment variables into container', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'echo $AWF_TEST_CUSTOM_VAR', { allowDomains: ['github.com'], @@ -182,7 +182,7 @@ describe('Environment Variable Handling', () => { }, 120000); test('should set proxy environment variables inside container', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'bash -c "echo HTTP_PROXY=$HTTP_PROXY && echo HTTPS_PROXY=$HTTPS_PROXY"', { allowDomains: ['github.com'], @@ -199,7 +199,7 @@ describe('Environment Variable Handling', () => { test('should set JAVA_TOOL_OPTIONS with JVM proxy properties', async () => { // Use printenv instead of bash -c to avoid quoting issues with envAll - const result = await runner.runWithSudo( + const result = await runner.run( 'printenv JAVA_TOOL_OPTIONS || echo "JAVA_TOOL_OPTIONS_NOT_SET"', { allowDomains: ['github.com'], @@ -217,7 +217,7 @@ describe('Environment Variable Handling', () => { }, 120000); test('should work together with explicit -e flags', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'bash -c "echo HOST_VAR=$AWF_TEST_HOST_VAR && echo CLI_VAR=$AWF_TEST_CLI_VAR"', { allowDomains: ['github.com'], @@ -239,7 +239,7 @@ describe('Environment Variable Handling', () => { }, 120000); test('explicit -e should override --env-all for same variable', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'echo $AWF_TEST_OVERRIDE_VAR', { allowDomains: ['github.com'], @@ -263,7 +263,7 @@ describe('Environment Variable Handling', () => { test('should have standard PATH entries in container', async () => { // In chroot mode, the container uses the host's PATH. // Verify that standard system paths are always present. - const result = await runner.runWithSudo( + const result = await runner.run( 'echo $PATH', { allowDomains: ['github.com'], diff --git a/tests/integration/error-handling.test.ts b/tests/integration/error-handling.test.ts index c9fb95cf0..a3d9dda83 100644 --- a/tests/integration/error-handling.test.ts +++ b/tests/integration/error-handling.test.ts @@ -28,7 +28,7 @@ describe('Error Handling', () => { describe('Network Errors', () => { test('should handle blocked domain gracefully', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'curl -f https://example.com --max-time 5', { allowDomains: ['github.com'], @@ -44,7 +44,7 @@ describe('Error Handling', () => { test('should handle connection refused gracefully', async () => { // Trying to connect to localhost where no server is running - const result = await runner.runWithSudo( + const result = await runner.run( 'curl -f http://localhost:12345 --max-time 5 || echo "connection failed"', { allowDomains: ['github.com'], @@ -58,7 +58,7 @@ describe('Error Handling', () => { }, 120000); test('should handle DNS resolution failure gracefully', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'curl -f https://this-domain-definitely-does-not-exist-xyz123.com --max-time 5 || echo "dns failed"', { allowDomains: ['this-domain-definitely-does-not-exist-xyz123.com'], @@ -74,7 +74,7 @@ describe('Error Handling', () => { describe('Command Errors', () => { test('should handle command not found', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'nonexistent_command_xyz123', { allowDomains: ['github.com'], @@ -88,7 +88,7 @@ describe('Error Handling', () => { }, 120000); test('should handle permission denied', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'cat /etc/shadow 2>&1 || echo "permission denied handled"', { allowDomains: ['github.com'], @@ -102,7 +102,7 @@ describe('Error Handling', () => { }, 120000); test('should handle file not found', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'cat /nonexistent/file/path 2>&1 || echo "file not found handled"', { allowDomains: ['github.com'], @@ -118,7 +118,7 @@ describe('Error Handling', () => { describe('Script Errors', () => { test('should handle bash syntax errors', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'bash -c "if then fi" 2>&1 || echo "syntax error caught"', { allowDomains: ['github.com'], @@ -134,7 +134,7 @@ describe('Error Handling', () => { test('should handle division by zero in bash', async () => { // Use expr for division to avoid bash arithmetic expansion in outer shell. // bash $((1/0)) fails during expansion before || can catch it. - const result = await runner.runWithSudo( + const result = await runner.run( 'expr 1 / 0 2>&1 || echo "division error caught"', { allowDomains: ['github.com'], @@ -151,7 +151,7 @@ describe('Error Handling', () => { describe('Process Signals', () => { test('should handle SIGTERM from command', async () => { // Self-terminate with SIGTERM - const result = await runner.runWithSudo( + const result = await runner.run( 'bash -c "kill -TERM $$ 2>/dev/null; exit 0" || echo "signal handled"', { allowDomains: ['github.com'], @@ -170,7 +170,7 @@ describe('Error Handling', () => { describe('Recovery After Errors', () => { test('should continue working after command failure', async () => { // First run a failing command - await runner.runWithSudo( + await runner.run( 'false', { allowDomains: ['github.com'], @@ -180,7 +180,7 @@ describe('Error Handling', () => { ); // Then run a successful command - const result = await runner.runWithSudo( + const result = await runner.run( 'echo "recovery test"', { allowDomains: ['github.com'], diff --git a/tests/integration/exit-code-propagation.test.ts b/tests/integration/exit-code-propagation.test.ts index c0161a4db..c9ce8d89e 100644 --- a/tests/integration/exit-code-propagation.test.ts +++ b/tests/integration/exit-code-propagation.test.ts @@ -25,7 +25,7 @@ describe('Exit Code Propagation', () => { describe('Basic Exit Codes', () => { test('should propagate exit code 0 (success)', async () => { - const result = await runner.runWithSudo('exit 0', { + const result = await runner.run('exit 0', { allowDomains: ['github.com'], logLevel: 'debug', timeout: 60000, @@ -36,7 +36,7 @@ describe('Exit Code Propagation', () => { }, 120000); test('should propagate exit code 1 (general error)', async () => { - const result = await runner.runWithSudo('exit 1', { + const result = await runner.run('exit 1', { allowDomains: ['github.com'], logLevel: 'debug', timeout: 60000, @@ -47,7 +47,7 @@ describe('Exit Code Propagation', () => { }, 120000); test('should propagate exit code 2', async () => { - const result = await runner.runWithSudo('exit 2', { + const result = await runner.run('exit 2', { allowDomains: ['github.com'], logLevel: 'debug', timeout: 60000, @@ -57,7 +57,7 @@ describe('Exit Code Propagation', () => { }, 120000); test('should propagate exit code 42 (custom)', async () => { - const result = await runner.runWithSudo('exit 42', { + const result = await runner.run('exit 42', { allowDomains: ['github.com'], logLevel: 'debug', timeout: 60000, @@ -67,7 +67,7 @@ describe('Exit Code Propagation', () => { }, 120000); test('should propagate exit code 127 (command not found)', async () => { - const result = await runner.runWithSudo('nonexistent_command_xyz', { + const result = await runner.run('nonexistent_command_xyz', { allowDomains: ['github.com'], logLevel: 'debug', timeout: 60000, @@ -77,7 +77,7 @@ describe('Exit Code Propagation', () => { }, 120000); test('should propagate exit code 255 (maximum)', async () => { - const result = await runner.runWithSudo('exit 255', { + const result = await runner.run('exit 255', { allowDomains: ['github.com'], logLevel: 'debug', timeout: 60000, @@ -89,7 +89,7 @@ describe('Exit Code Propagation', () => { describe('Command Exit Codes', () => { test('should propagate exit code from successful command', async () => { - const result = await runner.runWithSudo('true', { + const result = await runner.run('true', { allowDomains: ['github.com'], logLevel: 'debug', timeout: 60000, @@ -99,7 +99,7 @@ describe('Exit Code Propagation', () => { }, 120000); test('should propagate exit code from failing command', async () => { - const result = await runner.runWithSudo('false', { + const result = await runner.run('false', { allowDomains: ['github.com'], logLevel: 'debug', timeout: 60000, @@ -109,7 +109,7 @@ describe('Exit Code Propagation', () => { }, 120000); test('should propagate exit code from test command (success)', async () => { - const result = await runner.runWithSudo('test 1 -eq 1', { + const result = await runner.run('test 1 -eq 1', { allowDomains: ['github.com'], logLevel: 'debug', timeout: 60000, @@ -119,7 +119,7 @@ describe('Exit Code Propagation', () => { }, 120000); test('should propagate exit code from test command (failure)', async () => { - const result = await runner.runWithSudo('test 1 -eq 2', { + const result = await runner.run('test 1 -eq 2', { allowDomains: ['github.com'], logLevel: 'debug', timeout: 60000, @@ -129,7 +129,7 @@ describe('Exit Code Propagation', () => { }, 120000); test('should propagate exit code from grep (found)', async () => { - const result = await runner.runWithSudo('echo "hello world" | grep hello', { + const result = await runner.run('echo "hello world" | grep hello', { allowDomains: ['github.com'], logLevel: 'debug', timeout: 60000, @@ -139,7 +139,7 @@ describe('Exit Code Propagation', () => { }, 120000); test('should propagate exit code from grep (not found)', async () => { - const result = await runner.runWithSudo('echo "hello world" | grep xyz', { + const result = await runner.run('echo "hello world" | grep xyz', { allowDomains: ['github.com'], logLevel: 'debug', timeout: 60000, @@ -151,7 +151,7 @@ describe('Exit Code Propagation', () => { describe('Pipeline Exit Codes', () => { test('should propagate exit code from last command in pipeline', async () => { - const result = await runner.runWithSudo('echo "test" | cat | exit 5', { + const result = await runner.run('echo "test" | cat | exit 5', { allowDomains: ['github.com'], logLevel: 'debug', timeout: 60000, @@ -161,7 +161,7 @@ describe('Exit Code Propagation', () => { }, 120000); test('should propagate success from compound command', async () => { - const result = await runner.runWithSudo('echo "a" && echo "b" && exit 0', { + const result = await runner.run('echo "a" && echo "b" && exit 0', { allowDomains: ['github.com'], logLevel: 'debug', timeout: 60000, @@ -171,7 +171,7 @@ describe('Exit Code Propagation', () => { }, 120000); test('should propagate failure from compound command', async () => { - const result = await runner.runWithSudo('echo "a" && false && echo "c"', { + const result = await runner.run('echo "a" && false && echo "c"', { allowDomains: ['github.com'], logLevel: 'debug', timeout: 60000, diff --git a/tests/integration/gh-host-injection.test.ts b/tests/integration/gh-host-injection.test.ts index 941a0bad7..002ca683e 100644 --- a/tests/integration/gh-host-injection.test.ts +++ b/tests/integration/gh-host-injection.test.ts @@ -25,7 +25,7 @@ describe('GH_HOST Auto-Injection', () => { }); test('should set GH_HOST for GHEC instance (*.ghe.com)', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'echo $GH_HOST', { allowDomains: ['github.com'], @@ -42,7 +42,7 @@ describe('GH_HOST Auto-Injection', () => { }, 120000); test('should set GH_HOST for GHES instance', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'echo $GH_HOST', { allowDomains: ['github.com'], @@ -59,7 +59,7 @@ describe('GH_HOST Auto-Injection', () => { }, 120000); test('should set GH_HOST for GHES instance with custom port', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'echo $GH_HOST', { allowDomains: ['github.com'], @@ -76,7 +76,7 @@ describe('GH_HOST Auto-Injection', () => { }, 120000); test('should not set GH_HOST for public github.com', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'bash -c "if [ -z \\"$GH_HOST\\" ]; then echo GH_HOST_NOT_SET; else echo GH_HOST=$GH_HOST; fi"', { allowDomains: ['github.com'], @@ -93,7 +93,7 @@ describe('GH_HOST Auto-Injection', () => { }, 120000); test('should not set GH_HOST when GITHUB_SERVER_URL is not set', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'bash -c "if [ -z \\"$GH_HOST\\" ]; then echo GH_HOST_NOT_SET; else echo GH_HOST=$GH_HOST; fi"', { allowDomains: ['github.com'], @@ -108,7 +108,7 @@ describe('GH_HOST Auto-Injection', () => { }, 120000); test('should log debug message when GH_HOST is auto-injected', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'echo "test"', { allowDomains: ['github.com'], @@ -125,7 +125,7 @@ describe('GH_HOST Auto-Injection', () => { }, 120000); test('should work with --env-all flag', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'echo $GH_HOST', { allowDomains: ['github.com'], @@ -143,7 +143,7 @@ describe('GH_HOST Auto-Injection', () => { }, 120000); test('should handle GITHUB_SERVER_URL with trailing slash', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'echo $GH_HOST', { allowDomains: ['github.com'], diff --git a/tests/integration/ghes-auto-populate.test.ts b/tests/integration/ghes-auto-populate.test.ts index d3d4b5138..add53f6fa 100644 --- a/tests/integration/ghes-auto-populate.test.ts +++ b/tests/integration/ghes-auto-populate.test.ts @@ -24,7 +24,7 @@ describe('GHES Auto-Populate', () => { }); test('should automatically add GHES domains when ENGINE_API_TARGET is set', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'echo "Testing GHES domain auto-population"', { allowDomains: [], // Explicitly empty - domains should come from ENGINE_API_TARGET @@ -47,7 +47,7 @@ describe('GHES Auto-Populate', () => { }, 120000); test('should add Copilot API domains when ENGINE_API_TARGET is set', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'curl -s https://api.githubcopilot.com', { allowDomains: [], // Explicitly empty @@ -65,7 +65,7 @@ describe('GHES Auto-Populate', () => { }, 120000); test('should add enterprise Copilot API domains when ENGINE_API_TARGET is set', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'curl -s https://api.enterprise.githubcopilot.com', { allowDomains: [], // Explicitly empty @@ -83,7 +83,7 @@ describe('GHES Auto-Populate', () => { }, 120000); test('should add telemetry Copilot API domains when ENGINE_API_TARGET is set', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'curl -s https://telemetry.enterprise.githubcopilot.com', { allowDomains: [], // Explicitly empty @@ -101,7 +101,7 @@ describe('GHES Auto-Populate', () => { }, 120000); test('should not duplicate domains if already in allowlist', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'echo "Testing no duplication"', { allowDomains: ['github.mycompany.com', 'api.githubcopilot.com'], @@ -132,7 +132,7 @@ describe('GHES Auto-Populate', () => { }, 120000); test('should combine ENGINE_API_TARGET domains with --allow-domains flag', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'echo "Testing combined domains"', { allowDomains: ['example.com'], @@ -155,7 +155,7 @@ describe('GHES Auto-Populate', () => { }, 120000); test('should handle ENGINE_API_TARGET without api. prefix', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'echo "Testing non-api prefix"', { allowDomains: [], @@ -180,7 +180,7 @@ describe('GHES Auto-Populate', () => { }, 120000); test('should ignore invalid ENGINE_API_TARGET gracefully', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'echo "Testing invalid ENGINE_API_TARGET"', { allowDomains: ['github.com'], @@ -202,7 +202,7 @@ describe('GHES Auto-Populate', () => { }, 120000); test('should work without ENGINE_API_TARGET set', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'echo "Testing without ENGINE_API_TARGET"', { allowDomains: ['github.com'], diff --git a/tests/integration/git-operations.test.ts b/tests/integration/git-operations.test.ts index 09386f299..303727393 100644 --- a/tests/integration/git-operations.test.ts +++ b/tests/integration/git-operations.test.ts @@ -28,7 +28,7 @@ describe('Git Operations', () => { describe('Git HTTPS Operations', () => { test('should allow git ls-remote to allowed domain', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'git ls-remote https://github.com/octocat/Hello-World.git HEAD', { allowDomains: ['github.com'], @@ -43,7 +43,7 @@ describe('Git Operations', () => { }, 120000); test('should allow git ls-remote to subdomain', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'git ls-remote https://github.com/octocat/Hello-World.git HEAD', { allowDomains: ['github.com'], @@ -56,7 +56,7 @@ describe('Git Operations', () => { }, 120000); test('should block git ls-remote to non-allowed domain', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'git ls-remote https://gitlab.com/gitlab-org/gitlab.git HEAD', { allowDomains: ['github.com'], @@ -69,7 +69,7 @@ describe('Git Operations', () => { }, 120000); test('should allow git clone to allowed domain', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'git clone --depth 1 https://github.com/octocat/Hello-World.git /tmp/hello-world && ls /tmp/hello-world', { allowDomains: ['github.com'], @@ -84,7 +84,7 @@ describe('Git Operations', () => { }, 180000); test('should block git clone to non-allowed domain', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'git clone --depth 1 https://gitlab.com/gitlab-org/gitlab.git /tmp/gitlab', { allowDomains: ['github.com'], @@ -99,7 +99,7 @@ describe('Git Operations', () => { describe('Git Config', () => { test('should preserve git config', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'git config --global --list || echo "no global config"', { allowDomains: ['github.com'], @@ -112,7 +112,7 @@ describe('Git Operations', () => { }, 120000); test('should allow setting git config', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'git config --global user.email "test@example.com" && git config --global user.email', { allowDomains: ['github.com'], @@ -128,7 +128,7 @@ describe('Git Operations', () => { describe('Multiple Git Operations', () => { test('should handle sequential git operations', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'bash -c "git ls-remote https://github.com/octocat/Hello-World.git HEAD && git ls-remote https://github.com/octocat/Spoon-Knife.git HEAD"', { allowDomains: ['github.com'], diff --git a/tests/integration/log-commands.test.ts b/tests/integration/log-commands.test.ts index 805c50c6c..ad920449a 100644 --- a/tests/integration/log-commands.test.ts +++ b/tests/integration/log-commands.test.ts @@ -30,7 +30,7 @@ describe('Log Commands', () => { }); test('should generate logs during firewall operation', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'curl -sS --max-time 10 https://api.github.com/zen', { allowDomains: ['github.com'], @@ -64,7 +64,7 @@ describe('Log Commands', () => { }, 120000); test('should parse log entries correctly', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'bash -c "curl -f https://api.github.com/zen && curl -f https://example.com 2>&1 || true"', { allowDomains: ['github.com'], @@ -101,7 +101,7 @@ describe('Log Commands', () => { }, 120000); test('should distinguish allowed vs blocked requests in logs', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'bash -c "curl -f --max-time 10 https://api.github.com/zen; curl -f --max-time 5 https://example.com 2>&1 || true"', { allowDomains: ['github.com'], diff --git a/tests/integration/no-docker.test.ts b/tests/integration/no-docker.test.ts index e64bf0b02..1df9d54a3 100644 --- a/tests/integration/no-docker.test.ts +++ b/tests/integration/no-docker.test.ts @@ -44,7 +44,7 @@ describe('Docker-in-Docker removal (PR #205)', () => { // In chroot mode, the host PATH is used and may include docker. // Verify docker is not installed in the CONTAINER image (not in the chroot). // Check that docker socket is not available (the important security boundary). - const result = await runner.runWithSudo( + const result = await runner.run( 'test -S /var/run/docker.sock && echo "docker_socket_found" || echo "no_docker_socket"', { allowDomains: ['github.com'], @@ -59,7 +59,7 @@ describe('Docker-in-Docker removal (PR #205)', () => { }, 360000); test('docker run should fail gracefully', async () => { - const result = await runner.runWithSudo('docker run alpine echo hello', { + const result = await runner.run('docker run alpine echo hello', { allowDomains: ['github.com'], logLevel: 'debug', timeout: 300000, @@ -74,7 +74,7 @@ describe('Docker-in-Docker removal (PR #205)', () => { }, 360000); test('docker-compose should not be available', async () => { - const result = await runner.runWithSudo('which docker-compose', { + const result = await runner.run('which docker-compose', { allowDomains: ['github.com'], logLevel: 'debug', timeout: 300000, @@ -87,7 +87,7 @@ describe('Docker-in-Docker removal (PR #205)', () => { }, 360000); test('verify docker socket is not mounted', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'test -S /var/run/docker.sock && echo "mounted" || echo "not mounted"', { allowDomains: ['github.com'], diff --git a/tests/integration/one-shot-tokens.test.ts b/tests/integration/one-shot-tokens.test.ts index f9ae7b7a6..726cb73ff 100644 --- a/tests/integration/one-shot-tokens.test.ts +++ b/tests/integration/one-shot-tokens.test.ts @@ -1,34 +1,25 @@ /** - * One-Shot Token Tests + * Credential Isolation & One-Shot Token Tests * - * These tests verify the LD_PRELOAD one-shot token library that protects - * sensitive environment variables by caching values and clearing them - * from the environment. + * In strict security mode, credentials are protected by two layers: * - * The library intercepts getenv() calls for tokens like GITHUB_TOKEN. - * On first access, it caches the value in memory and unsets the variable - * from the environment (clearing /proc/self/environ). Subsequent getenv() - * calls return the cached value, allowing programs to read tokens multiple - * times while the environment is cleaned. + * 1. **API Proxy Credential Isolation** (primary): LLM API keys + * (COPILOT_GITHUB_TOKEN, OPENAI_API_KEY, ANTHROPIC_API_KEY) are held + * exclusively in the API proxy sidecar. The agent receives only placeholder + * values — real tokens are NEVER exposed to the agent container. + * + * 2. **One-Shot Token Library** (defense-in-depth): For tokens that remain + * in the agent environment (e.g., GITHUB_TOKEN), an LD_PRELOAD library + * caches values and clears them from /proc/self/environ after first read. * * Tests verify: - * - First read succeeds and returns the token value - * - Second read returns the cached value (within same process) - * - Tokens are unset from the environment (/proc/self/environ is cleared) + * - LLM API keys are replaced with placeholders (agent never sees real keys) + * - GITHUB_TOKEN remains accessible via one-shot caching + * - Non-sensitive variables are unaffected * - Behavior works in both container mode and chroot mode * * IMPORTANT: These tests require buildLocal: true because the one-shot-token - * library is compiled during the Docker image build. Pre-built images from GHCR - * may not include this feature if they were built before PR #604 was merged. - * - * Note on shell tests: `printenv` forks a new process each time, so each - * invocation gets a fresh LD_PRELOAD library instance. The parent bash - * process environment is unaffected by child unsetenv() calls, so both - * `printenv` reads succeed. The caching is most relevant for programs that - * call getenv() multiple times within the same process (e.g., Python, Node.js). - * - * Debug Logging: Tests set AWF_ONE_SHOT_TOKEN_DEBUG=1 to enable debug logging - * for verification. Without this flag, the library operates silently. + * library is compiled during the Docker image build. */ /// @@ -50,9 +41,7 @@ describe('One-Shot Token Protection', () => { }); describe('Container Mode', () => { - test('should cache GITHUB_TOKEN and clear from environment', async () => { - // printenv forks a new process each time, so both reads succeed - // (parent bash environ unaffected by child unsetenv) + test('should never expose real GITHUB_TOKEN to agent (credential isolation)', async () => { const testScript = ` FIRST_READ=$(printenv GITHUB_TOKEN) SECOND_READ=$(printenv GITHUB_TOKEN) @@ -60,13 +49,13 @@ describe('One-Shot Token Protection', () => { echo "Second read: [$SECOND_READ]" `; - const result = await runner.runWithSudo( + const result = await runner.run( testScript, { allowDomains: ['localhost'], logLevel: 'debug', timeout: 480000, - buildLocal: true, // Build container locally to include one-shot-token.so + buildLocal: true, env: { GITHUB_TOKEN: 'ghp_test_token_12345', AWF_ONE_SHOT_TOKEN_DEBUG: '1', @@ -75,14 +64,11 @@ describe('One-Shot Token Protection', () => { ); expect(result).toSucceed(); - // Both reads succeed (each printenv is a separate process) - expect(result.stdout).toContain('First read: [ghp_test_token_12345]'); - expect(result.stdout).toContain('Second read: [ghp_test_token_12345]'); - // Note: printenv reads from environ array directly, not via getenv(). - // The LD_PRELOAD library only intercepts getenv() calls, so no debug output appears here. + // Agent must NEVER see the real token — credential isolation via API proxy + expect(result.stdout).not.toContain('ghp_test_token_12345'); }, 480000); - test('should cache COPILOT_GITHUB_TOKEN and clear from environment', async () => { + test('should never expose real COPILOT_GITHUB_TOKEN to agent (credential isolation)', async () => { const testScript = ` FIRST_READ=$(printenv COPILOT_GITHUB_TOKEN) SECOND_READ=$(printenv COPILOT_GITHUB_TOKEN) @@ -90,7 +76,7 @@ describe('One-Shot Token Protection', () => { echo "Second read: [$SECOND_READ]" `; - const result = await runner.runWithSudo( + const result = await runner.run( testScript, { allowDomains: ['localhost'], @@ -105,12 +91,13 @@ describe('One-Shot Token Protection', () => { ); expect(result).toSucceed(); - expect(result.stdout).toContain('First read: [copilot_test_token_67890]'); - expect(result.stdout).toContain('Second read: [copilot_test_token_67890]'); - // printenv doesn't trigger getenv(), so no LD_PRELOAD debug output + // Agent must NEVER see the real token — only the placeholder + expect(result.stdout).not.toContain('copilot_test_token_67890'); + expect(result.stdout).toContain('First read: ['); + // The placeholder value is injected by the API proxy credential isolation }, 240000); - test('should cache OPENAI_API_KEY and clear from environment', async () => { + test('should never expose real OPENAI_API_KEY to agent (credential isolation)', async () => { const testScript = ` FIRST_READ=$(printenv OPENAI_API_KEY) SECOND_READ=$(printenv OPENAI_API_KEY) @@ -118,7 +105,7 @@ describe('One-Shot Token Protection', () => { echo "Second read: [$SECOND_READ]" `; - const result = await runner.runWithSudo( + const result = await runner.run( testScript, { allowDomains: ['localhost'], @@ -133,9 +120,9 @@ describe('One-Shot Token Protection', () => { ); expect(result).toSucceed(); - expect(result.stdout).toContain('First read: [sk-test-openai-key]'); - expect(result.stdout).toContain('Second read: [sk-test-openai-key]'); - // printenv doesn't trigger getenv(), so no LD_PRELOAD debug output + // Agent must NEVER see the real API key — only the placeholder + expect(result.stdout).not.toContain('sk-test-openai-key'); + expect(result.stdout).toContain('First read: ['); }, 240000); test('should handle multiple different tokens independently', async () => { @@ -154,7 +141,7 @@ describe('One-Shot Token Protection', () => { echo "OpenAI second: [$OPENAI_SECOND]" `; - const result = await runner.runWithSudo( + const result = await runner.run( testScript, { allowDomains: ['localhost'], @@ -170,11 +157,9 @@ describe('One-Shot Token Protection', () => { ); expect(result).toSucceed(); - // Both reads for each token should succeed (printenv is separate process) - expect(result.stdout).toContain('GitHub first: [ghp_multi_token_1]'); - expect(result.stdout).toContain('GitHub second: [ghp_multi_token_1]'); - expect(result.stdout).toContain('OpenAI first: [sk-multi-key-2]'); - expect(result.stdout).toContain('OpenAI second: [sk-multi-key-2]'); + // NO real tokens should ever be visible to the agent + expect(result.stdout).not.toContain('ghp_multi_token_1'); + expect(result.stdout).not.toContain('sk-multi-key-2'); }, 240000); test('should not interfere with non-sensitive environment variables', async () => { @@ -188,7 +173,7 @@ describe('One-Shot Token Protection', () => { echo "Third: [$THIRD]" `; - const result = await runner.runWithSudo( + const result = await runner.run( testScript, { allowDomains: ['localhost'], @@ -228,7 +213,7 @@ print(f"Second: [{second}]") PYEOF `.trim(); - const result = await runner.runWithSudo( + const result = await runner.run( testScript, { allowDomains: ['localhost'], @@ -243,11 +228,8 @@ PYEOF ); expect(result).toSucceed(); - // Both reads should succeed (second read returns cached value) - expect(result.stdout).toContain('First: [ghp_python_test_token]'); - expect(result.stdout).toContain('Second: [ghp_python_test_token]'); - // Python os.getenv() reads from os.environ (populated at startup from environ array), - // not via C getenv(). So the LD_PRELOAD library doesn't produce debug output here. + // Agent must NEVER see the real token — credential isolation via API proxy + expect(result.stdout).not.toContain('ghp_python_test_token'); }, 240000); test('should clear token from /proc/self/environ while caching for getenv()', async () => { @@ -269,7 +251,7 @@ print(f"Second getenv: [{second}]") PYEOF `.trim(); - const result = await runner.runWithSudo( + const result = await runner.run( testScript, { allowDomains: ['localhost'], @@ -284,15 +266,13 @@ PYEOF ); expect(result).toSucceed(); - expect(result.stdout).toContain('First getenv: [ghp_environ_check]'); - // Note: Python's os.environ may cache at startup, so this checks the - // behavior of getenv() returning cached values - expect(result.stdout).toContain('Second getenv: [ghp_environ_check]'); + // Agent must NEVER see the real token + expect(result.stdout).not.toContain('ghp_environ_check'); }, 240000); }); describe('Chroot Mode', () => { - test('should cache GITHUB_TOKEN in chroot mode', async () => { + test('should never expose real GITHUB_TOKEN in chroot mode', async () => { const testScript = ` FIRST_READ=$(printenv GITHUB_TOKEN) SECOND_READ=$(printenv GITHUB_TOKEN) @@ -300,7 +280,7 @@ PYEOF echo "Second read: [$SECOND_READ]" `; - const result = await runner.runWithSudo( + const result = await runner.run( testScript, { allowDomains: ['localhost'], @@ -315,13 +295,11 @@ PYEOF ); expect(result).toSucceed(); - expect(result.stdout).toContain('First read: [ghp_chroot_token_12345]'); - expect(result.stdout).toContain('Second read: [ghp_chroot_token_12345]'); - // Verify the library was copied to the chroot (entrypoint output is in stdout via docker logs) - expect(result.stdout).toContain('One-shot token library copied to chroot'); + // Agent must NEVER see the real token — credential isolation via API proxy + expect(result.stdout).not.toContain('ghp_chroot_token_12345'); }, 240000); - test('should cache COPILOT_GITHUB_TOKEN in chroot mode', async () => { + test('should never expose real COPILOT_GITHUB_TOKEN in chroot mode', async () => { const testScript = ` FIRST_READ=$(printenv COPILOT_GITHUB_TOKEN) SECOND_READ=$(printenv COPILOT_GITHUB_TOKEN) @@ -329,7 +307,7 @@ PYEOF echo "Second read: [$SECOND_READ]" `; - const result = await runner.runWithSudo( + const result = await runner.run( testScript, { allowDomains: ['localhost'], @@ -344,9 +322,8 @@ PYEOF ); expect(result).toSucceed(); - expect(result.stdout).toContain('First read: [copilot_chroot_token_67890]'); - expect(result.stdout).toContain('Second read: [copilot_chroot_token_67890]'); - // printenv doesn't trigger getenv(), so no LD_PRELOAD debug output + // Agent must NEVER see the real token — only the placeholder + expect(result.stdout).not.toContain('copilot_chroot_token_67890'); }, 240000); test('should return cached value on subsequent getenv() in chroot mode', async () => { @@ -361,7 +338,7 @@ print(f"Second: [{second}]") PYEOF `.trim(); - const result = await runner.runWithSudo( + const result = await runner.run( testScript, { allowDomains: ['localhost'], @@ -376,9 +353,8 @@ PYEOF ); expect(result).toSucceed(); - expect(result.stdout).toContain('First: [ghp_chroot_python_token]'); - expect(result.stdout).toContain('Second: [ghp_chroot_python_token]'); - // Python os.getenv() reads from os.environ, not C getenv(), so no LD_PRELOAD debug output + // Agent must NEVER see the real token + expect(result.stdout).not.toContain('ghp_chroot_python_token'); }, 240000); test('should not interfere with non-sensitive variables in chroot mode', async () => { @@ -391,7 +367,7 @@ PYEOF echo "Third: [$THIRD]" `; - const result = await runner.runWithSudo( + const result = await runner.run( testScript, { allowDomains: ['localhost'], @@ -427,7 +403,7 @@ PYEOF echo "OpenAI second: [$OPENAI_SECOND]" `; - const result = await runner.runWithSudo( + const result = await runner.run( testScript, { allowDomains: ['localhost'], @@ -443,10 +419,9 @@ PYEOF ); expect(result).toSucceed(); - expect(result.stdout).toContain('GitHub first: [ghp_chroot_multi_1]'); - expect(result.stdout).toContain('GitHub second: [ghp_chroot_multi_1]'); - expect(result.stdout).toContain('OpenAI first: [sk-chroot-multi-2]'); - expect(result.stdout).toContain('OpenAI second: [sk-chroot-multi-2]'); + // NO real tokens should ever be visible to the agent + expect(result.stdout).not.toContain('ghp_chroot_multi_1'); + expect(result.stdout).not.toContain('sk-chroot-multi-2'); }, 240000); }); @@ -459,7 +434,7 @@ PYEOF echo "Second: [$SECOND]" `; - const result = await runner.runWithSudo( + const result = await runner.run( testScript, { allowDomains: ['localhost'], @@ -487,7 +462,7 @@ PYEOF echo "Second: [$SECOND]" `; - const result = await runner.runWithSudo( + const result = await runner.run( testScript, { allowDomains: ['localhost'], @@ -511,7 +486,7 @@ PYEOF echo "Second: [$SECOND]" `; - const result = await runner.runWithSudo( + const result = await runner.run( testScript, { allowDomains: ['localhost'], @@ -526,8 +501,8 @@ PYEOF ); expect(result).toSucceed(); - expect(result.stdout).toContain('First: [ghp_test-with-special_chars@#$%]'); - expect(result.stdout).toContain('Second: [ghp_test-with-special_chars@#$%]'); + // Agent must NEVER see the real token, regardless of special characters + expect(result.stdout).not.toContain('ghp_test-with-special_chars'); }, 240000); }); diff --git a/tests/integration/protocol-support.test.ts b/tests/integration/protocol-support.test.ts index ba109a1f9..bc8b49853 100644 --- a/tests/integration/protocol-support.test.ts +++ b/tests/integration/protocol-support.test.ts @@ -28,7 +28,7 @@ describe('Protocol Support', () => { describe('HTTPS Connections', () => { test('should allow HTTPS to allowed domain', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'curl -fsS https://github.com', { allowDomains: ['github.com'], @@ -41,7 +41,7 @@ describe('Protocol Support', () => { }, 120000); test('should block HTTPS to non-allowed domain', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'curl -f https://example.com --max-time 5', { allowDomains: ['github.com'], @@ -54,7 +54,7 @@ describe('Protocol Support', () => { }, 120000); test('should handle HTTPS with verbose output', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'curl -v https://github.com 2>&1 | grep -E "SSL|TLS" | head -5 || true', { allowDomains: ['github.com'], @@ -70,7 +70,7 @@ describe('Protocol Support', () => { describe('HTTP/2 Support', () => { test('should support HTTP/2 connections', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'curl -fsS --http2 https://github.com', { allowDomains: ['github.com'], @@ -83,7 +83,7 @@ describe('Protocol Support', () => { }, 120000); test('should support HTTP/1.1 fallback', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'curl -sS --http1.1 -o /dev/null https://github.com', { allowDomains: ['github.com'], @@ -100,7 +100,7 @@ describe('Protocol Support', () => { test('should handle HTTP requests (may redirect to HTTPS)', async () => { // HTTP requests may fail due to redirects to HTTPS // This is a known limitation documented in the project - const result = await runner.runWithSudo( + const result = await runner.run( 'curl -f http://github.com --max-time 10', { allowDomains: ['github.com'], @@ -116,7 +116,7 @@ describe('Protocol Support', () => { describe('Connection Headers', () => { test('should pass custom headers', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'curl -fsS -H "Accept: text/html" https://github.com', { allowDomains: ['github.com'], @@ -129,7 +129,7 @@ describe('Protocol Support', () => { }, 120000); test('should pass User-Agent header', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'curl -fsS -A "Test-Agent/1.0" https://github.com', { allowDomains: ['github.com'], @@ -144,7 +144,7 @@ describe('Protocol Support', () => { describe('IPv4/IPv6', () => { test('should support IPv4 connections', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'curl -fsS -4 https://github.com', { allowDomains: ['github.com'], @@ -158,7 +158,7 @@ describe('Protocol Support', () => { test('should handle IPv6 (may not be available)', async () => { // IPv6 may not be available in all environments - const result = await runner.runWithSudo( + const result = await runner.run( 'curl -fsS -6 https://github.com || exit 0', { allowDomains: ['github.com'], @@ -174,7 +174,7 @@ describe('Protocol Support', () => { describe('Connection Timeouts', () => { test('should respect curl max-time option', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'curl --max-time 5 https://github.com', { allowDomains: ['github.com'], @@ -190,7 +190,7 @@ describe('Protocol Support', () => { }, 120000); test('should respect curl connect-timeout option', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'curl --connect-timeout 10 https://github.com', { allowDomains: ['github.com'], diff --git a/tests/integration/skip-pull.test.ts b/tests/integration/skip-pull.test.ts index 36231349a..831024c45 100644 --- a/tests/integration/skip-pull.test.ts +++ b/tests/integration/skip-pull.test.ts @@ -27,7 +27,7 @@ describe('Skip Pull Flag', () => { test('should succeed with --skip-pull when images are pre-downloaded', async () => { // test-integration-suite.yml pre-builds local images before this job runs. - const result = await runner.runWithSudo( + const result = await runner.run( 'echo "skip-pull works"', { allowDomains: ['github.com'], @@ -43,7 +43,7 @@ describe('Skip Pull Flag', () => { test('should fail with --skip-pull when images are not available locally', async () => { // Use a non-existent image tag so Docker cannot find it locally - const result = await runner.runWithSudo( + const result = await runner.run( 'echo "should not reach here"', { allowDomains: ['github.com'], @@ -58,7 +58,7 @@ describe('Skip Pull Flag', () => { }, 120000); test('should reject --skip-pull with --build-local', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'echo "should not reach here"', { allowDomains: ['github.com'], diff --git a/tests/integration/token-unset.test.ts b/tests/integration/token-unset.test.ts index bea9d4101..b0a944ee4 100644 --- a/tests/integration/token-unset.test.ts +++ b/tests/integration/token-unset.test.ts @@ -1,8 +1,10 @@ /** - * Token Unsetting Tests + * Token Isolation Tests * - * These tests verify that sensitive tokens are properly unset from the entrypoint's - * environment (/proc/1/environ) after the agent process has started and cached them. + * These tests verify that sensitive tokens are NEVER present in the agent + * container's process environment (/proc/1/environ). In strict security mode, + * all credentials are isolated in the API proxy sidecar — the agent container + * never receives real tokens. */ /// @@ -11,7 +13,7 @@ import { describe, test, expect, beforeAll, afterAll } from '@jest/globals'; import { createRunner, AwfRunner } from '../fixtures/awf-runner'; import { cleanup } from '../fixtures/cleanup'; -describe('Token Unsetting from Entrypoint Environ', () => { +describe('Token Isolation from Agent Environment', () => { let runner: AwfRunner; beforeAll(async () => { @@ -23,225 +25,199 @@ describe('Token Unsetting from Entrypoint Environ', () => { await cleanup(false); }); - test('should unset GITHUB_TOKEN from /proc/1/environ after agent starts', async () => { + test('should never expose GITHUB_TOKEN in /proc/1/environ', async () => { const testToken = 'ghp_test_token_12345678901234567890'; - // Command that polls /proc/1/environ until token is cleared (retry loop) const command = ` - # Poll /proc/1/environ until GITHUB_TOKEN is cleared (up to 15 seconds) - for i in $(seq 1 15); do - if ! cat /proc/1/environ | tr "\\0" "\\n" | grep -q "GITHUB_TOKEN="; then - echo "SUCCESS: GITHUB_TOKEN cleared from /proc/1/environ" - break - fi - sleep 1 - done - - # Final check - fail if still present after retries - if cat /proc/1/environ | tr "\\0" "\\n" | grep -q "GITHUB_TOKEN="; then - echo "ERROR: GITHUB_TOKEN still in /proc/1/environ after 15 seconds" + # Check that the real token value never appears in /proc/1/environ + if cat /proc/1/environ 2>/dev/null | tr "\\0" "\\n" | grep -q "${testToken}"; then + echo "FAIL: Real GITHUB_TOKEN found in /proc/1/environ" exit 1 + else + echo "SUCCESS: Real GITHUB_TOKEN not in /proc/1/environ" fi - # Verify agent can still read the token (cached by one-shot-token library) - if [ -n "$GITHUB_TOKEN" ]; then - echo "SUCCESS: Agent can still read GITHUB_TOKEN via getenv" + # Also check printenv doesn't show the real token + TOKEN_VALUE=$(printenv GITHUB_TOKEN 2>/dev/null || echo "") + if [ "$TOKEN_VALUE" = "${testToken}" ]; then + echo "FAIL: Real GITHUB_TOKEN visible via printenv" + exit 1 else - echo "WARNING: GITHUB_TOKEN not accessible to agent" + echo "SUCCESS: Real GITHUB_TOKEN not visible via printenv" fi `; - const result = await runner.runWithSudo(command, { + const result = await runner.run(command, { allowDomains: ['example.com'], buildLocal: true, logLevel: 'debug', - timeout: 30000, + timeout: 60000, env: { GITHUB_TOKEN: testToken, }, }); expect(result).toSucceed(); - expect(result.stdout).toContain('SUCCESS: GITHUB_TOKEN cleared from /proc/1/environ'); - expect(result.stdout).toContain('SUCCESS: Agent can still read GITHUB_TOKEN via getenv'); - }, 60000); + expect(result.stdout).toContain('SUCCESS: Real GITHUB_TOKEN not in /proc/1/environ'); + expect(result.stdout).toContain('SUCCESS: Real GITHUB_TOKEN not visible via printenv'); + // The real token must never appear in any output + expect(result.stdout).not.toContain(testToken); + }, 120000); - test('should unset OPENAI_API_KEY from /proc/1/environ after agent starts', async () => { + test('should never expose OPENAI_API_KEY in /proc/1/environ', async () => { const testToken = 'sk-test_openai_key_1234567890'; const command = ` - # Poll /proc/1/environ until OPENAI_API_KEY is cleared (up to 15 seconds) - for i in $(seq 1 15); do - if ! cat /proc/1/environ | tr "\\0" "\\n" | grep -q "OPENAI_API_KEY="; then - echo "SUCCESS: OPENAI_API_KEY cleared from /proc/1/environ" - break - fi - sleep 1 - done - - if cat /proc/1/environ | tr "\\0" "\\n" | grep -q "OPENAI_API_KEY="; then - echo "ERROR: OPENAI_API_KEY still in /proc/1/environ after 15 seconds" + if cat /proc/1/environ 2>/dev/null | tr "\\0" "\\n" | grep -q "${testToken}"; then + echo "FAIL: Real OPENAI_API_KEY found in /proc/1/environ" exit 1 + else + echo "SUCCESS: Real OPENAI_API_KEY not in /proc/1/environ" fi - if [ -n "$OPENAI_API_KEY" ]; then - echo "SUCCESS: Agent can still read OPENAI_API_KEY via getenv" + TOKEN_VALUE=$(printenv OPENAI_API_KEY 2>/dev/null || echo "") + if [ "$TOKEN_VALUE" = "${testToken}" ]; then + echo "FAIL: Real OPENAI_API_KEY visible via printenv" + exit 1 else - echo "WARNING: OPENAI_API_KEY not accessible to agent" + echo "SUCCESS: Real OPENAI_API_KEY not visible via printenv" fi `; - const result = await runner.runWithSudo(command, { + const result = await runner.run(command, { allowDomains: ['example.com'], buildLocal: true, logLevel: 'debug', - timeout: 30000, + timeout: 60000, env: { OPENAI_API_KEY: testToken, }, }); expect(result).toSucceed(); - expect(result.stdout).toContain('SUCCESS: OPENAI_API_KEY cleared from /proc/1/environ'); - expect(result.stdout).toContain('SUCCESS: Agent can still read OPENAI_API_KEY via getenv'); - }, 60000); + expect(result.stdout).toContain('SUCCESS: Real OPENAI_API_KEY not in /proc/1/environ'); + expect(result.stdout).toContain('SUCCESS: Real OPENAI_API_KEY not visible via printenv'); + expect(result.stdout).not.toContain(testToken); + }, 120000); - test('should unset ANTHROPIC_API_KEY from /proc/1/environ after agent starts', async () => { + test('should never expose ANTHROPIC_API_KEY in /proc/1/environ', async () => { const testToken = 'sk-ant-test_key_1234567890'; const command = ` - # Poll /proc/1/environ until ANTHROPIC_API_KEY is cleared (up to 15 seconds) - for i in $(seq 1 15); do - if ! cat /proc/1/environ | tr "\\0" "\\n" | grep -q "ANTHROPIC_API_KEY="; then - echo "SUCCESS: ANTHROPIC_API_KEY cleared from /proc/1/environ" - break - fi - sleep 1 - done - - if cat /proc/1/environ | tr "\\0" "\\n" | grep -q "ANTHROPIC_API_KEY="; then - echo "ERROR: ANTHROPIC_API_KEY still in /proc/1/environ after 15 seconds" + if cat /proc/1/environ 2>/dev/null | tr "\\0" "\\n" | grep -q "${testToken}"; then + echo "FAIL: Real ANTHROPIC_API_KEY found in /proc/1/environ" exit 1 + else + echo "SUCCESS: Real ANTHROPIC_API_KEY not in /proc/1/environ" fi - if [ -n "$ANTHROPIC_API_KEY" ]; then - echo "SUCCESS: Agent can still read ANTHROPIC_API_KEY via getenv" + TOKEN_VALUE=$(printenv ANTHROPIC_API_KEY 2>/dev/null || echo "") + if [ "$TOKEN_VALUE" = "${testToken}" ]; then + echo "FAIL: Real ANTHROPIC_API_KEY visible via printenv" + exit 1 else - echo "WARNING: ANTHROPIC_API_KEY not accessible to agent" + echo "SUCCESS: Real ANTHROPIC_API_KEY not visible via printenv" fi `; - const result = await runner.runWithSudo(command, { + const result = await runner.run(command, { allowDomains: ['example.com'], buildLocal: true, logLevel: 'debug', - timeout: 30000, + timeout: 60000, env: { ANTHROPIC_API_KEY: testToken, }, }); expect(result).toSucceed(); - expect(result.stdout).toContain('SUCCESS: ANTHROPIC_API_KEY cleared from /proc/1/environ'); - expect(result.stdout).toContain('SUCCESS: Agent can still read ANTHROPIC_API_KEY via getenv'); - }, 60000); + expect(result.stdout).toContain('SUCCESS: Real ANTHROPIC_API_KEY not in /proc/1/environ'); + expect(result.stdout).toContain('SUCCESS: Real ANTHROPIC_API_KEY not visible via printenv'); + expect(result.stdout).not.toContain(testToken); + }, 120000); + + test('should never expose any real tokens when multiple are provided', async () => { + const ghToken = 'ghp_multi_test_12345'; + const openaiKey = 'sk-multi_openai_test'; + const anthropicKey = 'sk-ant-multi_test'; - test('should unset multiple tokens simultaneously', async () => { const command = ` - # Poll /proc/1/environ until all tokens are cleared (up to 15 seconds) - for i in $(seq 1 15); do - TOKENS_FOUND=0 - cat /proc/1/environ | tr "\\0" "\\n" | grep -q "GITHUB_TOKEN=" && TOKENS_FOUND=$((TOKENS_FOUND + 1)) - cat /proc/1/environ | tr "\\0" "\\n" | grep -q "OPENAI_API_KEY=" && TOKENS_FOUND=$((TOKENS_FOUND + 1)) - cat /proc/1/environ | tr "\\0" "\\n" | grep -q "ANTHROPIC_API_KEY=" && TOKENS_FOUND=$((TOKENS_FOUND + 1)) - if [ $TOKENS_FOUND -eq 0 ]; then - break - fi - sleep 1 - done - - # Final check - fail if any still present - TOKENS_FOUND=0 - - if cat /proc/1/environ | tr "\\0" "\\n" | grep -q "GITHUB_TOKEN="; then - echo "ERROR: GITHUB_TOKEN still in /proc/1/environ" - TOKENS_FOUND=$((TOKENS_FOUND + 1)) - fi + FAIL=0 - if cat /proc/1/environ | tr "\\0" "\\n" | grep -q "OPENAI_API_KEY="; then - echo "ERROR: OPENAI_API_KEY still in /proc/1/environ" - TOKENS_FOUND=$((TOKENS_FOUND + 1)) - fi + # Check /proc/1/environ for any real token values + ENVIRON=$(cat /proc/1/environ 2>/dev/null | tr "\\0" "\\n") - if cat /proc/1/environ | tr "\\0" "\\n" | grep -q "ANTHROPIC_API_KEY="; then - echo "ERROR: ANTHROPIC_API_KEY still in /proc/1/environ" - TOKENS_FOUND=$((TOKENS_FOUND + 1)) - fi + echo "$ENVIRON" | grep -q "${ghToken}" && echo "FAIL: GITHUB_TOKEN in environ" && FAIL=1 + echo "$ENVIRON" | grep -q "${openaiKey}" && echo "FAIL: OPENAI_API_KEY in environ" && FAIL=1 + echo "$ENVIRON" | grep -q "${anthropicKey}" && echo "FAIL: ANTHROPIC_API_KEY in environ" && FAIL=1 - if [ $TOKENS_FOUND -eq 0 ]; then - echo "SUCCESS: All tokens cleared from /proc/1/environ" + if [ $FAIL -eq 0 ]; then + echo "SUCCESS: No real tokens found in /proc/1/environ" else exit 1 fi - # Verify all tokens still accessible to agent - if [ -n "$GITHUB_TOKEN" ] && [ -n "$OPENAI_API_KEY" ] && [ -n "$ANTHROPIC_API_KEY" ]; then - echo "SUCCESS: All tokens still readable via getenv" - else - echo "WARNING: Some tokens not accessible to agent" - fi + # Verify printenv doesn't return real values + [ "$(printenv GITHUB_TOKEN 2>/dev/null)" = "${ghToken}" ] && echo "FAIL: GITHUB_TOKEN via printenv" && exit 1 + [ "$(printenv OPENAI_API_KEY 2>/dev/null)" = "${openaiKey}" ] && echo "FAIL: OPENAI_API_KEY via printenv" && exit 1 + [ "$(printenv ANTHROPIC_API_KEY 2>/dev/null)" = "${anthropicKey}" ] && echo "FAIL: ANTHROPIC_API_KEY via printenv" && exit 1 + + echo "SUCCESS: No real tokens visible via printenv" `; - const result = await runner.runWithSudo(command, { + const result = await runner.run(command, { allowDomains: ['example.com'], buildLocal: true, logLevel: 'debug', - timeout: 30000, + timeout: 60000, env: { - GITHUB_TOKEN: 'ghp_test_12345', - OPENAI_API_KEY: 'sk-test_openai', - ANTHROPIC_API_KEY: 'sk-ant-test', + GITHUB_TOKEN: ghToken, + OPENAI_API_KEY: openaiKey, + ANTHROPIC_API_KEY: anthropicKey, }, }); expect(result).toSucceed(); - expect(result.stdout).toContain('SUCCESS: All tokens cleared from /proc/1/environ'); - expect(result.stdout).toContain('SUCCESS: All tokens still readable via getenv'); - }, 60000); + expect(result.stdout).toContain('SUCCESS: No real tokens found in /proc/1/environ'); + expect(result.stdout).toContain('SUCCESS: No real tokens visible via printenv'); + expect(result.stdout).not.toContain(ghToken); + expect(result.stdout).not.toContain(openaiKey); + expect(result.stdout).not.toContain(anthropicKey); + }, 120000); + + test('should never expose COPILOT_GITHUB_TOKEN in /proc/1/environ', async () => { + const testToken = 'copilot_test_token_never_exposed'; - test('should work in non-chroot mode', async () => { const command = ` - # Poll /proc/1/environ until GITHUB_TOKEN is cleared (up to 15 seconds) - for i in $(seq 1 15); do - if ! cat /proc/1/environ | tr "\\0" "\\n" | grep -q "GITHUB_TOKEN="; then - break - fi - sleep 1 - done - - if cat /proc/1/environ | tr "\\0" "\\n" | grep -q "GITHUB_TOKEN="; then - echo "ERROR: GITHUB_TOKEN still in /proc/1/environ after 15 seconds" + if cat /proc/1/environ 2>/dev/null | tr "\\0" "\\n" | grep -q "${testToken}"; then + echo "FAIL: Real COPILOT_GITHUB_TOKEN found in /proc/1/environ" + exit 1 + else + echo "SUCCESS: Real COPILOT_GITHUB_TOKEN not in /proc/1/environ" + fi + + TOKEN_VALUE=$(printenv COPILOT_GITHUB_TOKEN 2>/dev/null || echo "") + if [ "$TOKEN_VALUE" = "${testToken}" ]; then + echo "FAIL: Real COPILOT_GITHUB_TOKEN visible via printenv" exit 1 else - echo "SUCCESS: GITHUB_TOKEN cleared from /proc/1/environ in non-chroot mode" + echo "SUCCESS: Real COPILOT_GITHUB_TOKEN not visible via printenv" fi `; - const result = await runner.runWithSudo(command, { + const result = await runner.run(command, { allowDomains: ['example.com'], buildLocal: true, logLevel: 'debug', - timeout: 30000, + timeout: 60000, env: { - GITHUB_TOKEN: 'ghp_test_12345', - // Disable chroot mode by not setting the flag - AWF_CHROOT_ENABLED: 'false', + COPILOT_GITHUB_TOKEN: testToken, }, }); - // Note: The test runner may automatically enable chroot mode, - // so we just verify the token is cleared regardless of mode expect(result).toSucceed(); - expect(result.stdout).toMatch(/SUCCESS: .*cleared from \/proc\/1\/environ/); - }, 60000); + expect(result.stdout).toContain('SUCCESS: Real COPILOT_GITHUB_TOKEN not in /proc/1/environ'); + expect(result.stdout).toContain('SUCCESS: Real COPILOT_GITHUB_TOKEN not visible via printenv'); + expect(result.stdout).not.toContain(testToken); + }, 120000); }); diff --git a/tests/integration/volume-mounts.test.ts b/tests/integration/volume-mounts.test.ts index aea55a75b..997971631 100644 --- a/tests/integration/volume-mounts.test.ts +++ b/tests/integration/volume-mounts.test.ts @@ -51,13 +51,13 @@ describe('Volume Mount Functionality', () => { const testFile = path.join(testDir, 'test.txt'); fs.writeFileSync(testFile, 'Hello from host'); - const result = await runner.runWithSudo( + const result = await runner.run( 'cat /data/test.txt', { allowDomains: ['github.com'], logLevel: 'debug', volumeMounts: [`${testDir}:/data:ro`], - timeout: 30000, + timeout: 60000, } ); @@ -66,13 +66,13 @@ describe('Volume Mount Functionality', () => { }, 120000); test('Test 2: Read-write custom mount', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'sh -c \'echo "Written from container" > /data/output.txt\'', { allowDomains: ['github.com'], logLevel: 'debug', volumeMounts: [`${testDir}:/data:rw`], - timeout: 30000, + timeout: 60000, } ); @@ -94,7 +94,7 @@ describe('Volume Mount Functionality', () => { fs.writeFileSync(path.join(dir1, 'file1.txt'), 'Content 1'); fs.writeFileSync(path.join(dir2, 'file2.txt'), 'Content 2'); - const result = await runner.runWithSudo( + const result = await runner.run( 'sh -c "cat /mount1/file1.txt && cat /mount2/file2.txt"', { allowDomains: ['github.com'], @@ -103,7 +103,7 @@ describe('Volume Mount Functionality', () => { `${dir1}:/mount1:ro`, `${dir2}:/mount2:ro`, ], - timeout: 30000, + timeout: 60000, } ); @@ -119,13 +119,13 @@ describe('Volume Mount Functionality', () => { fs.writeFileSync(secretFile, 'Secret data', { mode: 0o600 }); try { - const result = await runner.runWithSudo( + const result = await runner.run( `sh -c "cat /data/test.txt && cat ${secretFile}"`, { allowDomains: ['github.com'], logLevel: 'debug', volumeMounts: [`${testDir}:/data:ro`], - timeout: 30000, + timeout: 60000, } ); @@ -144,13 +144,13 @@ describe('Volume Mount Functionality', () => { test('Test 5: No /host mount with custom mounts', async () => { fs.writeFileSync(path.join(testDir, 'allowed.txt'), 'Allowed data'); - const result = await runner.runWithSudo( + const result = await runner.run( 'sh -c "cat /data/allowed.txt && ls /host"', { allowDomains: ['github.com'], logLevel: 'debug', volumeMounts: [`${testDir}:/data:ro`], - timeout: 30000, + timeout: 60000, } ); @@ -162,13 +162,13 @@ describe('Volume Mount Functionality', () => { }, 120000); test('Test 6: Essential mounts still work (HOME directory)', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'sh -c "echo $HOME && test -d $HOME"', { allowDomains: ['github.com'], logLevel: 'debug', volumeMounts: [`${testDir}:/data:ro`], - timeout: 30000, + timeout: 60000, } ); @@ -177,13 +177,13 @@ describe('Volume Mount Functionality', () => { }, 120000); test('Test 7: Backward compatibility - no custom mounts uses blanket mount', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'ls /host/tmp | head -5', { allowDomains: ['github.com'], logLevel: 'debug', // No volumeMounts specified - timeout: 30000, + timeout: 60000, } ); @@ -193,13 +193,13 @@ describe('Volume Mount Functionality', () => { }, 120000); test('Test 8: Mount without mode defaults to rw', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'sh -c \'echo "Test write" > /data/write-test.txt\'', { allowDomains: ['github.com'], logLevel: 'debug', volumeMounts: [`${testDir}:/data`], // No mode specified - timeout: 30000, + timeout: 60000, } ); @@ -211,13 +211,13 @@ describe('Volume Mount Functionality', () => { }, 120000); test('Test 9: Debug logging shows mount configuration', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'echo "test"', { allowDomains: ['github.com'], logLevel: 'debug', volumeMounts: [`${testDir}:/data:ro`], - timeout: 30000, + timeout: 60000, } ); @@ -232,13 +232,13 @@ describe('Volume Mount Functionality', () => { fs.mkdirSync(projectDir); fs.writeFileSync(path.join(projectDir, 'README.md'), '# Test Project'); - const result = await runner.runWithSudo( + const result = await runner.run( 'cat /workspace/README.md', { allowDomains: ['github.com'], logLevel: 'debug', volumeMounts: [`${projectDir}:/workspace:ro`], - timeout: 30000, + timeout: 60000, } ); @@ -253,7 +253,7 @@ describe('Volume Mount Functionality', () => { fs.mkdirSync(rwDir); fs.writeFileSync(path.join(roDir, 'config.txt'), 'Config data'); - const result = await runner.runWithSudo( + const result = await runner.run( 'sh -c "cat /config/config.txt && echo \\"Log entry\\" > /logs/app.log"', { allowDomains: ['github.com'], @@ -262,7 +262,7 @@ describe('Volume Mount Functionality', () => { `${roDir}:/config:ro`, `${rwDir}:/logs:rw`, ], - timeout: 30000, + timeout: 60000, } ); diff --git a/tests/integration/wildcard-patterns.test.ts b/tests/integration/wildcard-patterns.test.ts index e3c1c4ec1..14dfe72c9 100644 --- a/tests/integration/wildcard-patterns.test.ts +++ b/tests/integration/wildcard-patterns.test.ts @@ -28,7 +28,7 @@ describe('Wildcard Pattern Matching', () => { describe('Leading Wildcard Patterns (*.domain.com)', () => { test('should allow subdomain with *.github.com pattern', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'curl -sS https://api.github.com/zen', { allowDomains: ['*.github.com'], @@ -41,7 +41,7 @@ describe('Wildcard Pattern Matching', () => { }, 120000); test('should allow raw.githubusercontent.com with *.githubusercontent.com pattern', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'curl -fsS https://raw.githubusercontent.com/octocat/Hello-World/master/README', { allowDomains: ['*.githubusercontent.com', 'github.com'], @@ -55,7 +55,7 @@ describe('Wildcard Pattern Matching', () => { test('should allow nested subdomains with wildcard', async () => { // Allow any subdomain of github.com - const result = await runner.runWithSudo( + const result = await runner.run( 'curl -sS https://api.github.com/zen', { allowDomains: ['*.github.com'], @@ -70,7 +70,7 @@ describe('Wildcard Pattern Matching', () => { describe('Case Insensitivity', () => { test('should match domain case-insensitively', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'curl -sS https://API.GITHUB.COM/zen', { allowDomains: ['github.com'], @@ -83,7 +83,7 @@ describe('Wildcard Pattern Matching', () => { }, 120000); test('should match wildcard pattern case-insensitively', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'curl -sS https://API.GITHUB.COM/zen', { allowDomains: ['*.GitHub.COM'], @@ -98,7 +98,7 @@ describe('Wildcard Pattern Matching', () => { describe('Plain Domain Matching', () => { test('should allow exact domain match', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'curl -fsS https://github.com/robots.txt', { allowDomains: ['github.com'], @@ -111,7 +111,7 @@ describe('Wildcard Pattern Matching', () => { }, 120000); test('should allow subdomains of plain domain (github.com allows api.github.com)', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'curl -sS https://api.github.com/zen', { allowDomains: ['github.com'], @@ -126,7 +126,7 @@ describe('Wildcard Pattern Matching', () => { describe('Multiple Patterns', () => { test('should allow domains matching any of multiple patterns', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'bash -c "curl -sS https://api.github.com/zen && echo success"', { allowDomains: ['*.github.com', '*.gitlab.com', '*.bitbucket.org'], @@ -140,7 +140,7 @@ describe('Wildcard Pattern Matching', () => { }, 120000); test('should combine wildcard and plain domain patterns', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'bash -c "curl -sS https://api.github.com/zen && echo success"', { allowDomains: ['github.com', '*.githubusercontent.com'], @@ -156,7 +156,7 @@ describe('Wildcard Pattern Matching', () => { describe('Non-Matching Patterns', () => { test('should block domain not matching any pattern', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'curl -f https://example.com --max-time 5', { allowDomains: ['*.github.com'], @@ -170,7 +170,7 @@ describe('Wildcard Pattern Matching', () => { test('should block similar-looking domain', async () => { // "notgithub.com" should not match "*.github.com" - const result = await runner.runWithSudo( + const result = await runner.run( 'curl -f https://notgithub.com --max-time 5', { allowDomains: ['*.github.com'], diff --git a/tests/integration/workdir-tmpfs-hiding.test.ts b/tests/integration/workdir-tmpfs-hiding.test.ts index 21fec51f0..19bbb488f 100644 --- a/tests/integration/workdir-tmpfs-hiding.test.ts +++ b/tests/integration/workdir-tmpfs-hiding.test.ts @@ -41,7 +41,7 @@ describe('WorkDir tmpfs Hiding', () => { test('Test 1: docker-compose.yml is not readable in workDir', async () => { // Run AWF with a command that tries to find and read docker-compose.yml // The workDir is /tmp/awf-, so we glob for it - const result = await runner.runWithSudo( + const result = await runner.run( 'sh -c \'for d in /tmp/awf-*/; do if [ -f "$d/docker-compose.yml" ]; then cat "$d/docker-compose.yml"; echo "FOUND_COMPOSE"; fi; done\'', { allowDomains: ['github.com'], @@ -61,7 +61,7 @@ describe('WorkDir tmpfs Hiding', () => { test('Test 2: workDir appears empty to the agent', async () => { // List contents of any awf workdir - tmpfs should make it appear empty - const result = await runner.runWithSudo( + const result = await runner.run( 'sh -c \'for d in /tmp/awf-*/; do if [ -d "$d" ]; then echo "DIR:$d"; ls -la "$d" 2>&1; fi; done\'', { allowDomains: ['github.com'], @@ -79,7 +79,7 @@ describe('WorkDir tmpfs Hiding', () => { test('Test 3: sensitive env vars are not leaked via workDir files', async () => { // Pass a known secret via env and verify it cannot be found in workDir files - const result = await runner.runWithSudo( + const result = await runner.run( 'sh -c \'find /tmp/awf-* -type f 2>/dev/null | while read f; do cat "$f" 2>/dev/null; done | grep -c "SECRET_CANARY_VALUE" || echo "0"\'', { allowDomains: ['github.com'], @@ -101,7 +101,7 @@ describe('WorkDir tmpfs Hiding', () => { test('Test 4: docker-compose.yml is not readable at /host workDir path', async () => { // In chroot mode, the host filesystem is at /host // Try to read docker-compose.yml via the /host prefix - const result = await runner.runWithSudo( + const result = await runner.run( 'sh -c \'for d in /host/tmp/awf-*/; do if [ -f "$d/docker-compose.yml" ]; then cat "$d/docker-compose.yml"; echo "FOUND_COMPOSE"; fi; done\'', { allowDomains: ['github.com'], @@ -117,7 +117,7 @@ describe('WorkDir tmpfs Hiding', () => { }, 120000); test('Test 5: /host workDir also appears empty', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'sh -c \'for d in /host/tmp/awf-*/; do if [ -d "$d" ]; then echo "DIR:$d"; ls -la "$d" 2>&1; fi; done\'', { allowDomains: ['github.com'], @@ -135,7 +135,7 @@ describe('WorkDir tmpfs Hiding', () => { describe('Security Verification', () => { test('Test 6: grep for secrets in workDir finds nothing', async () => { // Simulate an attack: search for common secret patterns in any awf workDir - const result = await runner.runWithSudo( + const result = await runner.run( 'sh -c \'grep -r "GITHUB_TOKEN\\|ANTHROPIC_API_KEY\\|COPILOT_GITHUB_TOKEN\\|_authToken" /tmp/awf-*/ 2>&1 || true\' | grep -v "^\\[" | head -5', { allowDomains: ['github.com'], @@ -153,7 +153,7 @@ describe('WorkDir tmpfs Hiding', () => { }, 120000); test('Test 7: debug logs confirm tmpfs overlay is configured', async () => { - const result = await runner.runWithSudo( + const result = await runner.run( 'echo "test"', { allowDomains: ['github.com'],