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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 120 additions & 1 deletion src/config-writer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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');
Expand Down
137 changes: 128 additions & 9 deletions src/config-writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand All @@ -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.`
);
}
}

Expand Down Expand Up @@ -386,6 +504,7 @@ export async function writeConfigs(config: WrapperConfig): Promise<void> {
const logPaths = resolveLogPaths(config);
prepareWorkDirectories(config, logPaths);
repairRunnerTempGhAwOwnership();
repairContainerWorkDirOwnership(config);

// Use fixed network configuration (network is created by host-iptables.ts)
const networkConfig: NetworkConfig = {
Expand Down
Loading