diff --git a/containers/api-proxy/blocked-request-diagnostics.js b/containers/api-proxy/blocked-request-diagnostics.js index aa6aaa4ba..8d7e2fff9 100644 --- a/containers/api-proxy/blocked-request-diagnostics.js +++ b/containers/api-proxy/blocked-request-diagnostics.js @@ -284,7 +284,7 @@ function getDiagStream() { if (diagStream) return diagStream; try { fs.mkdirSync(TOKEN_LOG_DIR, { recursive: true }); - diagStream = fs.createWriteStream(DIAG_FILE, { flags: 'a' }); + diagStream = fs.createWriteStream(DIAG_FILE, { flags: 'a', mode: 0o644 }); diagStream.on('error', () => { diagStream = null; }); return diagStream; } catch { diff --git a/containers/api-proxy/otel-exporters.js b/containers/api-proxy/otel-exporters.js index 31a0cb9c7..12bed86ec 100644 --- a/containers/api-proxy/otel-exporters.js +++ b/containers/api-proxy/otel-exporters.js @@ -126,7 +126,7 @@ class FileSpanExporter { _getStream() { if (this._stream) return this._stream; try { - this._stream = fs.createWriteStream(this._filePath, { flags: 'a' }); + this._stream = fs.createWriteStream(this._filePath, { flags: 'a', mode: 0o644 }); this._stream.on('error', () => { this._stream = null; }); } catch { return null; } return this._stream; diff --git a/containers/api-proxy/token-persistence.js b/containers/api-proxy/token-persistence.js index 608408ab2..26da25ca9 100644 --- a/containers/api-proxy/token-persistence.js +++ b/containers/api-proxy/token-persistence.js @@ -63,7 +63,7 @@ function diag(msg, data) { try { if (!diagStream) { fs.mkdirSync(TOKEN_LOG_DIR, { recursive: true }); - diagStream = fs.createWriteStream(DIAG_LOG_FILE, { flags: 'a' }); + diagStream = fs.createWriteStream(DIAG_LOG_FILE, { flags: 'a', mode: 0o644 }); diagStream.on('error', () => { diagStream = null; }); } const record = buildTokenDiagRecord(msg, data); @@ -81,7 +81,7 @@ function getLogStream() { try { // Ensure directory exists fs.mkdirSync(TOKEN_LOG_DIR, { recursive: true }); - logStream = fs.createWriteStream(TOKEN_LOG_FILE, { flags: 'a' }); + logStream = fs.createWriteStream(TOKEN_LOG_FILE, { flags: 'a', mode: 0o644 }); logStream.on('error', (err) => { logRequest('warn', 'token_log_error', { error: err.message }); logStream = null; diff --git a/containers/cli-proxy/server.js b/containers/cli-proxy/server.js index 8f09b136d..97ef0b225 100644 --- a/containers/cli-proxy/server.js +++ b/containers/cli-proxy/server.js @@ -56,7 +56,7 @@ const CLI_PROXY_ACCESS_SCHEMA = `cli-proxy-access/v${AWF_VERSION}`; let logStream = null; try { if (fs.existsSync(LOG_DIR)) { - logStream = fs.createWriteStream(LOG_FILE, { flags: 'a' }); + logStream = fs.createWriteStream(LOG_FILE, { flags: 'a', mode: 0o644 }); } } catch { // Non-fatal: logging to file is best-effort diff --git a/src/artifact-preservation-errors.test.ts b/src/artifact-preservation-errors.test.ts index 9a2acbca2..194144183 100644 --- a/src/artifact-preservation-errors.test.ts +++ b/src/artifact-preservation-errors.test.ts @@ -87,6 +87,13 @@ describe('artifact-preservation – error paths', () => { // ─── preserveCleanupArtifacts ─────────────────────────────────────────── describe('preserveCleanupArtifacts', () => { + let getuidSpy: jest.SpyInstance | undefined; + + afterEach(() => { + getuidSpy?.mockRestore(); + getuidSpy = undefined; + }); + it('does not throw when agent-logs renameSync fails (line 101)', () => { const workDir = makeTempDir(); try { @@ -160,6 +167,74 @@ describe('artifact-preservation – error paths', () => { } }); + it('runs rootless permission repair with translated mount paths', () => { + const auditDir = makeTempDir('awf-audit-'); + const workDir = makeTempDir(); + try { + getuidSpy = jest.spyOn(process, 'getuid').mockReturnValue(1001); + expect(() => preserveCleanupArtifacts(workDir, { + auditDir, + dockerHostPathPrefix: '/host', + imageRegistry: 'ghcr.io/github/gh-aw-firewall', + imageTag: 'latest', + })).not.toThrow(); + + expect(mockExecaSync).toHaveBeenCalledWith( + 'docker', + expect.arrayContaining([ + 'run', + '--pull', + 'never', + '-v', + `/host${path.resolve(auditDir)}:/fix:rw`, + 'ghcr.io/github/gh-aw-firewall/agent:latest', + ]), + expect.objectContaining({ reject: false }), + ); + } finally { + realFs.rmSync(auditDir, { recursive: true, force: true }); + realFs.rmSync(workDir, { recursive: true, force: true }); + } + }); + + it('uses agent-act image when agentImage is act', () => { + const auditDir = makeTempDir('awf-audit-'); + const workDir = makeTempDir(); + try { + getuidSpy = jest.spyOn(process, 'getuid').mockReturnValue(1001); + expect(() => preserveCleanupArtifacts(workDir, { + auditDir, + imageRegistry: 'ghcr.io/github/gh-aw-firewall', + imageTag: 'latest', + agentImage: 'act', + })).not.toThrow(); + + expect(mockExecaSync).toHaveBeenCalledWith( + 'docker', + expect.arrayContaining([ + 'ghcr.io/github/gh-aw-firewall/agent-act:latest', + ]), + expect.objectContaining({ reject: false }), + ); + } finally { + realFs.rmSync(auditDir, { recursive: true, force: true }); + realFs.rmSync(workDir, { recursive: true, force: true }); + } + }); + + it('skips rootless permission repair when running as root', () => { + const auditDir = makeTempDir('awf-audit-'); + const workDir = makeTempDir(); + try { + getuidSpy = jest.spyOn(process, 'getuid').mockReturnValue(0); + expect(() => preserveCleanupArtifacts(workDir, { auditDir })).not.toThrow(); + expect(mockExecaSync.mock.calls.some(call => call[0] === 'docker')).toBe(false); + } finally { + realFs.rmSync(auditDir, { recursive: true, force: true }); + realFs.rmSync(workDir, { recursive: true, force: true }); + } + }); + it('preserves default audit dir to /tmp when no auditDir arg is provided (lines 170-173)', () => { const workDir = makeTempDir(); const timestamp = path.basename(workDir).replace('awf-', ''); diff --git a/src/artifact-preservation.ts b/src/artifact-preservation.ts index 01e9269a9..9af5bbe81 100644 --- a/src/artifact-preservation.ts +++ b/src/artifact-preservation.ts @@ -2,7 +2,11 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import execa from 'execa'; +import { getSafeHostGid, getSafeHostUid } from './host-identity'; +import { buildRuntimeImageRef, parseImageTag } from './image-tag'; import { logger } from './logger'; +import { applyHostPathPrefixToVolumes } from './services/host-path-prefix'; +import { getLocalDockerEnv } from './docker-host'; /** * Copies the iptables audit dump from the init-signal volume to the audit directory. @@ -59,7 +63,7 @@ function preserveDirectory({ execa.sync('chmod', ['-R', 'a+rX', targetDir]); logger.info(`${availableLabel} available at: ${targetDir}`); } catch (error) { - logger.debug(permissionErrorMessage, error); + logger.warn(permissionErrorMessage, error); } } return; @@ -84,11 +88,92 @@ type PreserveCleanupArtifactsOptions = { proxyLogsDir?: string; auditDir?: string; sessionStateDir?: string; + dockerHostPathPrefix?: string; + imageRegistry?: string; + imageTag?: string; + agentImage?: string; }; +function resolvePermFixerImageRef(imageRegistry?: string, imageTag?: string, agentImage?: string): string { + try { + const registry = imageRegistry || 'ghcr.io/github/gh-aw-firewall'; + const parsedImageTag = parseImageTag(imageTag || 'latest'); + const imageName = agentImage === 'act' ? 'agent-act' : 'agent'; + return buildRuntimeImageRef(registry, imageName, parsedImageTag); + } catch { + return 'ghcr.io/github/gh-aw-firewall/agent:latest'; + } +} + +function fixArtifactPermissionsForRootless( + dirs: Array, + dockerHostPathPrefix: string | undefined, + imageRegistry: string | undefined, + imageTag: string | undefined, + agentImage: string | undefined, +): void { + const currentUid = process.getuid?.(); + if (currentUid === undefined || currentUid === 0) { + return; + } + + const existingDirs = dirs.filter( + (dir): dir is string => typeof dir === 'string' && dir.length > 0 && fs.existsSync(dir), + ); + if (existingDirs.length === 0) { + return; + } + + const uid = getSafeHostUid(); + const gid = getSafeHostGid(); + const imageRef = resolvePermFixerImageRef(imageRegistry, imageTag, agentImage); + + for (const dir of existingDirs) { + const mount = applyHostPathPrefixToVolumes([`${path.resolve(dir)}:/fix:rw`], dockerHostPathPrefix)[0]; + try { + const result = execa.sync( + 'docker', + [ + 'run', + '--rm', + '--pull', + 'never', + '--network', + 'none', + '--cap-drop', + 'ALL', + '--cap-add', + 'CHOWN', + '--cap-add', + 'DAC_OVERRIDE', + '--cap-add', + 'FOWNER', + '-e', + `TUID=${uid}`, + '-e', + `TGID=${gid}`, + '-v', + mount, + imageRef, + 'sh', + '-c', + 'chown -R "$TUID:$TGID" /fix && chmod -R a+rX /fix', + ], + { env: getLocalDockerEnv(), reject: false }, + ); + + if (typeof result.exitCode === 'number' && result.exitCode !== 0) { + logger.warn(`Rootless artifact permission repair failed for ${dir} (exit ${result.exitCode})`); + } + } catch (error) { + logger.warn(`Rootless artifact permission repair failed for ${dir}:`, error); + } + } +} + export function preserveCleanupArtifacts( workDir: string, - { proxyLogsDir, auditDir, sessionStateDir }: PreserveCleanupArtifactsOptions = {}, + { proxyLogsDir, auditDir, sessionStateDir, dockerHostPathPrefix, imageRegistry, imageTag, agentImage }: PreserveCleanupArtifactsOptions = {}, ): void { const timestamp = path.basename(workDir).replace('awf-', ''); const agentLogsDestination = path.join(os.tmpdir(), `awf-agent-logs-${timestamp}`); @@ -160,7 +245,7 @@ export function preserveCleanupArtifacts( execa.sync('chmod', ['-R', 'a+rX', auditDir]); logger.info(`Audit artifacts available at: ${auditDir}`); } catch (error) { - logger.debug('Could not fix audit dir permissions:', error); + logger.warn('Could not fix audit dir permissions as non-root user; rootless repair will be attempted:', error); } } } else { @@ -205,6 +290,14 @@ export function preserveCleanupArtifacts( } } } + + fixArtifactPermissionsForRootless( + [proxyLogsDir, auditDir, sessionStateDir], + dockerHostPathPrefix, + imageRegistry, + imageTag, + agentImage, + ); } export function removeWorkDirectories(workDir: string): void { diff --git a/src/commands/main-action.test.ts b/src/commands/main-action.test.ts index ee0185d2c..41844853b 100644 --- a/src/commands/main-action.test.ts +++ b/src/commands/main-action.test.ts @@ -247,7 +247,10 @@ describe('createMainAction', () => { false, STUB_CONFIG.proxyLogsDir, STUB_CONFIG.auditDir, - STUB_CONFIG.sessionStateDir + STUB_CONFIG.sessionStateDir, + STUB_CONFIG.dockerHostPathPrefix, + STUB_CONFIG.imageRegistry, + STUB_CONFIG.imageTag, ); expect(mockedHostIptables.cleanupHostIptables).not.toHaveBeenCalled(); expect(processExitSpy).toHaveBeenCalledWith(1); diff --git a/src/commands/main-action.ts b/src/commands/main-action.ts index 65a4f950a..eb8405054 100644 --- a/src/commands/main-action.ts +++ b/src/commands/main-action.ts @@ -162,7 +162,17 @@ export function createMainAction(getOptionValueSource: OptionSourceResolver) { } if (!config.keepContainers) { - await cleanup(config.workDir, false, config.proxyLogsDir, config.auditDir, config.sessionStateDir); + await cleanup( + config.workDir, + false, + config.proxyLogsDir, + config.auditDir, + config.sessionStateDir, + config.dockerHostPathPrefix, + config.imageRegistry, + config.imageTag, + config.agentImage, + ); // Note: We don't remove the firewall network here since it can be reused // across multiple runs. Cleanup script will handle removal if needed. } else { diff --git a/src/container-cleanup.ts b/src/container-cleanup.ts index 9ed835032..35453d249 100644 --- a/src/container-cleanup.ts +++ b/src/container-cleanup.ts @@ -17,6 +17,10 @@ export async function cleanup( proxyLogsDir?: string, auditDir?: string, sessionStateDir?: string, + dockerHostPathPrefix?: string, + imageRegistry?: string, + imageTag?: string, + agentImage?: string, ): Promise { if (keepFiles) { logger.debug(`Keeping temporary files in: ${workDir}`); @@ -29,7 +33,15 @@ export async function cleanup( return; } - preserveCleanupArtifacts(workDir, { proxyLogsDir, auditDir, sessionStateDir }); + preserveCleanupArtifacts(workDir, { + proxyLogsDir, + auditDir, + sessionStateDir, + dockerHostPathPrefix, + imageRegistry, + imageTag, + agentImage, + }); cleanupSslKeyMaterial(workDir); diff --git a/src/services/api-proxy-service-config.test.ts b/src/services/api-proxy-service-config.test.ts index b754a53fc..4311f21d8 100644 --- a/src/services/api-proxy-service-config.test.ts +++ b/src/services/api-proxy-service-config.test.ts @@ -1,5 +1,6 @@ import { generateDockerCompose, WrapperConfig, baseConfig, mockNetworkConfig, useTempWorkDir } from './service-test-setup.test-utils'; import { mockNetworkConfigWithProxy } from './api-proxy-service.test-utils'; +import { getSafeHostGid, getSafeHostUid } from '../host-identity'; // Create mock functions (must remain per-file — jest.mock() is hoisted before imports) @@ -33,8 +34,9 @@ describe('API proxy sidecar: service configuration', () => { const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-openai-key' }; const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); expect(result.services['api-proxy']).toBeDefined(); - const proxy = result.services['api-proxy']; + const proxy = result.services['api-proxy'] as any; expect(proxy.container_name).toBe('awf-api-proxy'); + expect(proxy.user).toBe(`${getSafeHostUid()}:${getSafeHostGid()}`); expect((proxy.networks as any)['awf-net'].ipv4_address).toBe('172.30.0.30'); }); diff --git a/src/services/api-proxy-service-config.ts b/src/services/api-proxy-service-config.ts index 81e29fbe7..061088299 100644 --- a/src/services/api-proxy-service-config.ts +++ b/src/services/api-proxy-service-config.ts @@ -3,6 +3,7 @@ import { } from '../constants'; import { assignImageSource } from '../image-tag'; import { WrapperConfig } from '../types'; +import { getSafeHostGid, getSafeHostUid } from '../host-identity'; import { NetworkConfig, ImageBuildConfig } from './squid-service'; import { applyHostPathPrefixToVolumes } from './host-path-prefix'; import { buildContainerSecurityHardening } from './service-security'; @@ -25,6 +26,7 @@ export function buildApiProxyServiceConfig(params: ApiProxyServiceConfigParams): const proxyService: any = { container_name: API_PROXY_CONTAINER_NAME, + user: `${getSafeHostUid()}:${getSafeHostGid()}`, ...buildApiProxyLifecycleConfig(networkConfig), volumes: applyHostPathPrefixToVolumes( [ diff --git a/src/services/cli-proxy-service.test.ts b/src/services/cli-proxy-service.test.ts index 8ad7e02d2..9dd01ef36 100644 --- a/src/services/cli-proxy-service.test.ts +++ b/src/services/cli-proxy-service.test.ts @@ -37,8 +37,9 @@ describe('CLI proxy sidecar (external DIFC proxy)', () => { const configWithCliProxy = { ...mockConfig, difcProxyHost: 'host.docker.internal:18443' }; const result = generateDockerCompose(configWithCliProxy, mockNetworkConfigWithCliProxy); expect(result.services['cli-proxy']).toBeDefined(); - const proxy = result.services['cli-proxy']; + const proxy = result.services['cli-proxy'] as any; expect(proxy.container_name).toBe('awf-cli-proxy'); + expect(proxy.user).toBeUndefined(); // cli-proxy gets its own IP on awf-net (no shared network namespace) expect((proxy.networks as any)['awf-net'].ipv4_address).toBe('172.30.0.50'); expect(proxy.network_mode).toBeUndefined();