diff --git a/src/config-writer.test.ts b/src/config-writer.test.ts index 0644f2108..64b21caad 100644 --- a/src/config-writer.test.ts +++ b/src/config-writer.test.ts @@ -11,7 +11,7 @@ import * as path from 'path'; import execa from 'execa'; import { writeConfigs } from './config-writer'; import { isOpenSslAvailable } from './ssl-bump'; -import { getRealUserHome } from './host-identity'; +import { getRealUserHome, isNativeRootWithoutSudo } from './host-identity'; import { buildWriteConfig, setupConfigWriterTempDir, @@ -137,6 +137,125 @@ describe('writeConfigs', () => { ); }); + it('chowns the host workspace on native-root runners', async () => { + Object.defineProperty(process, 'getuid', { value: () => 0, configurable: true }); + (isNativeRootWithoutSudo as jest.Mock).mockReturnValue(true); + const workspaceDir = path.join(tempDir, 'workspace'); + process.env.GITHUB_WORKSPACE = workspaceDir; + fs.mkdirSync(workspaceDir, { recursive: true }); + fs.chmodSync(workspaceDir, 0o777); + + await writeConfigs(buildWriteConfig(tempDir, { containerWorkDir: workspaceDir })); + + expect(execa.sync).toHaveBeenCalledWith( + 'chown', + ['-h', '-P', '-R', '--', '1000:1000', workspaceDir], + expect.objectContaining({ reject: false }) + ); + }); + + it('chowns the host workspace when the container workdir is beneath it', async () => { + Object.defineProperty(process, 'getuid', { value: () => 0, configurable: true }); + (isNativeRootWithoutSudo as jest.Mock).mockReturnValue(true); + const workspaceDir = path.join(tempDir, 'workspace'); + const containerWorkDir = path.join(workspaceDir, 'packages', 'app'); + process.env.GITHUB_WORKSPACE = workspaceDir; + fs.mkdirSync(containerWorkDir, { recursive: true }); + fs.chmodSync(workspaceDir, 0o777); + + await writeConfigs(buildWriteConfig(tempDir, { containerWorkDir })); + + expect(execa.sync).toHaveBeenCalledWith( + 'chown', + ['-h', '-P', '-R', '--', '1000:1000', workspaceDir], + expect.objectContaining({ reject: false }) + ); + }); + + it('throws when the host workspace stays unwritable by the sandbox identity', async () => { + Object.defineProperty(process, 'getuid', { value: () => 0, configurable: true }); + (isNativeRootWithoutSudo as jest.Mock).mockReturnValue(true); + const workspaceDir = path.join(tempDir, 'workspace'); + process.env.GITHUB_WORKSPACE = workspaceDir; + fs.mkdirSync(workspaceDir, { recursive: true }); + fs.chmodSync(workspaceDir, 0o755); + + await expect( + writeConfigs(buildWriteConfig(tempDir, { containerWorkDir: workspaceDir })) + ).rejects.toThrow( + `Host workspace is not writable by the sandbox identity (1000:1000): ${workspaceDir}` + ); + }); + + it.each(['/tmp', '/etc', '/'])( + 'does not chown an unrelated custom container workdir (%s)', + async containerWorkDir => { + Object.defineProperty(process, 'getuid', { value: () => 0, configurable: true }); + (isNativeRootWithoutSudo as jest.Mock).mockReturnValue(true); + const workspaceDir = path.join(tempDir, 'workspace'); + process.env.GITHUB_WORKSPACE = workspaceDir; + fs.mkdirSync(workspaceDir, { recursive: true }); + + await writeConfigs(buildWriteConfig(tempDir, { containerWorkDir })); + + expect(execa.sync).not.toHaveBeenCalledWith( + 'chown', + expect.arrayContaining([containerWorkDir]), + expect.anything() + ); + } + ); + + it('rejects a filesystem root as the host workspace', async () => { + Object.defineProperty(process, 'getuid', { value: () => 0, configurable: true }); + (isNativeRootWithoutSudo as jest.Mock).mockReturnValue(true); + process.env.GITHUB_WORKSPACE = '/'; + + await expect( + writeConfigs(buildWriteConfig(tempDir, { containerWorkDir: '/' })) + ).rejects.toThrow('Refusing to repair ownership of filesystem root: /'); + + expect(execa.sync).not.toHaveBeenCalledWith( + 'chown', + expect.arrayContaining(['/']), + expect.anything() + ); + }); + + it('rejects a host workspace symlink that resolves to a filesystem root', async () => { + Object.defineProperty(process, 'getuid', { value: () => 0, configurable: true }); + (isNativeRootWithoutSudo as jest.Mock).mockReturnValue(true); + const workspaceDir = path.join(tempDir, 'workspace-root'); + fs.symlinkSync('/', workspaceDir); + process.env.GITHUB_WORKSPACE = workspaceDir; + + await expect( + writeConfigs(buildWriteConfig(tempDir, { containerWorkDir: workspaceDir })) + ).rejects.toThrow('Refusing to repair ownership of filesystem root: /'); + + expect(execa.sync).not.toHaveBeenCalledWith( + 'chown', + expect.arrayContaining(['/']), + expect.anything() + ); + }); + + it('does not touch the host workspace when not running as native root', async () => { + (isNativeRootWithoutSudo as jest.Mock).mockReturnValue(false); + const workspaceDir = path.join(tempDir, 'workspace'); + process.env.GITHUB_WORKSPACE = workspaceDir; + fs.mkdirSync(workspaceDir, { recursive: true }); + fs.chmodSync(workspaceDir, 0o755); + + await writeConfigs(buildWriteConfig(tempDir, { containerWorkDir: workspaceDir })); + + expect(execa.sync).not.toHaveBeenCalledWith( + 'chown', + ['-h', '-P', '-R', '--', '1000:1000', workspaceDir], + expect.anything() + ); + }); + it('throws when workDir path exists but is not a directory', async () => { const filePath = path.join(tempDir, 'not-a-directory'); fs.writeFileSync(filePath, 'content'); diff --git a/src/config-writer.ts b/src/config-writer.ts index e5ba8b3ab..a79e78f0b 100644 --- a/src/config-writer.ts +++ b/src/config-writer.ts @@ -13,7 +13,7 @@ import { generateDockerCompose, redactDockerComposeSecrets } from './compose-gen import { deriveSensitiveEndpointForms, redactSensitiveValues } from './redact-secrets'; import { resolveLogPaths } from './log-paths'; import { DEFAULT_DNS_SERVERS, filterForNetworkIsolation } from './dns-resolver'; -import { getSafeHostGid, getSafeHostUid } from './host-identity'; +import { getSafeHostGid, getSafeHostUid, isNativeRootWithoutSudo } from './host-identity'; import { AGENT_IP, API_PROXY_IP, @@ -104,6 +104,33 @@ function chownTreeWithoutFollowingSymlink(targetPath: string, uid: number, gid: } } +/** + * Resolves the sandbox identity used inside the agent container, or null when + * it is not a usable unprivileged identity. + */ +function resolveSandboxIdentity(): { uid: number; gid: number } | null { + const uid = Number.parseInt(getSafeHostUid(), 10); + const gid = Number.parseInt(getSafeHostGid(), 10); + if (!Number.isInteger(uid) || !Number.isInteger(gid) || uid <= 0 || gid <= 0) { + return null; + } + return { uid, gid }; +} + +/** + * Transfers ownership of a host path to the sandbox identity, logging (rather + * than throwing) when the repair cannot be completed. + */ +function repairPathOwnership(targetPath: string, uid: number, gid: number): void { + try { + chownTreeWithoutFollowingSymlink(targetPath, uid, gid); + logger.debug(`Transferred ${targetPath} ownership to sandbox user (${uid}:${gid}) before container launch`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.warn(`Failed to transfer ${targetPath} ownership to sandbox user (${uid}:${gid}): ${message}`); + } +} + function repairRunnerTempGhAwOwnership(): void { if (process.getuid?.() !== 0) { return; @@ -119,19 +146,110 @@ function repairRunnerTempGhAwOwnership(): void { return; } - const uid = Number.parseInt(getSafeHostUid(), 10); - const gid = Number.parseInt(getSafeHostGid(), 10); - if (!Number.isInteger(uid) || !Number.isInteger(gid) || uid <= 0 || gid <= 0) { + const identity = resolveSandboxIdentity(); + if (!identity) { logger.warn(`Skipping ${ghAwRoot} ownership repair because the sandbox identity is invalid`); return; } + repairPathOwnership(ghAwRoot, identity.uid, identity.gid); +} + +/** + * Reports whether the sandbox identity can create entries in `targetDir`. + * + * The host process usually runs as root, which bypasses permission checks, so + * `fs.accessSync` cannot answer this question. The mode bits are inspected + * directly instead: the sandbox identity has no supplementary groups inside the + * container, so owner/group/other is the complete picture. + */ +function isDirectoryWritableByIdentity(targetDir: string, uid: number, gid: number): boolean { + let stat: fs.Stats; try { - chownTreeWithoutFollowingSymlink(ghAwRoot, uid, gid); - logger.debug(`Transferred ${ghAwRoot} ownership to sandbox user (${uid}:${gid}) before container launch`); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - logger.warn(`Failed to transfer ${ghAwRoot} ownership to sandbox user (${uid}:${gid}): ${message}`); + stat = fs.statSync(targetDir); + } catch { + return false; + } + + if (!stat.isDirectory()) { + return false; + } + + // Write + search permission are both required to create entries in a directory. + if (stat.uid === uid) { + return (stat.mode & 0o300) === 0o300; + } + if (stat.gid === gid) { + return (stat.mode & 0o030) === 0o030; + } + return (stat.mode & 0o003) === 0o003; +} + +/** + * Repairs ownership of the host workspace mount and verifies the sandbox + * identity can write to it. + * + * On native-root runners (root with no `SUDO_UID`, e.g. AWS CodeBuild-hosted + * runners) the checkout is root-owned while the agent runs as the fallback + * sandbox identity, so the workdir is writable by mount but not by ownership. + * Without this repair the agent silently fails to write and the job reports a + * false green. + */ +function repairContainerWorkDirOwnership(config: WrapperConfig): void { + const containerWorkDir = config.containerWorkDir; + if (!containerWorkDir || !path.isAbsolute(containerWorkDir)) { + return; + } + + if (!isNativeRootWithoutSudo()) { + return; + } + + const workspaceDir = process.env.GITHUB_WORKSPACE || process.cwd(); + if (!path.isAbsolute(workspaceDir)) { + return; + } + + const normalizedWorkspaceDir = path.resolve(workspaceDir); + const normalizedContainerWorkDir = path.resolve(containerWorkDir); + const relativeWorkDir = path.relative(normalizedWorkspaceDir, normalizedContainerWorkDir); + const workDirUsesWorkspaceMount = + relativeWorkDir === '' || + (relativeWorkDir !== '..' && + !relativeWorkDir.startsWith(`..${path.sep}`) && + !path.isAbsolute(relativeWorkDir)); + if (!workDirUsesWorkspaceMount) { + return; + } + + if (!fs.existsSync(normalizedWorkspaceDir)) { + return; + } + + const canonicalWorkspaceDir = fs.realpathSync(normalizedWorkspaceDir); + if (canonicalWorkspaceDir === path.parse(canonicalWorkspaceDir).root) { + throw new Error(`Refusing to repair ownership of filesystem root: ${canonicalWorkspaceDir}`); + } + + const identity = resolveSandboxIdentity(); + if (!identity) { + logger.warn(`Skipping ${canonicalWorkspaceDir} ownership repair because the sandbox identity is invalid`); + return; + } + + repairPathOwnership(canonicalWorkspaceDir, identity.uid, identity.gid); + + if (!isDirectoryWritableByIdentity(canonicalWorkspaceDir, identity.uid, identity.gid)) { + throw new Error( + `Host workspace is not writable by the sandbox identity ` + + `(${identity.uid}:${identity.gid}): ${canonicalWorkspaceDir}\n` + + `AWF is running as root without SUDO_UID, so it attempted to transfer ` + + `ownership of the host workspace to the sandbox identity, but the ` + + `workspace is still not writable.\n` + + `The agent would start and exit successfully without being able to write ` + + `any files, so AWF is failing early instead.\n` + + ` Suggested fix: chown -R ${identity.uid}:${identity.gid} ${canonicalWorkspaceDir} before invoking AWF.` + ); } } @@ -386,6 +504,7 @@ export async function writeConfigs(config: WrapperConfig): Promise { const logPaths = resolveLogPaths(config); prepareWorkDirectories(config, logPaths); repairRunnerTempGhAwOwnership(); + repairContainerWorkDirOwnership(config); // Use fixed network configuration (network is created by host-iptables.ts) const networkConfig: NetworkConfig = {