Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
ae5ef67
feat(cli): add --enable-chroot for transparent host binary execution
Mossaka Jan 29, 2026
0bb15c6
test(chroot): add integration tests for Python, Node, Go
Mossaka Jan 29, 2026
eb70f00
test(chroot): add package manager and edge case tests
Mossaka Jan 29, 2026
4a1183d
fix(test): use 'localhost' instead of empty allowDomains array
Mossaka Jan 29, 2026
84857de
test(docker-manager): add tests for getRealUserHome passwd lookup
Mossaka Jan 29, 2026
5a2e2b5
fix(test): handle debug output in stdout for edge case tests
Mossaka Jan 29, 2026
1586452
test(chroot): skip custom env var test in chroot mode (known limitation)
Mossaka Jan 29, 2026
0ca6a44
docs(chroot): add documentation for --enable-chroot feature
Mossaka Jan 29, 2026
371e80a
fix(security): restore DNS config on chroot exit and fix docs
Mossaka Jan 29, 2026
323ec2c
feat(chroot): use minimal agent image without Node.js
Mossaka Jan 29, 2026
bb58040
feat: add smoke-chroot agentic workflow for --enable-chroot testing
Mossaka Jan 29, 2026
4267e54
feat(smoke-chroot): add multi-runtime version verification
Mossaka Jan 29, 2026
10a4d72
fix(smoke-chroot): run tests in setup step, agent reports results
Mossaka Jan 29, 2026
868c4cf
fix(smoke-chroot): run agent inside chroot, let it test directly
Mossaka Jan 30, 2026
6ff64fb
fix(workflow): correct chroot smoke test to run commands directly
Mossaka Jan 30, 2026
6eea890
refactor(workflow): simplify chroot smoke test prompt
Mossaka Jan 30, 2026
e5ecbbc
chore(workflow): switch smoke-chroot to Copilot engine
Mossaka Jan 30, 2026
b6cff01
fix(security): remove host /proc mount from chroot mode
Mossaka Jan 30, 2026
da9726e
feat(smoke-chroot): add version verification between host and chroot
Mossaka Jan 30, 2026
33ce8ae
fix(smoke-chroot): use remote gh-aw actions instead of local
Mossaka Jan 30, 2026
706c711
fix(chroot): mount /proc/self for Go runtime support
Mossaka Jan 30, 2026
c98e360
fix(chroot): pass host PATH for consistent tool versions
Mossaka Jan 30, 2026
af10718
fix(chroot): pass GOROOT for GitHub Actions Go support
Mossaka Jan 30, 2026
8a7cf2f
refactor(smoke-chroot): use chroot mode with simplified awf command
Mossaka Jan 30, 2026
2eabf9b
revert(smoke-chroot): use standard awf mode until chroot is released
Mossaka Jan 30, 2026
647a54a
fix(chroot): export GOROOT in workflow for Go tests
Mossaka Jan 30, 2026
d98aecd
fix(chroot): pass CARGO_HOME and JAVA_HOME for CI runners
Mossaka Jan 30, 2026
339b515
fix(chroot): ensure CARGO_HOME and JAVA_HOME are preserved through sudo
Mossaka Jan 30, 2026
e85929b
fix(chroot): set LD_LIBRARY_PATH for Java shared libraries
Mossaka Jan 30, 2026
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
238 changes: 238 additions & 0 deletions .github/workflows/test-chroot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
name: Chroot Integration Tests

on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:

permissions:
contents: read

jobs:
test-chroot-languages:
name: Test Chroot Language Support
runs-on: ubuntu-latest
timeout-minutes: 30

steps:
- name: Checkout repository
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4

- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '22'
cache: 'npm'

- name: Setup Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.12'

- name: Setup Go
uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5
with:
go-version: '1.22'

- name: Verify host tools are available
run: |
echo "=== Verifying host tools ==="
echo "Node.js: $(node --version)"
echo "npm: $(npm --version)"
echo "Python: $(python3 --version)"
echo "pip: $(pip3 --version)"
echo "Go: $(go version)"
echo "Git: $(git --version)"
echo "curl: $(curl --version | head -1)"

- name: Install dependencies
run: npm ci

- name: Build project
run: npm run build

- name: Build local containers
run: |
echo "=== Building local containers ==="
docker build -t ghcr.io/githubnext/gh-aw-firewall/squid:latest containers/squid/
docker build -t ghcr.io/githubnext/gh-aw-firewall/agent:latest containers/agent/

- name: Pre-test cleanup
run: |
echo "=== Pre-test cleanup ==="
./scripts/ci/cleanup.sh || true

- name: Run chroot language tests
run: |
echo "=== Running chroot language tests ==="
npm run test:integration -- --testPathPattern="chroot-languages" --verbose
env:
JEST_TIMEOUT: 180000

- name: Post-test cleanup
if: always()
run: |
echo "=== Post-test cleanup ==="
./scripts/ci/cleanup.sh || true

- name: Collect logs on failure
if: failure()
run: |
echo "=== Collecting failure logs ==="
docker ps -a || true
docker logs awf-squid 2>&1 || true
docker logs awf-agent 2>&1 || true
ls -la /tmp/awf-* 2>/dev/null || true
sudo cat /tmp/awf-*/squid-logs/access.log 2>/dev/null || true

test-chroot-package-managers:
name: Test Chroot Package Managers
runs-on: ubuntu-latest
timeout-minutes: 45
needs: test-chroot-languages # Run after language tests pass

steps:
- name: Checkout repository
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4

- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '22'
cache: 'npm'

- name: Setup Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.12'

- name: Setup Go
uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5
with:
go-version: '1.22'

- name: Setup Ruby
uses: ruby/setup-ruby@a4effe49ee8ee5b8b5091268c473a4628afb5651 # v1
with:
ruby-version: '3.2'

- name: Setup Rust
uses: dtolnay/rust-toolchain@stable

Check warning

Code scanning / CodeQL

Unpinned tag for a non-immutable Action in workflow Medium test

Unpinned 3rd party Action 'Chroot Integration Tests' step
Uses Step
uses 'dtolnay/rust-toolchain' with ref 'stable', not a pinned commit hash

- name: Setup Java
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4
with:
distribution: 'temurin'
java-version: '21'

- name: Verify host tools are available
run: |
echo "=== Verifying host tools ==="
echo "Node.js: $(node --version)"
echo "npm: $(npm --version)"
echo "Python: $(python3 --version)"
echo "pip: $(pip3 --version)"
echo "Go: $(go version)"
echo "Ruby: $(ruby --version)"
echo "Gem: $(gem --version)"
echo "Rust: $(rustc --version)"
echo "Cargo: $(cargo --version)"
echo "Java: $(java --version 2>&1 | head -1)"

- name: Install dependencies
run: npm ci

- name: Build project
run: npm run build

- name: Build local containers
run: |
echo "=== Building local containers ==="
docker build -t ghcr.io/githubnext/gh-aw-firewall/squid:latest containers/squid/
docker build -t ghcr.io/githubnext/gh-aw-firewall/agent:latest containers/agent/

- name: Pre-test cleanup
run: |
echo "=== Pre-test cleanup ==="
./scripts/ci/cleanup.sh || true

- name: Run chroot package manager tests
run: |
echo "=== Running chroot package manager tests ==="
npm run test:integration -- --testPathPattern="chroot-package-managers" --verbose
env:
JEST_TIMEOUT: 300000

- name: Post-test cleanup
if: always()
run: |
echo "=== Post-test cleanup ==="
./scripts/ci/cleanup.sh || true

- name: Collect logs on failure
if: failure()
run: |
echo "=== Collecting failure logs ==="
docker ps -a || true
docker logs awf-squid 2>&1 || true
docker logs awf-agent 2>&1 || true
ls -la /tmp/awf-* 2>/dev/null || true
sudo cat /tmp/awf-*/squid-logs/access.log 2>/dev/null || true

test-chroot-edge-cases:
name: Test Chroot Edge Cases
runs-on: ubuntu-latest
timeout-minutes: 30
needs: test-chroot-languages # Run after language tests pass

steps:
- name: Checkout repository
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4

- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '22'
cache: 'npm'

- name: Install dependencies
run: npm ci

- name: Build project
run: npm run build

- name: Build local containers
run: |
echo "=== Building local containers ==="
docker build -t ghcr.io/githubnext/gh-aw-firewall/squid:latest containers/squid/
docker build -t ghcr.io/githubnext/gh-aw-firewall/agent:latest containers/agent/

- name: Pre-test cleanup
run: |
echo "=== Pre-test cleanup ==="
./scripts/ci/cleanup.sh || true

- name: Run chroot edge case tests
run: |
echo "=== Running chroot edge case tests ==="
npm run test:integration -- --testPathPattern="chroot-edge-cases" --verbose
env:
JEST_TIMEOUT: 180000

- name: Post-test cleanup
if: always()
run: |
echo "=== Post-test cleanup ==="
./scripts/ci/cleanup.sh || true

- name: Collect logs on failure
if: failure()
run: |
echo "=== Collecting failure logs ==="
docker ps -a || true
docker logs awf-squid 2>&1 || true
docker logs awf-agent 2>&1 || true
ls -la /tmp/awf-* 2>/dev/null || true
sudo cat /tmp/awf-*/squid-logs/access.log 2>/dev/null || true
128 changes: 118 additions & 10 deletions containers/agent/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -129,16 +129,124 @@ echo "[entrypoint] Hostname: $(hostname)"
runuser -u awfuser -- git config --global --add safe.directory '*' 2>/dev/null || true

echo "[entrypoint] =================================="
echo "[entrypoint] Dropping CAP_NET_ADMIN capability and privileges to awfuser (UID: $(id -u awfuser), GID: $(id -g awfuser))"

# Determine which capabilities to drop
# - CAP_NET_ADMIN is always dropped (prevents iptables bypass)
# - CAP_SYS_CHROOT is dropped when chroot mode is enabled (prevents user code from using chroot)
if [ "${AWF_CHROOT_ENABLED}" = "true" ]; then
CAPS_TO_DROP="cap_net_admin,cap_sys_chroot"
echo "[entrypoint] Chroot mode enabled - dropping CAP_NET_ADMIN and CAP_SYS_CHROOT"
else
CAPS_TO_DROP="cap_net_admin"
echo "[entrypoint] Dropping CAP_NET_ADMIN capability"
fi

echo "[entrypoint] Switching to awfuser (UID: $(id -u awfuser), GID: $(id -g awfuser))"
echo "[entrypoint] Executing command: $@"
echo ""

# Drop CAP_NET_ADMIN capability and privileges, then execute the user command
# This prevents malicious code from modifying iptables rules to bypass the firewall
# Security note: capsh --drop removes the capability from the bounding set,
# preventing any process (even if it escalates to root) from acquiring it
# The order of operations:
# 1. capsh drops CAP_NET_ADMIN from the bounding set (cannot be regained)
# 2. gosu switches to awfuser (drops root privileges)
# 3. exec replaces the current process with the user command
exec capsh --drop=cap_net_admin -- -c "exec gosu awfuser $(printf '%q ' "$@")"
# If chroot mode is enabled, run user command INSIDE the chroot /host
# This provides transparent host binary access - user command sees host filesystem as /
if [ "${AWF_CHROOT_ENABLED}" = "true" ]; then
echo "[entrypoint] Chroot mode: running command inside host filesystem (/host)"

# Verify capsh is available on the host (required for privilege drop)
if ! chroot /host which capsh >/dev/null 2>&1; then
echo "[entrypoint][ERROR] capsh not found on host system"
echo "[entrypoint][ERROR] Install libcap2-bin package: apt-get install libcap2-bin"
exit 1
fi

# Backup and copy container's resolv.conf to host (preserves AWF DNS configuration)
# This ensures DNS queries inside the chroot use the configured DNS servers
# NOTE: We backup the host's original resolv.conf and set up a trap to restore it
RESOLV_BACKUP="/host/etc/resolv.conf.awf-backup-$$"
RESOLV_MODIFIED=false
if cp /host/etc/resolv.conf "$RESOLV_BACKUP" 2>/dev/null; then
if cp /etc/resolv.conf /host/etc/resolv.conf.awf 2>/dev/null; then
mv /host/etc/resolv.conf.awf /host/etc/resolv.conf 2>/dev/null && RESOLV_MODIFIED=true
echo "[entrypoint] DNS configuration copied to chroot (backup at $RESOLV_BACKUP)"
else
echo "[entrypoint][WARN] Could not copy DNS configuration to chroot"
fi
else
echo "[entrypoint][WARN] Could not backup host resolv.conf, skipping DNS override"
fi

# Determine working directory inside the chroot
# AWF_WORKDIR is set by docker-manager.ts (containerWorkDir or HOME)
# For chroot mode, paths like /home/user stay the same (no /host prefix)
CONTAINER_WORKDIR="${AWF_WORKDIR:-${HOME:-/}}"
if [ -n "${CONTAINER_WORKDIR}" ] && [ "${CONTAINER_WORKDIR#/host}" != "${CONTAINER_WORKDIR}" ]; then
# Strip /host prefix if present (for paths that include it)
CHROOT_WORKDIR="${CONTAINER_WORKDIR#/host}"
[ -z "${CHROOT_WORKDIR}" ] && CHROOT_WORKDIR="/"
else
# Use the path as-is (normal paths like /home/user, /tmp, etc.)
CHROOT_WORKDIR="${CONTAINER_WORKDIR}"
fi
echo "[entrypoint] Chroot working directory: ${CHROOT_WORKDIR}"

# Validate working directory exists in chroot
if [ ! -d "/host${CHROOT_WORKDIR}" ]; then
echo "[entrypoint][WARN] Working directory ${CHROOT_WORKDIR} does not exist on host, will use /"
fi

# Find the user name on the host system by UID
# This allows us to run as the same user inside the chroot
HOST_USER_UID="${AWF_USER_UID:-1000}"
HOST_USER=$(chroot /host getent passwd "${HOST_USER_UID}" 2>/dev/null | cut -d: -f1 || echo "")
if [ -z "${HOST_USER}" ]; then
# Fall back to 'nobody' if user not found by UID
HOST_USER="nobody"
echo "[entrypoint][WARN] Could not find user with UID ${HOST_USER_UID} on host, using ${HOST_USER}"
else
echo "[entrypoint] Running as host user: ${HOST_USER} (UID: ${HOST_USER_UID})"
fi

# Write the command to a temporary script file in the chroot
# This avoids complex quoting issues with nested shells
SCRIPT_FILE="/tmp/awf-cmd-$$.sh"
cat > "/host${SCRIPT_FILE}" << 'AWFEOF'
#!/bin/bash
# Set comprehensive PATH for host binaries
# Include standard paths plus tool cache locations (GitHub Actions)
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
# Add tool cache paths if they exist (Python, Node, Go, etc.)
[ -d "/opt/hostedtoolcache" ] && export PATH="/opt/hostedtoolcache/node/*/x64/bin:/opt/hostedtoolcache/Python/*/x64/bin:/opt/hostedtoolcache/go/*/x64/bin:$PATH"
# Add user's local bin if it exists
[ -d "$HOME/.local/bin" ] && export PATH="$HOME/.local/bin:$PATH"
# Add Cargo bin for Rust (common in development)
[ -d "$HOME/.cargo/bin" ] && export PATH="$HOME/.cargo/bin:$PATH"
AWFEOF
# Append the actual command arguments
printf '%q ' "$@" >> "/host${SCRIPT_FILE}"
echo "" >> "/host${SCRIPT_FILE}"
chmod +x "/host${SCRIPT_FILE}"

# Execute inside chroot:
# 1. chroot /host - filesystem root becomes host's /
# 2. cd to the working directory
# 3. Drop capabilities (NET_ADMIN and SYS_CHROOT)
# 4. Run as the mapped user using capsh --user
# 5. Clean up the script file
#
# Note: We use capsh inside the chroot because it handles the privilege drop
# and user switch atomically. The host must have capsh installed.
exec chroot /host /bin/bash -c "
cd '${CHROOT_WORKDIR}' 2>/dev/null || cd /
trap 'rm -f ${SCRIPT_FILE}' EXIT
exec capsh --drop=${CAPS_TO_DROP} --user=${HOST_USER} -- -c 'exec ${SCRIPT_FILE}'
"
else
# Original behavior - run in container filesystem
# Drop capabilities and privileges, then execute the user command
# This prevents malicious code from modifying iptables rules or using chroot
# Security note: capsh --drop removes capabilities from the bounding set,
# preventing any process (even if it escalates to root) from acquiring them
# The order of operations:
# 1. capsh drops capabilities from the bounding set (cannot be regained)
# 2. gosu switches to awfuser (drops root privileges)
# 3. exec replaces the current process with the user command
exec capsh --drop=$CAPS_TO_DROP -- -c "exec gosu awfuser $(printf '%q ' "$@")"
fi
8 changes: 8 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,13 @@ program
'Comma-separated list of allowed URL patterns for HTTPS (requires --ssl-bump).\n' +
' Supports wildcards: https://github.com/githubnext/*'
)
.option(
'--enable-chroot',
'Enable chroot to /host for running host binaries (Python, Node, Go, etc.)\n' +
' Uses selective path mounts instead of full filesystem access.\n' +
' Docker socket is hidden to prevent firewall bypass.',
false
)
.argument('[args...]', 'Command and arguments to execute (use -- to separate from options)')
.action(async (args: string[], options) => {
// Require -- separator for passing command arguments
Expand Down Expand Up @@ -794,6 +801,7 @@ program
allowHostPorts: options.allowHostPorts,
sslBump: options.sslBump,
allowedUrls,
enableChroot: options.enableChroot,
};

// Warn if --env-all is used
Expand Down
Loading
Loading