Skip to content

Commit eaa41fc

Browse files
authored
feat: add configurable agent base image for GitHub Actions runner parity (#214)
Squash merged with coverage regression acknowledged (pre-existing, documented in PR)
1 parent 19f924e commit eaa41fc

7 files changed

Lines changed: 426 additions & 17 deletions

File tree

containers/agent/Dockerfile

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,61 @@
1-
FROM ubuntu:22.04
1+
# BASE_IMAGE allows customization of the base Ubuntu image for closer parity
2+
# with GitHub Actions runner environments. Options:
3+
# - ubuntu:22.04 (default): Minimal image, smallest size (~200MB)
4+
# - ghcr.io/catthehacker/ubuntu:runner-22.04: Closer to GitHub Actions runner (~2-5GB)
5+
# - ghcr.io/catthehacker/ubuntu:full-22.04: Near-identical to GitHub Actions runner (~20GB compressed)
6+
# Use --build-arg BASE_IMAGE=<image> to customize
7+
ARG BASE_IMAGE=ubuntu:22.04
8+
FROM ${BASE_IMAGE}
29

310
# Install required packages and Node.js 22
11+
# Note: Some packages may already exist in runner-like base images, apt handles this gracefully
412
RUN apt-get update && \
513
apt-get install -y --no-install-recommends \
614
iptables \
715
curl \
816
ca-certificates \
917
git \
18+
gh \
1019
gnupg \
1120
dnsutils \
1221
net-tools \
1322
netcat-openbsd \
1423
gosu \
1524
libcap2-bin && \
25+
# Prefer system binaries over runner toolcache (e.g., act images) for Node checks.
26+
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$PATH" && \
1627
# Install Node.js 22 from NodeSource
17-
# Remove any existing nodejs packages first to avoid conflicts
18-
apt-get remove -y nodejs npm || true && \
19-
curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
20-
apt-get install -y nodejs && \
21-
# Verify Node.js 22 was installed correctly
22-
node --version | grep -q "^v22\." || (echo "ERROR: Node.js 22 not installed correctly" && exit 1) && \
23-
npx --version || (echo "ERROR: npx not found" && exit 1) && \
28+
# Check if Node.js 22 is already installed (common in runner images)
29+
if ! command -v node >/dev/null 2>&1 || ! node --version | grep -qE '^v22\.'; then \
30+
# Remove any existing nodejs packages first to avoid conflicts
31+
apt-get remove -y nodejs npm || true && \
32+
curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
33+
apt-get install -y nodejs && \
34+
# Verify Node.js 22 was installed correctly
35+
node --version | grep -q "^v22\." || (echo "ERROR: Node.js 22 not installed correctly" && exit 1) && \
36+
npx --version || (echo "ERROR: npx not found" && exit 1); \
37+
fi && \
2438
rm -rf /var/lib/apt/lists/*
2539

2640
# Create non-root user with UID/GID matching host user
2741
# This allows the user command to run with appropriate permissions
2842
# and prevents file ownership issues with mounted volumes
2943
ARG USER_UID=1000
3044
ARG USER_GID=1000
31-
RUN groupadd -g ${USER_GID} awfuser && \
32-
useradd -u ${USER_UID} -g ${USER_GID} -m -s /bin/bash awfuser && \
45+
RUN if ! getent group awfuser >/dev/null 2>&1; then \
46+
if ! getent group ${USER_GID} >/dev/null 2>&1; then \
47+
groupadd -g ${USER_GID} awfuser; \
48+
else \
49+
groupadd awfuser; \
50+
fi; \
51+
fi && \
52+
if ! id -u awfuser >/dev/null 2>&1; then \
53+
if ! getent passwd ${USER_UID} >/dev/null 2>&1; then \
54+
useradd -u ${USER_UID} -g awfuser -m -s /bin/bash awfuser; \
55+
else \
56+
useradd -g awfuser -m -s /bin/bash awfuser; \
57+
fi; \
58+
fi && \
3359
# Create directories for awfuser
3460
mkdir -p /home/awfuser/.copilot/logs && \
3561
chown -R awfuser:awfuser /home/awfuser

docs/usage.md

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ Options:
2525
--env-all Pass all host environment variables to container
2626
-v, --mount <path:path> Volume mount (host_path:container_path[:ro|rw])
2727
--tty Allocate a pseudo-TTY for interactive tools
28+
--build-local Build containers locally instead of using GHCR images
29+
--agent-base-image <image> Base image for agent container (requires --build-local)
30+
See "Agent Base Image" section for available options
2831
-V, --version Output the version number
2932
-h, --help Display help for command
3033
@@ -342,6 +345,102 @@ SSL Bump requires intercepting HTTPS traffic:
342345

343346
For more details, see [SSL Bump documentation](ssl-bump.md).
344347

348+
## Agent Base Image (GitHub Actions Parity)
349+
350+
By default, the agent container uses `ubuntu:22.04`, a minimal image optimized for size (~200MB). When you need closer parity with GitHub Actions runner environments, you can specify an alternative base image.
351+
352+
### Available Base Images
353+
354+
| Image | Size | Description |
355+
|-------|------|-------------|
356+
| `ubuntu:22.04` (default) | ~200MB | Minimal Ubuntu, smallest footprint |
357+
| `ghcr.io/catthehacker/ubuntu:runner-22.04` | ~2-5GB | Medium image with common tools, closer to GitHub Actions |
358+
| `ghcr.io/catthehacker/ubuntu:full-22.04` | ~20GB compressed | Near-identical to GitHub Actions runner |
359+
360+
### Usage
361+
362+
The `--agent-base-image` flag requires `--build-local` since it customizes the container build:
363+
364+
```bash
365+
# Use runner image for better GitHub Actions compatibility
366+
sudo awf \
367+
--build-local \
368+
--agent-base-image ghcr.io/catthehacker/ubuntu:runner-22.04 \
369+
--allow-domains github.com \
370+
-- your-command
371+
372+
# Use full image for maximum parity (large download, ~20GB)
373+
sudo awf \
374+
--build-local \
375+
--agent-base-image ghcr.io/catthehacker/ubuntu:full-22.04 \
376+
--allow-domains github.com \
377+
-- your-command
378+
```
379+
380+
### When to Use Custom Base Images
381+
382+
**Use `ubuntu:22.04` (default) when:**
383+
- Fast startup time is important
384+
- Minimal container size is preferred
385+
- Your commands only need basic tools (curl, git, Node.js, Docker CLI)
386+
387+
**Use `runner-22.04` when:**
388+
- You need tools commonly available in GitHub Actions (multiple Python versions, Go, Java, etc.)
389+
- Commands fail due to missing dependencies
390+
- Moderate GitHub Actions parity is needed
391+
392+
**Use `full-22.04` when:**
393+
- Maximum GitHub Actions parity is required
394+
- You need specific tools only available in the full runner image
395+
- Download time and disk space are not concerns
396+
397+
### Security Considerations
398+
399+
**⚠️ IMPORTANT:** Custom base images introduce supply chain risk. When using third-party images:
400+
401+
1. **Verify image sources** - Only use images from trusted publishers. The `catthehacker` images are community-maintained and not officially supported by GitHub.
402+
403+
2. **Review image contents** - Understand what tools and configurations are included. Third-party images may contain pre-installed software that could behave unexpectedly.
404+
405+
3. **Pin specific versions** - Use image digests (e.g., `@sha256:...`) instead of mutable tags to prevent tag manipulation:
406+
```bash
407+
--agent-base-image ghcr.io/catthehacker/ubuntu@sha256:abc123...
408+
```
409+
410+
4. **Monitor for vulnerabilities** - Third-party images may not receive timely security updates compared to official images.
411+
412+
**Existing security controls remain in effect:**
413+
- Host-level iptables (DOCKER-USER chain) enforce egress filtering regardless of container contents
414+
- Squid proxy enforces domain allowlist at L7
415+
- NET_ADMIN capability is dropped before user command execution
416+
- Seccomp profile blocks dangerous syscalls
417+
- `no-new-privileges` prevents privilege escalation
418+
419+
**For maximum security, use the default `ubuntu:22.04` image.** Custom base images are recommended only when you trust the image publisher and the benefits outweigh the supply chain risks.
420+
421+
### Pre-installed Tools
422+
423+
The default `ubuntu:22.04` image includes:
424+
- Node.js 22
425+
- Docker CLI
426+
- curl, git, iptables
427+
- CA certificates
428+
- Network utilities (dnsutils, net-tools, netcat)
429+
430+
When using runner images, you get additional tools like:
431+
- Multiple Python, Node.js, Go, Ruby versions
432+
- Build tools (make, cmake, gcc)
433+
- AWS CLI, Azure CLI, GitHub CLI
434+
- Container tools (docker, buildx)
435+
- And many more (see [catthehacker/docker_images](https://github.com/catthehacker/docker_images))
436+
437+
### Notes
438+
439+
- Custom base images only work with `--build-local` (not GHCR images)
440+
- First build with a new base image will take longer (downloading the image)
441+
- Subsequent builds use Docker cache and are faster
442+
- The `full-22.04` image requires significant disk space (~60GB extracted)
443+
345444
## Limitations
346445

347446
### No Internationalized Domains

src/cli.test.ts

Lines changed: 146 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Command } from 'commander';
2-
import { parseEnvironmentVariables, parseDomains, parseDomainsFile, escapeShellArg, joinShellArgs, parseVolumeMounts, isValidIPv4, isValidIPv6, parseDnsServers } from './cli';
2+
import { parseEnvironmentVariables, parseDomains, parseDomainsFile, escapeShellArg, joinShellArgs, parseVolumeMounts, isValidIPv4, isValidIPv6, parseDnsServers, validateAgentBaseImage } from './cli';
33
import { redactSecrets } from './redact-secrets';
44
import * as fs from 'fs';
55
import * as path from 'path';
@@ -774,4 +774,149 @@ describe('cli', () => {
774774
expect(DEFAULT_DNS_SERVERS).toEqual(['8.8.8.8', '8.8.4.4']);
775775
});
776776
});
777+
778+
describe('validateAgentBaseImage', () => {
779+
describe('valid images', () => {
780+
it('should accept official Ubuntu images', () => {
781+
expect(validateAgentBaseImage('ubuntu:22.04')).toEqual({ valid: true });
782+
expect(validateAgentBaseImage('ubuntu:24.04')).toEqual({ valid: true });
783+
expect(validateAgentBaseImage('ubuntu:20.04')).toEqual({ valid: true });
784+
});
785+
786+
it('should accept catthehacker runner images', () => {
787+
expect(validateAgentBaseImage('ghcr.io/catthehacker/ubuntu:runner-22.04')).toEqual({ valid: true });
788+
expect(validateAgentBaseImage('ghcr.io/catthehacker/ubuntu:runner-24.04')).toEqual({ valid: true });
789+
});
790+
791+
it('should accept catthehacker full images', () => {
792+
expect(validateAgentBaseImage('ghcr.io/catthehacker/ubuntu:full-22.04')).toEqual({ valid: true });
793+
expect(validateAgentBaseImage('ghcr.io/catthehacker/ubuntu:full-24.04')).toEqual({ valid: true });
794+
});
795+
796+
it('should accept catthehacker act images', () => {
797+
expect(validateAgentBaseImage('ghcr.io/catthehacker/ubuntu:act-22.04')).toEqual({ valid: true });
798+
expect(validateAgentBaseImage('ghcr.io/catthehacker/ubuntu:act-24.04')).toEqual({ valid: true });
799+
});
800+
801+
it('should accept images with SHA256 digest pinning', () => {
802+
expect(validateAgentBaseImage('ubuntu:22.04@sha256:a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1')).toEqual({ valid: true });
803+
expect(validateAgentBaseImage('ghcr.io/catthehacker/ubuntu:runner-22.04@sha256:a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1')).toEqual({ valid: true });
804+
expect(validateAgentBaseImage('ghcr.io/catthehacker/ubuntu:full-22.04@sha256:a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1')).toEqual({ valid: true });
805+
expect(validateAgentBaseImage('ghcr.io/catthehacker/ubuntu:act-22.04@sha256:a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1')).toEqual({ valid: true });
806+
});
807+
});
808+
809+
describe('invalid images', () => {
810+
it('should reject arbitrary images', () => {
811+
const result = validateAgentBaseImage('malicious-registry.com/evil:latest');
812+
expect(result.valid).toBe(false);
813+
expect(result.error).toContain('Invalid base image');
814+
});
815+
816+
it('should reject images with typos', () => {
817+
const result = validateAgentBaseImage('ubunto:22.04');
818+
expect(result.valid).toBe(false);
819+
expect(result.error).toContain('Invalid base image');
820+
});
821+
822+
it('should reject non-ubuntu official images', () => {
823+
const result = validateAgentBaseImage('alpine:latest');
824+
expect(result.valid).toBe(false);
825+
expect(result.error).toContain('Invalid base image');
826+
});
827+
828+
it('should reject unknown registries', () => {
829+
const result = validateAgentBaseImage('docker.io/library/ubuntu:22.04');
830+
expect(result.valid).toBe(false);
831+
expect(result.error).toContain('Invalid base image');
832+
});
833+
834+
it('should reject images from other catthehacker registries', () => {
835+
const result = validateAgentBaseImage('ghcr.io/catthehacker/debian:latest');
836+
expect(result.valid).toBe(false);
837+
expect(result.error).toContain('Invalid base image');
838+
});
839+
840+
it('should reject ubuntu with non-standard tags', () => {
841+
const result = validateAgentBaseImage('ubuntu:latest');
842+
expect(result.valid).toBe(false);
843+
expect(result.error).toContain('Invalid base image');
844+
});
845+
846+
it('should reject empty image string', () => {
847+
const result = validateAgentBaseImage('');
848+
expect(result.valid).toBe(false);
849+
expect(result.error).toContain('Invalid base image');
850+
});
851+
852+
it('should reject ubuntu with only major version', () => {
853+
const result = validateAgentBaseImage('ubuntu:22');
854+
expect(result.valid).toBe(false);
855+
expect(result.error).toContain('Invalid base image');
856+
});
857+
858+
it('should reject catthehacker with wrong prefix', () => {
859+
const result = validateAgentBaseImage('ghcr.io/catthehacker/ubuntu:minimal-22.04');
860+
expect(result.valid).toBe(false);
861+
expect(result.error).toContain('Invalid base image');
862+
});
863+
864+
it('should reject malformed SHA256 digest (too short)', () => {
865+
const result = validateAgentBaseImage('ubuntu:22.04@sha256:abc123');
866+
expect(result.valid).toBe(false);
867+
expect(result.error).toContain('Invalid base image');
868+
});
869+
870+
it('should reject image with path traversal attempt', () => {
871+
const result = validateAgentBaseImage('../ubuntu:22.04');
872+
expect(result.valid).toBe(false);
873+
expect(result.error).toContain('Invalid base image');
874+
});
875+
876+
it('should provide helpful error message with allowed options', () => {
877+
const result = validateAgentBaseImage('invalid:image');
878+
expect(result.valid).toBe(false);
879+
expect(result.error).toContain('ubuntu:XX.XX');
880+
expect(result.error).toContain('ghcr.io/catthehacker/ubuntu:runner-XX.XX');
881+
expect(result.error).toContain('ghcr.io/catthehacker/ubuntu:full-XX.XX');
882+
expect(result.error).toContain('ghcr.io/catthehacker/ubuntu:act-XX.XX');
883+
expect(result.error).toContain('@sha256:');
884+
});
885+
});
886+
887+
describe('regex pattern coverage', () => {
888+
// Ensure each regex pattern in SAFE_BASE_IMAGE_PATTERNS is individually tested
889+
it('should match pattern 1: plain ubuntu version', () => {
890+
expect(validateAgentBaseImage('ubuntu:18.04')).toEqual({ valid: true });
891+
expect(validateAgentBaseImage('ubuntu:26.10')).toEqual({ valid: true });
892+
});
893+
894+
it('should match pattern 2: catthehacker runner/full/act without digest', () => {
895+
expect(validateAgentBaseImage('ghcr.io/catthehacker/ubuntu:runner-18.04')).toEqual({ valid: true });
896+
expect(validateAgentBaseImage('ghcr.io/catthehacker/ubuntu:full-26.10')).toEqual({ valid: true });
897+
expect(validateAgentBaseImage('ghcr.io/catthehacker/ubuntu:act-22.04')).toEqual({ valid: true });
898+
});
899+
900+
it('should match pattern 3: catthehacker with SHA256 digest', () => {
901+
const digest = 'sha256:' + '1234567890abcdef'.repeat(4);
902+
expect(validateAgentBaseImage(`ghcr.io/catthehacker/ubuntu:runner-22.04@${digest}`)).toEqual({ valid: true });
903+
expect(validateAgentBaseImage(`ghcr.io/catthehacker/ubuntu:full-24.04@${digest}`)).toEqual({ valid: true });
904+
expect(validateAgentBaseImage(`ghcr.io/catthehacker/ubuntu:act-22.04@${digest}`)).toEqual({ valid: true });
905+
});
906+
907+
it('should match pattern 4: plain ubuntu with SHA256 digest', () => {
908+
const digest = 'sha256:' + 'abcdef1234567890'.repeat(4);
909+
expect(validateAgentBaseImage(`ubuntu:22.04@${digest}`)).toEqual({ valid: true });
910+
expect(validateAgentBaseImage(`ubuntu:24.04@${digest}`)).toEqual({ valid: true });
911+
});
912+
913+
it('should reject images that almost match but do not exactly', () => {
914+
// Nearly matching but invalid
915+
expect(validateAgentBaseImage('ubuntu:22.04 ').valid).toBe(false); // trailing space
916+
expect(validateAgentBaseImage(' ubuntu:22.04').valid).toBe(false); // leading space
917+
expect(validateAgentBaseImage('Ubuntu:22.04').valid).toBe(false); // capital U
918+
expect(validateAgentBaseImage('ghcr.io/catthehacker/ubuntu:Runner-22.04').valid).toBe(false); // capital R
919+
});
920+
});
921+
});
777922
});

0 commit comments

Comments
 (0)