Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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
2 changes: 1 addition & 1 deletion .github/workflows/test-integration-suite.yml
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ jobs:
run: |
echo "=== Running container & ops tests ==="
npm run test:integration -- \
--testPathPatterns="(container-workdir|environment-variables|error-handling|exit-code-propagation|log-commands|no-docker|volume-mounts|skip-pull)" \
--testPathPatterns="(container-workdir|environment-variables|error-handling|exit-code-propagation|filesystem-allowwrite|log-commands|no-docker|volume-mounts|skip-pull)" \
--verbose
env:
JEST_TIMEOUT: 180000
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ The codebase follows a modular architecture with clear separation of concerns:
- Selective bind mounts under `/host/`: system binaries `/usr`, `/bin`, `/sbin`, `/lib`, `/lib64`, `/opt`, `/sys`, `/dev` (ro); workspace and `/tmp` (rw); whitelisted `$HOME` subdirs (rw); select `/etc` files — NOT a blanket host FS mount; `/etc/shadow`, unwhitelisted home dirs, and most of `/etc` are excluded
- `entrypoint.sh` handles: UID/GID remapping → DNS config → SSL CA import → chroot to `/host` → capability drop → run user command as host user
- **procfs mount**: A container-scoped procfs is mounted at `/host/proc` with `hidepid=2` to support runtimes that read `/proc/self/exe` (Java, .NET) while preventing the agent from reading other processes' `/proc/[pid]/environ` (credential isolation)
- **iptables init container** (`awf-iptables-init`): separate container sharing agent's network namespace via `network_mode: service:agent`. Runs `setup-iptables.sh` to configure NAT rules before user command starts. Agent waits for `/tmp/awf-init/ready` signal file.
- **iptables init container** (`awf-iptables-init`): separate container sharing agent's network namespace via `network_mode: service:agent`. Runs `setup-iptables.sh` to configure NAT rules before user command starts. Agent waits for `/run/awf-init/ready` signal file (also accepting the legacy `/tmp/awf-init/ready` so an older pinned agent image still works).
- Key iptables rules (in `setup-iptables.sh`):
- Allow localhost (for stdio MCP servers) and DNS
- Allow traffic to Squid proxy itself
Expand Down
152 changes: 88 additions & 64 deletions containers/agent/entrypoint.sh

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions containers/agent/one-shot-token/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,8 +174,8 @@ exec capsh --drop=$CAPS_TO_DROP -- -c "exec gosu awfuser $COMMAND"

In chroot mode, the library must be accessible from within the chroot (host filesystem). The entrypoint:

1. Copies the library from container to `/host/tmp/awf-lib/one-shot-token.so`
2. Sets `LD_PRELOAD=/tmp/awf-lib/one-shot-token.so` inside the chroot
1. Copies the library from container to `/host/run/awf-lib/one-shot-token.so`
2. Sets `LD_PRELOAD=/run/awf-lib/one-shot-token.so` inside the chroot
3. Cleans up the library on exit

## Building
Expand Down
6 changes: 5 additions & 1 deletion containers/agent/setup-iptables.sh
Original file line number Diff line number Diff line change
Expand Up @@ -497,7 +497,11 @@ dump_nat_rules_for_debugging() {
dump_audit_state() {
# Dump full iptables state for audit trail
# Written to the init signal volume so it can be preserved by the host
local audit_file="/tmp/awf-init/iptables-audit.txt"
local audit_dir="${AWF_INIT_SIGNAL_DIR:-/run/awf-init}"
if [ ! -d "$audit_dir" ] && [ -d /tmp/awf-init ]; then
audit_dir="/tmp/awf-init"
fi
local audit_file="${audit_dir}/iptables-audit.txt"
echo "# iptables audit dump - $(date -u '+%Y-%m-%dT%H:%M:%SZ')" > "$audit_file"
echo "" >> "$audit_file"
echo "## IPv4 NAT rules" >> "$audit_file"
Expand Down
2 changes: 1 addition & 1 deletion docs/chroot-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ When `chroot.binariesSourcePath` is set in stdin config, AWF also mounts:

**Note:** As of v0.13.13, `/proc` is no longer bind-mounted. Instead, a fresh container-scoped procfs is mounted at `/host/proc` during entrypoint initialization. This provides dynamic `/proc/self/exe` resolution required by Java and .NET runtimes.

**System CA Bundle Detection:** The entrypoint automatically detects the host system CA bundle from common locations (Debian/Ubuntu `/etc/ssl/certs/ca-certificates.crt`, RHEL/Amazon Linux `/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem`, `/etc/pki/tls/certs/ca-bundle.crt`, `/etc/pki/tls/cert.pem`, macOS `/etc/ssl/cert.pem`). If the bundle is not already accessible in the chroot via the mounted CA paths, it is copied to `/tmp/awf-lib/system-ca-certificates.crt` and `SSL_CERT_FILE`, `NODE_EXTRA_CA_CERTS`, `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, and `GIT_SSL_CAINFO` are set to point at it.
**System CA Bundle Detection:** The entrypoint automatically detects the host system CA bundle from common locations (Debian/Ubuntu `/etc/ssl/certs/ca-certificates.crt`, RHEL/Amazon Linux `/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem`, `/etc/pki/tls/certs/ca-bundle.crt`, `/etc/pki/tls/cert.pem`, macOS `/etc/ssl/cert.pem`). If the bundle is not already accessible in the chroot via the mounted CA paths, it is copied to `/run/awf-lib/system-ca-certificates.crt` and `SSL_CERT_FILE`, `NODE_EXTRA_CA_CERTS`, `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, and `GIT_SSL_CAINFO` are set to point at it.

### Read-Write Mounts

Expand Down
87 changes: 87 additions & 0 deletions src/agent-helper-staging.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import * as fs from 'fs';
import * as path from 'path';

const entrypointSource = fs.readFileSync(
path.resolve(__dirname, '../containers/agent/entrypoint.sh'),
'utf8',
);

/**
* AWF stages several helpers on the host side of the chroot before the agent
* runs: the one-shot-token LD_PRELOAD library, the gh CLI proxy wrapper, the
* Claude key helper and CA bundles.
*
* These used to live under `/tmp/awf-lib`. That is a bind mount of the host's
* `/tmp`, so `filesystem.allowWrite` can narrow it to read-only, at which point
* every one of those copies fails. The failures were swallowed, silently
* disabling security controls while the run appeared healthy.
*
* They now stage under `/run/awf-lib`, which lives on the container's own
* writable rootfs and is therefore never affected by a user write policy.
*/
describe('agent helper staging location', () => {
it('never stages helpers under the host-bound /tmp', () => {
expect(entrypointSource).not.toContain('/tmp/awf-lib');
});

it('stages helpers under /run/awf-lib on both sides of the chroot', () => {
expect(entrypointSource).toContain('mkdir -p /host/run/awf-lib');
expect(entrypointSource).toContain('/host/run/awf-lib/one-shot-token.so');
expect(entrypointSource).toContain('/host/run/awf-lib/gh');
});

it('preloads the one-shot token library from the chroot-visible path', () => {
expect(entrypointSource).toContain('LD_PRELOAD=/run/awf-lib/one-shot-token.so');
});

it('puts the staging directory on PATH so the gh wrapper shadows the host gh', () => {
expect(entrypointSource).toContain('export AWF_HOST_PATH="/run/awf-lib:${AWF_HOST_PATH:-$PATH}"');
expect(entrypointSource).toContain('export PATH="/run/awf-lib:${PATH}"');
});

it('cleans up the staging directory at its new location', () => {
expect(entrypointSource).toContain('rm -rf /run/awf-lib');
});

it('no longer claims /tmp is always writable', () => {
expect(entrypointSource).not.toContain('/tmp is mounted read-write in chroot mode');
expect(entrypointSource).not.toMatch(/\/tmp\/awf-lib\/ \(always writable\)/);
});

describe('fail-closed behaviour', () => {
function functionBody(name: string): string {
const start = entrypointSource.indexOf(`${name}() {`);
expect(start).toBeGreaterThan(-1);
const end = entrypointSource.indexOf('\n}\n', start);
return entrypointSource.slice(start, end);
}

it('refuses to start when the one-shot token library cannot be staged', () => {
const body = functionBody('copy_preload_libs');

expect(body).toContain('Refusing to start without one-shot token protection');
// Both the mkdir and the copy failure paths must abort.
expect(body.match(/exit 1/g) ?? []).toHaveLength(2);
expect(body).not.toContain('Token protection will be disabled (tokens may be readable multiple times)\n fi');
});

it('still tolerates a host libc that cannot load the library', () => {
// An incompatible dynamic linker (musl/Alpine) is an environment property,
// not a staging failure, and must stay a warning.
const body = functionBody('copy_preload_libs');

expect(body).toContain('host libc incompatibility');
expect(body).toContain('Token protection will be disabled');
});

it('refuses to start with an unmediated gh CLI when the proxy is enabled', () => {
const occurrences = entrypointSource.match(
/Refusing to start with an unmediated gh CLI/g,
) ?? [];

// Once for the chroot path, once for the non-chroot path.
expect(occurrences).toHaveLength(2);
expect(entrypointSource).not.toContain('[entrypoint][WARN] Could not install gh CLI proxy wrapper');
});
});
});
13 changes: 13 additions & 0 deletions src/chroot-home-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,4 +116,17 @@ export function prepareChrootHomeMounts(config: WrapperConfig): void {
logger.debug(`Prepared chroot runner tool cache mountpoint: ${chrootToolCachePath} (${uid}:${gid})`);
}
}

// On GitHub-hosted runners the workspace lives under $HOME
// (`/home/runner/work/<repo>/<repo>`), so its `/host`-prefixed bind lands
// inside the chroot home. Docker used to create those parents itself, but
// once filesystem.allowWrite narrows the chroot home bind to read-only runc
// can no longer do so and container init fails with EROFS. Prepare the
// mountpoint up front, exactly as for the tool-cache paths above.
const workspaceDir = process.env.GITHUB_WORKSPACE || process.cwd();
const relativeWorkspacePath = path.relative(effectiveHome, workspaceDir);
if (relativeWorkspacePath && !relativeWorkspacePath.startsWith('..') && !path.isAbsolute(relativeWorkspacePath)) {
const chrootWorkspacePath = prepareChrootHomeMountpoint(emptyHomeDir, relativeWorkspacePath, uid, gid);
logger.debug(`Prepared chroot workspace mountpoint: ${chrootWorkspacePath} (${uid}:${gid})`);
}
}
2 changes: 2 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export const CLI_PROXY_CONTAINER_NAME = 'awf-cli-proxy';
export const ENCLAVE_MCP_SERVER_CONTAINER_NAME = 'awf-enclave-mcp-server';
export const ENCLAVE_AGENT_API_PROXY_CONTAINER_NAME = 'awf-enclave-agent-api-proxy';
export const LOCAL_ENCLAVE_MCP_SERVER_IMAGE = 'awf-enclave-mcp-server:local';
export const INIT_SIGNAL_DIR = '/run/awf-init';
export const LEGACY_INIT_SIGNAL_DIR = '/tmp/awf-init';

// SQUID_PORT is centralized in src/config/sandbox-network-policy.json and
// re-exported here so existing import sites keep working unchanged.
Expand Down
25 changes: 24 additions & 1 deletion src/fs-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,30 @@ export function createMissingOwnedDirectorySegments(dirPath: string, uid: number
}

if (created) {
fs.chownSync(currentPath, uid, gid);
// Only chown when ownership actually has to change, and only when this
// process could possibly succeed. A freshly created directory already
// belongs to the caller, so an owner-preserving chown is a no-op that
// macOS still denies to non-root; and a non-root caller can never hand a
// directory to a *different* owner, so attempting it only turns a usable
// directory into a hard EPERM failure. AWF runs privileged in production,
// where this is unchanged.
if (stat.uid !== uid || stat.gid !== gid) {
try {
fs.chownSync(currentPath, uid, gid);
} catch (error) {
// An unprivileged caller cannot hand a directory to another owner, so
// this can never succeed however often it is retried. Turning that
// into a hard failure would abort on a directory that is already
// usable, so tolerate exactly that case and nothing else. AWF runs
// privileged in production, where the chown still happens and still
// propagates any failure.
const code = (error as NodeJS.ErrnoException).code;
const unprivileged = process.getuid?.() !== 0;
if (!unprivileged || (code !== 'EPERM' && code !== 'EACCES')) {
throw error;
}
}
}
fs.chmodSync(currentPath, 0o755);
}
}
Expand Down
3 changes: 2 additions & 1 deletion src/services/agent-environment/core-environment.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { SQUID_PORT } from '../../constants';
import { INIT_SIGNAL_DIR, SQUID_PORT } from '../../constants';
import { getRealUserHome } from '../../host-identity';
import { AgentEnvironmentParams } from './types';

Expand All @@ -14,6 +14,7 @@ export function buildCoreEnvironment(params: AgentEnvironmentParams): Record<str
SQUID_PROXY_PORT: SQUID_PORT.toString(),
HOME: homeDir,
PATH: '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin',
AWF_INIT_SIGNAL_DIR: INIT_SIGNAL_DIR,
...(config.tty ? {
FORCE_COLOR: '1',
TERM: 'xterm-256color',
Expand Down
12 changes: 6 additions & 6 deletions src/services/agent-service-build.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ describe('agent service', () => {
expect(initService.entrypoint).toEqual(['/bin/bash']);
expect(initService.command).toEqual([
'-c',
'/usr/local/bin/setup-iptables.sh > /tmp/awf-init/output.log 2>&1 && touch /tmp/awf-init/ready',
'mkdir -p "$$AWF_INIT_SIGNAL_DIR" && /usr/local/bin/setup-iptables.sh > "$$AWF_INIT_SIGNAL_DIR/output.log" 2>&1 && touch "$$AWF_INIT_SIGNAL_DIR/ready"',
]);
expect(initService.security_opt).toBeUndefined();
expect(initService.restart).toBe('no');
Expand All @@ -136,8 +136,8 @@ describe('agent service', () => {
const initService = result.services['iptables-init'] as any;
const volumes = initService.volumes as string[];

// Source path is the runner-side init-signal dir, container path is /tmp/awf-init
expect(volumes).toContain(`${mockConfig.workDir}/init-signal:/tmp/awf-init:rw`);
// Source path is the runner-side init-signal dir, container path is outside /tmp.
expect(volumes).toContain(`${mockConfig.workDir}/init-signal:/run/awf-init:rw`);
});

it('should apply dockerHostPathPrefix to the iptables-init init-signal volume', () => {
Expand All @@ -158,10 +158,10 @@ describe('agent service', () => {
const agentVolumes = result.services.agent.volumes as string[];

const expectedSource = `/host${mockConfig.workDir}/init-signal`;
expect(initVolumes).toContain(`${expectedSource}:/tmp/awf-init:rw`);
expect(initVolumes).toContain(`${expectedSource}:/run/awf-init:rw`);

// The agent must mount the SAME daemon-side source so they share the ready file.
expect(agentVolumes).toContain(`${expectedSource}:/tmp/awf-init:rw`);
expect(agentVolumes).toContain(`${expectedSource}:/run/awf-init:rw`);
});

it('should normalize trailing slash in dockerHostPathPrefix for iptables-init mount', () => {
Expand All @@ -174,7 +174,7 @@ describe('agent service', () => {
const initService = result.services['iptables-init'] as any;
const initVolumes = initService.volumes as string[];

expect(initVolumes).toContain(`/host${mockConfig.workDir}/init-signal:/tmp/awf-init:rw`);
expect(initVolumes).toContain(`/host${mockConfig.workDir}/init-signal:/run/awf-init:rw`);
});

// Symmetric invariant: every absolute, non-kernel-virtual bind-mount source on every
Expand Down
27 changes: 24 additions & 3 deletions src/services/agent-service.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import * as path from 'path';
import {
AGENT_CONTAINER_NAME,
INIT_SIGNAL_DIR,
IPTABLES_INIT_CONTAINER_NAME,
LEGACY_INIT_SIGNAL_DIR,
SQUID_PORT,
} from '../constants';
import { ACT_PRESET_BASE_IMAGE, getSafeHostUid, getSafeHostGid } from '../host-identity';
Expand Down Expand Up @@ -292,13 +294,30 @@ interface IptablesInitServiceParams {
*/
export function buildIptablesInitService(params: IptablesInitServiceParams): any {
const { agentService, environment, networkConfig, initSignalDir, dockerHostPathPrefix, hostGatewayIp } = params;
const setupCommand = [
'mkdir -p "$$AWF_INIT_SIGNAL_DIR"',
'/usr/local/bin/setup-iptables.sh > "$$AWF_INIT_SIGNAL_DIR/output.log" 2>&1',
'touch "$$AWF_INIT_SIGNAL_DIR/ready"',
].join(' && ');

// The init-signal mount must use the same source path that the agent container uses,
// otherwise the two containers bind to different daemon-side directories and the
// ready-file handshake fails. buildAgentVolumes() applies dockerHostPathPrefix to its
// mounts, so do the same here via the shared helper.
const [initSignalMount] = applyHostPathPrefixToVolumes(
[`${initSignalDir}:/tmp/awf-init:rw`],
//
// The legacy path is bound here as well, and read-write. This container has its
// own mount namespace, so the agent-side legacy bind is invisible to it and
// cannot help. It matters because setup-iptables.sh in agent images released
// before the signal directory moved to /run writes its audit dump to a
// hardcoded LEGACY_INIT_SIGNAL_DIR path, under `set -e`: with the directory
// absent the redirection fails, the script exits non-zero before the `touch`
// above, and the agent waits out its full ready timeout and then fails. Both
// paths are the same host directory, so a new image simply ignores this one.
const [initSignalMount, legacyInitSignalMount] = applyHostPathPrefixToVolumes(
[
`${initSignalDir}:${INIT_SIGNAL_DIR}:rw`,
`${initSignalDir}:${LEGACY_INIT_SIGNAL_DIR}:rw`,
],
dockerHostPathPrefix,
);

Expand All @@ -312,6 +331,7 @@ export function buildIptablesInitService(params: IptablesInitServiceParams): any
// Only mount the init signal volume and the iptables setup script
volumes: [
initSignalMount,
legacyInitSignalMount,
],
environment: {
// Pass through environment variables needed by setup-iptables.sh
Expand All @@ -335,6 +355,7 @@ export function buildIptablesInitService(params: IptablesInitServiceParams): any
AWF_SSL_BUMP_ENABLED: environment.AWF_SSL_BUMP_ENABLED || '',
AWF_SSL_BUMP_INTERCEPT_PORT: environment.AWF_SSL_BUMP_INTERCEPT_PORT || '',
AWF_HOST_GATEWAY_IP: hostGatewayIp || '',
AWF_INIT_SIGNAL_DIR: INIT_SIGNAL_DIR,
},
depends_on: {
'agent': {
Expand All @@ -350,7 +371,7 @@ export function buildIptablesInitService(params: IptablesInitServiceParams): any
// The init container only needs to run setup-iptables.sh directly.
entrypoint: ['/bin/bash'],
// Run setup-iptables.sh then signal readiness; log output to shared volume for diagnostics
command: ['-c', '/usr/local/bin/setup-iptables.sh > /tmp/awf-init/output.log 2>&1 && touch /tmp/awf-init/ready'],
command: ['-c', setupCommand],
// Resource limits (init container exits quickly)
mem_limit: '128m',
pids_limit: 50,
Expand Down
6 changes: 6 additions & 0 deletions src/services/agent-volumes-basic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@ describe('agent service', () => {
expect(volumes).toContain(`${writablePath}:/host${writablePath}:rw`);
expect(volumes).toContain('/tmp:/tmp:ro');
expect(volumes).toContain('/tmp:/host/tmp:ro');
expect(volumes).toContain(`${getConfig().workDir}/init-signal:/run/awf-init:rw`);
// Legacy path stays exposed read-only so a newer CLI still works with an
// older pinned agent image, and it must not become a writable hole in the
// narrowed /tmp tree.
expect(volumes).toContain(`${getConfig().workDir}/init-signal:/tmp/awf-init:ro`);
expect(volumes).not.toContain(`${getConfig().workDir}/init-signal:/tmp/awf-init:rw`);
expect(volumes.some((volume) => volume.includes('/.copilot/logs:rw'))).toBe(true);
});

Expand Down
Loading
Loading