From 306854b2ca1c52dc32d6bcbc3bedb7f319c6294f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 13 Jun 2026 17:18:30 +0000 Subject: [PATCH 1/4] Initial plan From 12057cb15b2b6a983117eb9851489d48f1051cc3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 13 Jun 2026 17:26:05 +0000 Subject: [PATCH 2/4] refactor: extract workdir setup from writeConfigs into workdir-setup.ts Extract concerns #1 (log/state dir setup) and #2 (chroot home bind-mount prep) with their four private helpers into a new src/workdir-setup.ts module. - src/log-paths.ts: export LogPaths interface - src/workdir-setup.ts: new module with prepareWorkDirectories() - src/config-writer.ts: remove extracted code; call prepareWorkDirectories(); shrinks from 431 to ~194 lines - src/workdir-setup.test.ts: 16 direct unit tests for the new module Closes #4878 --- src/config-writer.ts | 274 +++------------------------------- src/log-paths.ts | 2 +- src/workdir-setup.test.ts | 299 ++++++++++++++++++++++++++++++++++++++ src/workdir-setup.ts | 284 ++++++++++++++++++++++++++++++++++++ 4 files changed, 602 insertions(+), 257 deletions(-) create mode 100644 src/workdir-setup.test.ts create mode 100644 src/workdir-setup.ts diff --git a/src/config-writer.ts b/src/config-writer.ts index 74084a0ef..3b47cea69 100644 --- a/src/config-writer.ts +++ b/src/config-writer.ts @@ -5,120 +5,16 @@ import { WrapperConfig, API_PROXY_PORTS } from './types'; import { logger } from './logger'; import { generatePolicyManifest, generateSquidConfig } from './squid-config'; import { generateSessionCa, initSslDb, parseUrlPatterns, isOpenSslAvailable } from './ssl-bump'; -import { SslConfig, SQUID_PORT, getSafeHostUid, getSafeHostGid, getRealUserHome } from './host-env'; +import { SslConfig, SQUID_PORT } from './host-env'; import { generateDockerCompose, redactDockerComposeSecrets } from './compose-generator'; import { resolveLogPaths } from './log-paths'; -import { resolveRunnerToolCachePath } from './runner-tool-cache'; +import { prepareWorkDirectories } from './workdir-setup'; // When bundled with esbuild, this global is replaced at build time with the // JSON content of containers/agent/seccomp-profile.json. In normal (tsc) // builds the identifier remains undeclared, so the typeof check below is safe. declare const __AWF_SECCOMP_PROFILE__: string | undefined; -interface EnsureDirectoryOptions { - mode?: number; - onCreate?: () => void; - onExists?: () => void; - onAfterEnsure?: () => void; -} - -function ensureDirectory(dirPath: string, options: EnsureDirectoryOptions = {}): boolean { - const { mode, onCreate, onExists, onAfterEnsure } = options; - const created = Boolean( - fs.mkdirSync(dirPath, mode === undefined ? { recursive: true } : { recursive: true, mode }) - ); - - const lstat = fs.lstatSync(dirPath); - if (lstat.isSymbolicLink()) { - throw new Error(`Refusing to use symlink as directory: ${dirPath}`); - } - - const stat = fs.statSync(dirPath); - if (!stat.isDirectory()) { - throw new Error(`Expected directory but found non-directory path: ${dirPath}`); - } - - if (created) { - onCreate?.(); - } else { - onExists?.(); - } - - onAfterEnsure?.(); - return created; -} - -function assertRealDirectory(dirPath: string): void { - const lstat = fs.lstatSync(dirPath); - if (lstat.isSymbolicLink()) { - throw new Error(`Refusing to use symlink as directory: ${dirPath}`); - } - - const stat = fs.statSync(dirPath); - if (!stat.isDirectory()) { - throw new Error(`Expected directory but found non-directory path: ${dirPath}`); - } -} - -function createMissingOwnedDirectorySegments(dirPath: string, uid: number, gid: number): void { - let currentPath = path.isAbsolute(dirPath) - ? path.parse(dirPath).root - : ''; - const segments = dirPath.split(path.sep).filter(Boolean); - - for (const segment of segments) { - currentPath = currentPath ? path.join(currentPath, segment) : segment; - let created = false; - if (!fs.existsSync(currentPath)) { - fs.mkdirSync(currentPath); - created = true; - } - - // Validate the current segment is a directory. Allow root-owned system symlinks - // (e.g. /var on macOS) but refuse user-controlled symlinks. - const lstat = fs.lstatSync(currentPath); - if (lstat.isSymbolicLink() && (created || lstat.uid !== 0)) { - throw new Error(`Refusing to use symlink as directory: ${currentPath}`); - } - const stat = fs.statSync(currentPath); - if (!stat.isDirectory()) { - throw new Error(`Expected directory but found non-directory path: ${currentPath}`); - } - - if (created) { - fs.chownSync(currentPath, uid, gid); - fs.chmodSync(currentPath, 0o755); - } - } -} - -// Prepare a nested bind-mount destination inside the empty chroot home before -// Docker sees it. Without this, Docker may create intermediate parents such as -// `/work` as root-owned directories with restrictive traversal bits, -// causing the chrooted runner user to get EACCES before reaching the leaf mount. -// This operates only on the chroot-home placeholder path, e.g. -// `/work/_tool`; it does not chown or chmod the real host source -// `/home/runner/work/_tool`, which Docker will later mount over the placeholder. -function prepareChrootHomeMountpoint(emptyHomeDir: string, relativeMountPath: string, uid: number, gid: number): string { - let chrootPath = emptyHomeDir; - const segments = relativeMountPath.split(path.sep).filter(Boolean); - - for (const segment of segments) { - chrootPath = path.join(chrootPath, segment); - if (!fs.existsSync(chrootPath)) { - fs.mkdirSync(chrootPath); - } - - // The final segment may be the leaf mountpoint (`_tool`). That is okay: this - // is still only the placeholder inside emptyHomeDir, not the host tool cache. - assertRealDirectory(chrootPath); - fs.chownSync(chrootPath, uid, gid); - fs.chmodSync(chrootPath, 0o755); - } - - return chrootPath; -} - /** * Writes configuration files to disk * Uses fixed network configuration (172.30.0.0/24) defined in host-iptables.ts @@ -129,160 +25,26 @@ export async function writeConfigs(config: WrapperConfig): Promise { // Ensure work directory exists with restricted permissions (owner-only access) // Defense-in-depth: even if tmpfs overlay fails, non-root processes on the host // cannot read the docker-compose.yml which contains sensitive tokens - ensureDirectory(config.workDir, { - mode: 0o700, - onExists: () => fs.chmodSync(config.workDir, 0o700), - }); + const workDirCreated = Boolean( + fs.mkdirSync(config.workDir, { recursive: true, mode: 0o700 }) + ); + const workDirLstat = fs.lstatSync(config.workDir); + if (workDirLstat.isSymbolicLink()) { + throw new Error(`Refusing to use symlink as directory: ${config.workDir}`); + } + const workDirStat = fs.statSync(config.workDir); + if (!workDirStat.isDirectory()) { + throw new Error(`Expected directory but found non-directory path: ${config.workDir}`); + } + if (!workDirCreated) { + fs.chmodSync(config.workDir, 0o700); + } // Resolve all log/state directory paths from a single source of truth const logPaths = resolveLogPaths(config); - // Create agent logs directory for persistence - // Chown to host user so Copilot CLI can write logs (AWF runs as root, agent runs as host user) - ensureDirectory(logPaths.agentLogs, { - onAfterEnsure: () => { - try { - fs.chownSync(logPaths.agentLogs, parseInt(getSafeHostUid()), parseInt(getSafeHostGid())); - } catch { /* ignore chown failures in non-root context */ } - }, - }); - logger.debug(`Agent logs directory created at: ${logPaths.agentLogs}`); - - // Create agent session-state directory for persistence (events.jsonl, session data) - // If sessionStateDir is specified, write directly there (timeout-safe, predictable path) - // Otherwise, use workDir/agent-session-state (will be moved to /tmp after cleanup) - // Chown to host user so Copilot CLI can create session subdirs and write events.jsonl - ensureDirectory(logPaths.sessionState, { - onAfterEnsure: () => { - try { - fs.chownSync(logPaths.sessionState, parseInt(getSafeHostUid()), parseInt(getSafeHostGid())); - } catch { /* ignore chown failures in non-root context */ } - }, - }); - logger.debug(`Agent session-state directory created at: ${logPaths.sessionState}`); - - // Create squid logs directory for persistence - // If proxyLogsDir is specified, write directly there (timeout-safe) - // Otherwise, use workDir/squid-logs (will be moved to /tmp after cleanup) - // Note: Squid runs as user 'proxy' (UID 13, GID 13 in ubuntu/squid image) - // We need to make the directory writable by the proxy user - // Squid container runs as non-root 'proxy' user (UID 13, GID 13) - // Set ownership so proxy user can write logs without root privileges - const SQUID_PROXY_UID = 13; - const SQUID_PROXY_GID = 13; - ensureDirectory(logPaths.squidLogs, { - mode: 0o755, - onCreate: () => { - try { - fs.chownSync(logPaths.squidLogs, SQUID_PROXY_UID, SQUID_PROXY_GID); - } catch { - // Fallback to world-writable if chown fails (e.g., non-root context) - fs.chmodSync(logPaths.squidLogs, 0o777); - } - }, - }); - logger.debug(`Squid logs directory created at: ${logPaths.squidLogs}`); - - // Create api-proxy logs directory for persistence - // If proxyLogsDir is specified, write inside it as a subdirectory (timeout-safe, - // and included in the firewall-audit-logs artifact upload automatically) - // Otherwise, write to workDir/api-proxy-logs (will be moved to /tmp after cleanup) - // Note: API proxy runs as user 'apiproxy' (non-root) - ensureDirectory(logPaths.apiProxyLogs, { - mode: 0o777, - onCreate: () => { - // Explicitly set permissions to 0o777 (not affected by umask) - fs.chmodSync(logPaths.apiProxyLogs, 0o777); - }, - }); - logger.debug(`API proxy logs directory created at: ${logPaths.apiProxyLogs}`); - - // Create CLI proxy logs directory for persistence - // Note: CLI proxy runs as user 'cliproxy' (non-root) - ensureDirectory(logPaths.cliProxyLogs, { - mode: 0o777, - onCreate: () => fs.chmodSync(logPaths.cliProxyLogs, 0o777), - }); - logger.debug(`CLI proxy logs directory created at: ${logPaths.cliProxyLogs}`); - - // Create /tmp/gh-aw/mcp-logs directory - // This directory exists on the HOST for MCP gateway to write logs - // Inside the AWF container, it's hidden via tmpfs mount (see generateDockerCompose) - // Uses mode 0o777 to allow GitHub Actions workflows and MCP gateway to create subdirectories - // even when AWF runs as root (e.g., sudo awf) - const mcpLogsDir = '/tmp/gh-aw/mcp-logs'; - if (ensureDirectory(mcpLogsDir, { mode: 0o777 })) { - // Explicitly set permissions to 0o777 (not affected by umask) - fs.chmodSync(mcpLogsDir, 0o777); - logger.debug(`MCP logs directory created at: ${mcpLogsDir}`); - } else { - // Fix permissions if directory already exists (e.g., created by a previous run) - fs.chmodSync(mcpLogsDir, 0o777); - logger.debug(`MCP logs directory permissions fixed at: ${mcpLogsDir}`); - } - - // Ensure chroot home subdirectories exist with correct ownership before Docker - // bind-mounts them. If a source directory doesn't exist, Docker creates it as - // root:root, making it inaccessible to the agent user (e.g., UID 1001). - // Also create an empty writable home directory that gets mounted as $HOME - // in the chroot, giving tools a writable home without exposing credentials. - { - const effectiveHome = getRealUserHome(); - const uid = parseInt(getSafeHostUid(), 10); - const gid = parseInt(getSafeHostGid(), 10); - - // Create empty writable home directory for the chroot - // This is mounted as $HOME inside the container so tools can write to it - // NOTE: Must be outside workDir to avoid being hidden by the tmpfs overlay - const emptyHomeDir = `${config.workDir}-chroot-home`; - if (!fs.existsSync(emptyHomeDir)) { - fs.mkdirSync(emptyHomeDir, { recursive: true }); - } - fs.chownSync(emptyHomeDir, uid, gid); - logger.debug(`Created chroot home directory: ${emptyHomeDir} (${uid}:${gid})`); - - // Ensure source directories for home subdirectory mounts exist with correct ownership. - const hostHomeMountSourceDirs = [ - '.copilot', '.cache', '.config', '.local', - '.anthropic', '.claude', '.cargo', '.rustup', '.npm', '.nvm', - ...(config.geminiApiKey ? ['.gemini'] : []), - ]; - for (const dir of hostHomeMountSourceDirs) { - const dirPath = path.join(effectiveHome, dir); - if (!fs.existsSync(dirPath)) { - fs.mkdirSync(dirPath, { recursive: true }); - fs.chownSync(dirPath, uid, gid); - logger.debug(`Created host home subdirectory: ${dirPath} (${uid}:${gid})`); - } - } - - // Source-side prep: this only applies when the config file explicitly names - // a runner tool-cache source path that does not exist yet. Create that host - // source so Docker has something real to bind-mount later. - if (config.runnerToolCachePath && !fs.existsSync(config.runnerToolCachePath)) { - const relToHome = path.relative(effectiveHome, config.runnerToolCachePath); - const isUnderHome = relToHome && !relToHome.startsWith('..') && !path.isAbsolute(relToHome); - - if (isUnderHome) { - createMissingOwnedDirectorySegments(config.runnerToolCachePath, uid, gid); - logger.debug(`Created runner tool cache directory: ${config.runnerToolCachePath} (${uid}:${gid})`); - } else { - logger.warn(`Runner tool cache path does not exist; refusing to create outside effective home (${effectiveHome}): ${config.runnerToolCachePath}`); - } - } - - // Destination-side prep: resolve the same source path that home-strategy.ts - // will mount. If that source is nested under the empty chroot home, prepare - // the placeholder mountpoint there so Docker does not create parents as root. - const runnerToolCachePath = resolveRunnerToolCachePath(config, effectiveHome); - if (runnerToolCachePath) { - const relativeToolCachePath = path.relative(effectiveHome, runnerToolCachePath); - if (relativeToolCachePath && !relativeToolCachePath.startsWith('..') && !path.isAbsolute(relativeToolCachePath)) { - const chrootToolCachePath = prepareChrootHomeMountpoint(emptyHomeDir, relativeToolCachePath, uid, gid); - logger.debug(`Prepared chroot runner tool cache mountpoint: ${chrootToolCachePath} (${uid}:${gid})`); - } - } - } + // Prepare all working directories (log/state dirs and chroot home bind-mounts) + prepareWorkDirectories(config, logPaths); // Use fixed network configuration (network is created by host-iptables.ts) const networkConfig = { diff --git a/src/log-paths.ts b/src/log-paths.ts index e3ba81ae8..6d8fd9e1b 100644 --- a/src/log-paths.ts +++ b/src/log-paths.ts @@ -1,7 +1,7 @@ import * as path from 'path'; import { WrapperConfig } from './types'; -interface LogPaths { +export interface LogPaths { squidLogs: string; sessionState: string; agentLogs: string; diff --git a/src/workdir-setup.test.ts b/src/workdir-setup.test.ts new file mode 100644 index 000000000..653e2338d --- /dev/null +++ b/src/workdir-setup.test.ts @@ -0,0 +1,299 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const mockGetRealUserHome = jest.fn(); + +jest.mock('fs', () => { + const actual = jest.requireActual('fs'); + return { + ...actual, + chmodSync: jest.fn((...args: Parameters) => + actual.chmodSync(...args) + ), + chownSync: jest.fn(), + existsSync: jest.fn((...args: Parameters) => + actual.existsSync(...args) + ), + lstatSync: jest.fn((...args: Parameters) => + actual.lstatSync(...args) + ) as typeof actual.lstatSync, + }; +}); + +jest.mock('./host-env', () => ({ + getSafeHostUid: jest.fn().mockReturnValue('1000'), + getSafeHostGid: jest.fn().mockReturnValue('1000'), + getRealUserHome: mockGetRealUserHome, +})); + +jest.mock('./host-identity', () => ({ + getRealUserHome: mockGetRealUserHome, +})); + +import { prepareWorkDirectories } from './workdir-setup'; +import { resolveLogPaths } from './log-paths'; +import { getRealUserHome } from './host-identity'; + +describe('prepareWorkDirectories', () => { + let tempDir: string; + + const buildConfig = (overrides: Record = {}) => ({ + workDir: tempDir, + sslBump: false, + allowedDomains: [] as string[], + agentCommand: 'echo test', + logLevel: 'info' as const, + keepContainers: false, + buildLocal: false, + imageRegistry: 'ghcr.io/github/gh-aw-firewall', + imageTag: 'latest', + ...overrides, + }); + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'workdir-setup-test-')); + jest.clearAllMocks(); + (fs.chownSync as unknown as jest.Mock).mockImplementation(() => undefined); + (getRealUserHome as jest.Mock).mockReturnValue(tempDir); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + fs.rmSync(`${tempDir}-chroot-home`, { recursive: true, force: true }); + }); + + describe('log/state directory setup', () => { + it('creates agent logs directory', () => { + const config = buildConfig(); + const logPaths = resolveLogPaths(config); + + prepareWorkDirectories(config, logPaths); + + expect(fs.existsSync(logPaths.agentLogs)).toBe(true); + expect(fs.statSync(logPaths.agentLogs).isDirectory()).toBe(true); + }); + + it('creates session-state directory', () => { + const config = buildConfig(); + const logPaths = resolveLogPaths(config); + + prepareWorkDirectories(config, logPaths); + + expect(fs.existsSync(logPaths.sessionState)).toBe(true); + expect(fs.statSync(logPaths.sessionState).isDirectory()).toBe(true); + }); + + it('creates squid logs directory', () => { + const config = buildConfig(); + const logPaths = resolveLogPaths(config); + + prepareWorkDirectories(config, logPaths); + + expect(fs.existsSync(logPaths.squidLogs)).toBe(true); + expect(fs.statSync(logPaths.squidLogs).isDirectory()).toBe(true); + }); + + it('creates api-proxy logs directory', () => { + const config = buildConfig(); + const logPaths = resolveLogPaths(config); + + prepareWorkDirectories(config, logPaths); + + expect(fs.existsSync(logPaths.apiProxyLogs)).toBe(true); + expect(fs.statSync(logPaths.apiProxyLogs).isDirectory()).toBe(true); + }); + + it('creates cli-proxy logs directory', () => { + const config = buildConfig(); + const logPaths = resolveLogPaths(config); + + prepareWorkDirectories(config, logPaths); + + expect(fs.existsSync(logPaths.cliProxyLogs)).toBe(true); + expect(fs.statSync(logPaths.cliProxyLogs).isDirectory()).toBe(true); + }); + + it('creates /tmp/gh-aw/mcp-logs with mode 0o777', () => { + const mcpLogsDir = '/tmp/gh-aw/mcp-logs'; + const config = buildConfig(); + const logPaths = resolveLogPaths(config); + + prepareWorkDirectories(config, logPaths); + + expect(fs.existsSync(mcpLogsDir)).toBe(true); + const mode = fs.statSync(mcpLogsDir).mode & 0o777; + expect(mode).toBe(0o777); + }); + + it('forces pre-existing mcp logs directory to mode 0o777', () => { + const mcpLogsDir = '/tmp/gh-aw/mcp-logs'; + fs.mkdirSync(mcpLogsDir, { recursive: true, mode: 0o700 }); + fs.chmodSync(mcpLogsDir, 0o700); + + const config = buildConfig(); + const logPaths = resolveLogPaths(config); + + prepareWorkDirectories(config, logPaths); + + const mode = fs.statSync(mcpLogsDir).mode & 0o777; + expect(mode).toBe(0o777); + }); + + it('falls back to world-writable squid logs when squid chown fails', () => { + const proxyLogsDir = path.join(tempDir, 'proxy-logs'); + (fs.chownSync as unknown as jest.Mock).mockImplementation((targetPath: fs.PathLike) => { + if (String(targetPath) === proxyLogsDir) { + throw new Error('chown failed'); + } + }); + + const config = buildConfig({ proxyLogsDir }); + const logPaths = resolveLogPaths(config); + + prepareWorkDirectories(config, logPaths); + + const mode = fs.statSync(proxyLogsDir).mode & 0o777; + expect(mode).toBe(0o777); + }); + }); + + describe('chroot home bind-mount preparation', () => { + it('creates chroot home directory when it does not exist', () => { + const emptyHomeDir = `${tempDir}-chroot-home`; + expect(fs.existsSync(emptyHomeDir)).toBe(false); + + const config = buildConfig(); + const logPaths = resolveLogPaths(config); + + prepareWorkDirectories(config, logPaths); + + expect(fs.existsSync(emptyHomeDir)).toBe(true); + expect(fs.statSync(emptyHomeDir).isDirectory()).toBe(true); + }); + + it('uses existing chroot home directory if already present', () => { + const emptyHomeDir = `${tempDir}-chroot-home`; + fs.mkdirSync(emptyHomeDir, { recursive: true }); + const statBefore = fs.statSync(emptyHomeDir); + + const config = buildConfig(); + const logPaths = resolveLogPaths(config); + + prepareWorkDirectories(config, logPaths); + + const statAfter = fs.statSync(emptyHomeDir); + expect(statAfter.ino).toBe(statBefore.ino); + }); + + it('creates missing home subdirectories with correct ownership', () => { + (getRealUserHome as jest.Mock).mockReturnValue(tempDir); + + const copilotDir = path.join(tempDir, '.copilot'); + if (fs.existsSync(copilotDir)) { + fs.rmSync(copilotDir, { recursive: true, force: true }); + } + + const config = buildConfig(); + const logPaths = resolveLogPaths(config); + + prepareWorkDirectories(config, logPaths); + + expect(fs.existsSync(copilotDir)).toBe(true); + expect(fs.chownSync).toHaveBeenCalledWith(copilotDir, 1000, 1000); + }); + + it('creates .gemini directory when geminiApiKey is provided', () => { + (getRealUserHome as jest.Mock).mockReturnValue(tempDir); + + const geminiDir = path.join(tempDir, '.gemini'); + if (fs.existsSync(geminiDir)) { + fs.rmSync(geminiDir, { recursive: true, force: true }); + } + + const config = buildConfig({ geminiApiKey: 'test-key' }); + const logPaths = resolveLogPaths(config); + + prepareWorkDirectories(config, logPaths); + + expect(fs.existsSync(geminiDir)).toBe(true); + }); + + it('does not create .gemini directory when geminiApiKey is not provided', () => { + (getRealUserHome as jest.Mock).mockReturnValue(tempDir); + + const geminiDir = path.join(tempDir, '.gemini'); + if (fs.existsSync(geminiDir)) { + fs.rmSync(geminiDir, { recursive: true, force: true }); + } + + const config = buildConfig(); + const logPaths = resolveLogPaths(config); + + prepareWorkDirectories(config, logPaths); + + expect(fs.existsSync(geminiDir)).toBe(false); + }); + + it('creates configured runner tool cache directory segments with correct ownership', () => { + const runnerToolCacheParent = path.join(tempDir, 'runner-work'); + const runnerToolCachePath = path.join(runnerToolCacheParent, '_tool'); + expect(fs.existsSync(runnerToolCachePath)).toBe(false); + + const config = buildConfig({ runnerToolCachePath }); + const logPaths = resolveLogPaths(config); + + prepareWorkDirectories(config, logPaths); + + expect(fs.existsSync(runnerToolCachePath)).toBe(true); + expect(fs.statSync(runnerToolCachePath).isDirectory()).toBe(true); + expect(fs.chownSync).toHaveBeenCalledWith(runnerToolCacheParent, 1000, 1000); + expect(fs.chmodSync).toHaveBeenCalledWith(runnerToolCacheParent, 0o755); + expect(fs.chownSync).toHaveBeenCalledWith(runnerToolCachePath, 1000, 1000); + expect(fs.chmodSync).toHaveBeenCalledWith(runnerToolCachePath, 0o755); + }); + + it('throws when runnerToolCachePath contains a pre-existing non-root-owned intermediate symlink', () => { + const realDir = path.join(tempDir, 'real-dir'); + const symlinkDir = path.join(tempDir, 'link-to-real'); + fs.mkdirSync(realDir, { recursive: true }); + fs.symlinkSync(realDir, symlinkDir); + const runnerToolCachePath = path.join(symlinkDir, 'child'); + (getRealUserHome as jest.Mock).mockReturnValue(tempDir); + + const config = buildConfig({ runnerToolCachePath }); + const logPaths = resolveLogPaths(config); + + expect(() => prepareWorkDirectories(config, logPaths)).toThrow( + `Refusing to use symlink as directory: ${symlinkDir}` + ); + }); + + it('prepares chroot mountpoint for fallback runner tool cache under home', () => { + const runnerToolCachePath = path.join(tempDir, 'work', '_tool'); + fs.mkdirSync(runnerToolCachePath, { recursive: true }); + + const savedRunnerToolCache = process.env.RUNNER_TOOL_CACHE; + delete process.env.RUNNER_TOOL_CACHE; + try { + const config = buildConfig(); + const logPaths = resolveLogPaths(config); + + prepareWorkDirectories(config, logPaths); + } finally { + if (savedRunnerToolCache !== undefined) { + process.env.RUNNER_TOOL_CACHE = savedRunnerToolCache; + } + } + + const chrootWorkDir = path.join(`${tempDir}-chroot-home`, 'work'); + const chrootToolCacheDir = path.join(chrootWorkDir, '_tool'); + expect(fs.existsSync(chrootToolCacheDir)).toBe(true); + expect(fs.statSync(chrootToolCacheDir).isDirectory()).toBe(true); + expect(fs.chownSync).toHaveBeenCalledWith(chrootWorkDir, 1000, 1000); + expect(fs.chmodSync).toHaveBeenCalledWith(chrootWorkDir, 0o755); + expect(fs.chownSync).toHaveBeenCalledWith(chrootToolCacheDir, 1000, 1000); + expect(fs.chmodSync).toHaveBeenCalledWith(chrootToolCacheDir, 0o755); + }); + }); +}); diff --git a/src/workdir-setup.ts b/src/workdir-setup.ts new file mode 100644 index 000000000..6ba72308a --- /dev/null +++ b/src/workdir-setup.ts @@ -0,0 +1,284 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { WrapperConfig } from './types'; +import { logger } from './logger'; +import { getSafeHostUid, getSafeHostGid, getRealUserHome } from './host-env'; +import { LogPaths } from './log-paths'; +import { resolveRunnerToolCachePath } from './runner-tool-cache'; + +interface EnsureDirectoryOptions { + mode?: number; + onCreate?: () => void; + onExists?: () => void; + onAfterEnsure?: () => void; +} + +function ensureDirectory(dirPath: string, options: EnsureDirectoryOptions = {}): boolean { + const { mode, onCreate, onExists, onAfterEnsure } = options; + const created = Boolean( + fs.mkdirSync(dirPath, mode === undefined ? { recursive: true } : { recursive: true, mode }) + ); + + const lstat = fs.lstatSync(dirPath); + if (lstat.isSymbolicLink()) { + throw new Error(`Refusing to use symlink as directory: ${dirPath}`); + } + + const stat = fs.statSync(dirPath); + if (!stat.isDirectory()) { + throw new Error(`Expected directory but found non-directory path: ${dirPath}`); + } + + if (created) { + onCreate?.(); + } else { + onExists?.(); + } + + onAfterEnsure?.(); + return created; +} + +function assertRealDirectory(dirPath: string): void { + const lstat = fs.lstatSync(dirPath); + if (lstat.isSymbolicLink()) { + throw new Error(`Refusing to use symlink as directory: ${dirPath}`); + } + + const stat = fs.statSync(dirPath); + if (!stat.isDirectory()) { + throw new Error(`Expected directory but found non-directory path: ${dirPath}`); + } +} + +function createMissingOwnedDirectorySegments(dirPath: string, uid: number, gid: number): void { + let currentPath = path.isAbsolute(dirPath) + ? path.parse(dirPath).root + : ''; + const segments = dirPath.split(path.sep).filter(Boolean); + + for (const segment of segments) { + currentPath = currentPath ? path.join(currentPath, segment) : segment; + let created = false; + if (!fs.existsSync(currentPath)) { + fs.mkdirSync(currentPath); + created = true; + } + + // Validate the current segment is a directory. Allow root-owned system symlinks + // (e.g. /var on macOS) but refuse user-controlled symlinks. + const lstat = fs.lstatSync(currentPath); + if (lstat.isSymbolicLink() && (created || lstat.uid !== 0)) { + throw new Error(`Refusing to use symlink as directory: ${currentPath}`); + } + const stat = fs.statSync(currentPath); + if (!stat.isDirectory()) { + throw new Error(`Expected directory but found non-directory path: ${currentPath}`); + } + + if (created) { + fs.chownSync(currentPath, uid, gid); + fs.chmodSync(currentPath, 0o755); + } + } +} + +// Prepare a nested bind-mount destination inside the empty chroot home before +// Docker sees it. Without this, Docker may create intermediate parents such as +// `/work` as root-owned directories with restrictive traversal bits, +// causing the chrooted runner user to get EACCES before reaching the leaf mount. +// This operates only on the chroot-home placeholder path, e.g. +// `/work/_tool`; it does not chown or chmod the real host source +// `/home/runner/work/_tool`, which Docker will later mount over the placeholder. +function prepareChrootHomeMountpoint(emptyHomeDir: string, relativeMountPath: string, uid: number, gid: number): string { + let chrootPath = emptyHomeDir; + const segments = relativeMountPath.split(path.sep).filter(Boolean); + + for (const segment of segments) { + chrootPath = path.join(chrootPath, segment); + if (!fs.existsSync(chrootPath)) { + fs.mkdirSync(chrootPath); + } + + // The final segment may be the leaf mountpoint (`_tool`). That is okay: this + // is still only the placeholder inside emptyHomeDir, not the host tool cache. + assertRealDirectory(chrootPath); + fs.chownSync(chrootPath, uid, gid); + fs.chmodSync(chrootPath, 0o755); + } + + return chrootPath; +} + +/** + * Prepares all working directories required before container startup. + * + * Concern 1 — log/state directories: creates and chowns agent-logs, + * session-state, squid-logs, api-proxy-logs, cli-proxy-logs, and the host + * MCP logs directory. + * + * Concern 2 — chroot home bind-mount preparation: creates the emptyHomeDir + * placeholder, all whitelisted ~/.* subdirectories on the host, and any + * runner tool-cache mountpoints so Docker does not create them as root-owned. + */ +export function prepareWorkDirectories(config: WrapperConfig, logPaths: LogPaths): void { + // ── Concern 1: log / state directory setup ────────────────────────────── + + // Create agent logs directory for persistence + // Chown to host user so Copilot CLI can write logs (AWF runs as root, agent runs as host user) + ensureDirectory(logPaths.agentLogs, { + onAfterEnsure: () => { + try { + fs.chownSync(logPaths.agentLogs, parseInt(getSafeHostUid()), parseInt(getSafeHostGid())); + } catch { /* ignore chown failures in non-root context */ } + }, + }); + logger.debug(`Agent logs directory created at: ${logPaths.agentLogs}`); + + // Create agent session-state directory for persistence (events.jsonl, session data) + // If sessionStateDir is specified, write directly there (timeout-safe, predictable path) + // Otherwise, use workDir/agent-session-state (will be moved to /tmp after cleanup) + // Chown to host user so Copilot CLI can create session subdirs and write events.jsonl + ensureDirectory(logPaths.sessionState, { + onAfterEnsure: () => { + try { + fs.chownSync(logPaths.sessionState, parseInt(getSafeHostUid()), parseInt(getSafeHostGid())); + } catch { /* ignore chown failures in non-root context */ } + }, + }); + logger.debug(`Agent session-state directory created at: ${logPaths.sessionState}`); + + // Create squid logs directory for persistence + // If proxyLogsDir is specified, write directly there (timeout-safe) + // Otherwise, use workDir/squid-logs (will be moved to /tmp after cleanup) + // Note: Squid runs as user 'proxy' (UID 13, GID 13 in ubuntu/squid image) + // We need to make the directory writable by the proxy user + // Squid container runs as non-root 'proxy' user (UID 13, GID 13) + // Set ownership so proxy user can write logs without root privileges + const SQUID_PROXY_UID = 13; + const SQUID_PROXY_GID = 13; + ensureDirectory(logPaths.squidLogs, { + mode: 0o755, + onCreate: () => { + try { + fs.chownSync(logPaths.squidLogs, SQUID_PROXY_UID, SQUID_PROXY_GID); + } catch { + // Fallback to world-writable if chown fails (e.g., non-root context) + fs.chmodSync(logPaths.squidLogs, 0o777); + } + }, + }); + logger.debug(`Squid logs directory created at: ${logPaths.squidLogs}`); + + // Create api-proxy logs directory for persistence + // If proxyLogsDir is specified, write inside it as a subdirectory (timeout-safe, + // and included in the firewall-audit-logs artifact upload automatically) + // Otherwise, write to workDir/api-proxy-logs (will be moved to /tmp after cleanup) + // Note: API proxy runs as user 'apiproxy' (non-root) + ensureDirectory(logPaths.apiProxyLogs, { + mode: 0o777, + onCreate: () => { + // Explicitly set permissions to 0o777 (not affected by umask) + fs.chmodSync(logPaths.apiProxyLogs, 0o777); + }, + }); + logger.debug(`API proxy logs directory created at: ${logPaths.apiProxyLogs}`); + + // Create CLI proxy logs directory for persistence + // Note: CLI proxy runs as user 'cliproxy' (non-root) + ensureDirectory(logPaths.cliProxyLogs, { + mode: 0o777, + onCreate: () => fs.chmodSync(logPaths.cliProxyLogs, 0o777), + }); + logger.debug(`CLI proxy logs directory created at: ${logPaths.cliProxyLogs}`); + + // Create /tmp/gh-aw/mcp-logs directory + // This directory exists on the HOST for MCP gateway to write logs + // Inside the AWF container, it's hidden via tmpfs mount (see generateDockerCompose) + // Uses mode 0o777 to allow GitHub Actions workflows and MCP gateway to create subdirectories + // even when AWF runs as root (e.g., sudo awf) + const mcpLogsDir = '/tmp/gh-aw/mcp-logs'; + if (ensureDirectory(mcpLogsDir, { mode: 0o777 })) { + // Explicitly set permissions to 0o777 (not affected by umask) + fs.chmodSync(mcpLogsDir, 0o777); + logger.debug(`MCP logs directory created at: ${mcpLogsDir}`); + } else { + // Fix permissions if directory already exists (e.g., created by a previous run) + fs.chmodSync(mcpLogsDir, 0o777); + logger.debug(`MCP logs directory permissions fixed at: ${mcpLogsDir}`); + } + + // ── Concern 2: chroot home bind-mount preparation ──────────────────────── + + // Ensure chroot home subdirectories exist with correct ownership before Docker + // bind-mounts them. If a source directory doesn't exist, Docker creates it as + // root:root, making it inaccessible to the agent user (e.g., UID 1001). + // Also create an empty writable home directory that gets mounted as $HOME + // in the chroot, giving tools a writable home without exposing credentials. + { + const effectiveHome = getRealUserHome(); + const uid = parseInt(getSafeHostUid(), 10); + const gid = parseInt(getSafeHostGid(), 10); + + // Create empty writable home directory for the chroot + // This is mounted as $HOME inside the container so tools can write to it + // NOTE: Must be outside workDir to avoid being hidden by the tmpfs overlay + const emptyHomeDir = `${config.workDir}-chroot-home`; + if (!fs.existsSync(emptyHomeDir)) { + fs.mkdirSync(emptyHomeDir, { recursive: true }); + } + fs.chownSync(emptyHomeDir, uid, gid); + logger.debug(`Created chroot home directory: ${emptyHomeDir} (${uid}:${gid})`); + + // Ensure source directories for home subdirectory mounts exist with correct ownership. + const hostHomeMountSourceDirs = [ + '.copilot', '.cache', '.config', '.local', + '.anthropic', '.claude', '.cargo', '.rustup', '.npm', '.nvm', + ...(config.geminiApiKey ? ['.gemini'] : []), + ]; + for (const dir of hostHomeMountSourceDirs) { + const dirPath = path.join(effectiveHome, dir); + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + fs.chownSync(dirPath, uid, gid); + logger.debug(`Created host home subdirectory: ${dirPath} (${uid}:${gid})`); + } + } + + // Source-side prep: this only applies when the config file explicitly names + // a runner tool-cache source path that does not exist yet. Create that host + // source so Docker has something real to bind-mount later. + if (config.runnerToolCachePath && !fs.existsSync(config.runnerToolCachePath)) { + const relToHome = path.relative(effectiveHome, config.runnerToolCachePath); + const isUnderHome = relToHome && !relToHome.startsWith('..') && !path.isAbsolute(relToHome); + + if (isUnderHome) { + createMissingOwnedDirectorySegments(config.runnerToolCachePath, uid, gid); + logger.debug(`Created runner tool cache directory: ${config.runnerToolCachePath} (${uid}:${gid})`); + } else { + logger.warn(`Runner tool cache path does not exist; refusing to create outside effective home (${effectiveHome}): ${config.runnerToolCachePath}`); + } + } + + // Destination-side prep: resolve the same source path that home-strategy.ts + // will mount. If that source is nested under the empty chroot home, prepare + // the placeholder mountpoint there so Docker does not create parents as root. + const runnerToolCachePath = resolveRunnerToolCachePath(config, effectiveHome); + if (runnerToolCachePath) { + const relativeToolCachePath = path.relative(effectiveHome, runnerToolCachePath); + if (relativeToolCachePath && !relativeToolCachePath.startsWith('..') && !path.isAbsolute(relativeToolCachePath)) { + const chrootToolCachePath = prepareChrootHomeMountpoint(emptyHomeDir, relativeToolCachePath, uid, gid); + logger.debug(`Prepared chroot runner tool cache mountpoint: ${chrootToolCachePath} (${uid}:${gid})`); + } + } + } +} + +/** @internal Exposed only for unit tests — not part of the public API. */ +// ts-prune-ignore-next +export const workdirSetupTestHelpers = { + ensureDirectory, + assertRealDirectory, + createMissingOwnedDirectorySegments, + prepareChrootHomeMountpoint, +}; From e3368e36ef8bbda74f46c6b02d50dbcb077d0aaf Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Sat, 13 Jun 2026 13:30:15 -0700 Subject: [PATCH 3/4] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/workdir-setup.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/workdir-setup.ts b/src/workdir-setup.ts index 6ba72308a..299009336 100644 --- a/src/workdir-setup.ts +++ b/src/workdir-setup.ts @@ -129,7 +129,7 @@ export function prepareWorkDirectories(config: WrapperConfig, logPaths: LogPaths ensureDirectory(logPaths.agentLogs, { onAfterEnsure: () => { try { - fs.chownSync(logPaths.agentLogs, parseInt(getSafeHostUid()), parseInt(getSafeHostGid())); + fs.chownSync(logPaths.agentLogs, parseInt(getSafeHostUid(), 10), parseInt(getSafeHostGid(), 10)); } catch { /* ignore chown failures in non-root context */ } }, }); From 3794a350699852ccb0eeb1117514f74e51f4abb5 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Sat, 13 Jun 2026 13:30:26 -0700 Subject: [PATCH 4/4] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/workdir-setup.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/workdir-setup.ts b/src/workdir-setup.ts index 299009336..23001b34a 100644 --- a/src/workdir-setup.ts +++ b/src/workdir-setup.ts @@ -142,7 +142,7 @@ export function prepareWorkDirectories(config: WrapperConfig, logPaths: LogPaths ensureDirectory(logPaths.sessionState, { onAfterEnsure: () => { try { - fs.chownSync(logPaths.sessionState, parseInt(getSafeHostUid()), parseInt(getSafeHostGid())); + fs.chownSync(logPaths.sessionState, parseInt(getSafeHostUid(), 10), parseInt(getSafeHostGid(), 10)); } catch { /* ignore chown failures in non-root context */ } }, });