diff --git a/.github/workflows/build-test.lock.yml b/.github/workflows/build-test.lock.yml index 66d180def..5cee86779 100644 --- a/.github/workflows/build-test.lock.yml +++ b/.github/workflows/build-test.lock.yml @@ -499,7 +499,8 @@ jobs: const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs'); await main(); - name: Install ripgrep - run: bash "${RUNNER_TEMP}/gh-aw/actions/install_ripgrep.sh" + timeout-minutes: 5 + run: timeout --foreground --kill-after=10s 4m bash "${RUNNER_TEMP}/gh-aw/actions/install_ripgrep.sh" - name: Install GitHub Copilot CLI run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" env: diff --git a/.github/workflows/smoke-chroot.lock.yml b/.github/workflows/smoke-chroot.lock.yml index 542e5e18b..fb3831d2f 100644 --- a/.github/workflows/smoke-chroot.lock.yml +++ b/.github/workflows/smoke-chroot.lock.yml @@ -528,7 +528,8 @@ jobs: const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs'); await main(); - name: Install ripgrep - run: bash "${RUNNER_TEMP}/gh-aw/actions/install_ripgrep.sh" + timeout-minutes: 5 + run: timeout --foreground --kill-after=10s 4m bash "${RUNNER_TEMP}/gh-aw/actions/install_ripgrep.sh" - name: Install GitHub Copilot CLI run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" env: diff --git a/.github/workflows/smoke-cloud-hypervisor-build-test.lock.yml b/.github/workflows/smoke-cloud-hypervisor-build-test.lock.yml index a8c0ef9ca..ef0d26321 100644 --- a/.github/workflows/smoke-cloud-hypervisor-build-test.lock.yml +++ b/.github/workflows/smoke-cloud-hypervisor-build-test.lock.yml @@ -504,7 +504,8 @@ jobs: const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs'); await main(); - name: Install ripgrep - run: bash "${RUNNER_TEMP}/gh-aw/actions/install_ripgrep.sh" + timeout-minutes: 5 + run: timeout --foreground --kill-after=10s 4m bash "${RUNNER_TEMP}/gh-aw/actions/install_ripgrep.sh" - name: Install GitHub Copilot CLI run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.34 env: diff --git a/.github/workflows/smoke-cloud-hypervisor.lock.yml b/.github/workflows/smoke-cloud-hypervisor.lock.yml index 8fcd64835..0ba881d24 100644 --- a/.github/workflows/smoke-cloud-hypervisor.lock.yml +++ b/.github/workflows/smoke-cloud-hypervisor.lock.yml @@ -486,7 +486,8 @@ jobs: const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs'); await main(); - name: Install ripgrep - run: bash "${RUNNER_TEMP}/gh-aw/actions/install_ripgrep.sh" + timeout-minutes: 5 + run: timeout --foreground --kill-after=10s 4m bash "${RUNNER_TEMP}/gh-aw/actions/install_ripgrep.sh" - name: Install GitHub Copilot CLI run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.34 env: diff --git a/.github/workflows/smoke-copilot-byok-aoai-apikey.lock.yml b/.github/workflows/smoke-copilot-byok-aoai-apikey.lock.yml index 2945f3ae9..a4a43f896 100644 --- a/.github/workflows/smoke-copilot-byok-aoai-apikey.lock.yml +++ b/.github/workflows/smoke-copilot-byok-aoai-apikey.lock.yml @@ -537,7 +537,8 @@ jobs: const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs'); await main(); - name: Install ripgrep - run: bash "${RUNNER_TEMP}/gh-aw/actions/install_ripgrep.sh" + timeout-minutes: 5 + run: timeout --foreground --kill-after=10s 4m bash "${RUNNER_TEMP}/gh-aw/actions/install_ripgrep.sh" - name: Install GitHub Copilot CLI run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" env: diff --git a/.github/workflows/smoke-copilot-byok-aoai-entra.lock.yml b/.github/workflows/smoke-copilot-byok-aoai-entra.lock.yml index d36db9914..c2a02df38 100644 --- a/.github/workflows/smoke-copilot-byok-aoai-entra.lock.yml +++ b/.github/workflows/smoke-copilot-byok-aoai-entra.lock.yml @@ -504,7 +504,8 @@ jobs: const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs'); await main(); - name: Install ripgrep - run: bash "${RUNNER_TEMP}/gh-aw/actions/install_ripgrep.sh" + timeout-minutes: 5 + run: timeout --foreground --kill-after=10s 4m bash "${RUNNER_TEMP}/gh-aw/actions/install_ripgrep.sh" - name: Install GitHub Copilot CLI run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" env: diff --git a/.github/workflows/smoke-copilot-byok.lock.yml b/.github/workflows/smoke-copilot-byok.lock.yml index 1ea7e0802..25bdbc2aa 100644 --- a/.github/workflows/smoke-copilot-byok.lock.yml +++ b/.github/workflows/smoke-copilot-byok.lock.yml @@ -489,7 +489,8 @@ jobs: const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs'); await main(); - name: Install ripgrep - run: bash "${RUNNER_TEMP}/gh-aw/actions/install_ripgrep.sh" + timeout-minutes: 5 + run: timeout --foreground --kill-after=10s 4m bash "${RUNNER_TEMP}/gh-aw/actions/install_ripgrep.sh" - name: Install GitHub Copilot CLI run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" env: diff --git a/.github/workflows/smoke-copilot-network-isolation.lock.yml b/.github/workflows/smoke-copilot-network-isolation.lock.yml index 6f76fc3f6..f001d6a8e 100644 --- a/.github/workflows/smoke-copilot-network-isolation.lock.yml +++ b/.github/workflows/smoke-copilot-network-isolation.lock.yml @@ -492,7 +492,8 @@ jobs: const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs'); await main(); - name: Install ripgrep - run: bash "${RUNNER_TEMP}/gh-aw/actions/install_ripgrep.sh" + timeout-minutes: 5 + run: timeout --foreground --kill-after=10s 4m bash "${RUNNER_TEMP}/gh-aw/actions/install_ripgrep.sh" - name: Install GitHub Copilot CLI run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.34 env: diff --git a/.github/workflows/smoke-copilot.lock.yml b/.github/workflows/smoke-copilot.lock.yml index eb13c0daf..43efbbc72 100644 --- a/.github/workflows/smoke-copilot.lock.yml +++ b/.github/workflows/smoke-copilot.lock.yml @@ -493,7 +493,8 @@ jobs: const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs'); await main(); - name: Install ripgrep - run: bash "${RUNNER_TEMP}/gh-aw/actions/install_ripgrep.sh" + timeout-minutes: 5 + run: timeout --foreground --kill-after=10s 4m bash "${RUNNER_TEMP}/gh-aw/actions/install_ripgrep.sh" - name: Install GitHub Copilot CLI run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.34 env: diff --git a/.github/workflows/smoke-docker-sbx-build-test.lock.yml b/.github/workflows/smoke-docker-sbx-build-test.lock.yml index 6c4647130..099d8f329 100644 --- a/.github/workflows/smoke-docker-sbx-build-test.lock.yml +++ b/.github/workflows/smoke-docker-sbx-build-test.lock.yml @@ -562,7 +562,8 @@ jobs: const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs'); await main(); - name: Install ripgrep - run: bash "${RUNNER_TEMP}/gh-aw/actions/install_ripgrep.sh" + timeout-minutes: 5 + run: timeout --foreground --kill-after=10s 4m bash "${RUNNER_TEMP}/gh-aw/actions/install_ripgrep.sh" - name: Install GitHub Copilot CLI run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.34 env: diff --git a/.github/workflows/smoke-docker-sbx.lock.yml b/.github/workflows/smoke-docker-sbx.lock.yml index 538118640..33e926ba2 100644 --- a/.github/workflows/smoke-docker-sbx.lock.yml +++ b/.github/workflows/smoke-docker-sbx.lock.yml @@ -506,7 +506,8 @@ jobs: const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs'); await main(); - name: Install ripgrep - run: bash "${RUNNER_TEMP}/gh-aw/actions/install_ripgrep.sh" + timeout-minutes: 5 + run: timeout --foreground --kill-after=10s 4m bash "${RUNNER_TEMP}/gh-aw/actions/install_ripgrep.sh" - name: Install GitHub Copilot CLI run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.34 env: diff --git a/.github/workflows/smoke-enclave-build-test.lock.yml b/.github/workflows/smoke-enclave-build-test.lock.yml index fb9e4162a..87834c72e 100644 --- a/.github/workflows/smoke-enclave-build-test.lock.yml +++ b/.github/workflows/smoke-enclave-build-test.lock.yml @@ -439,7 +439,8 @@ jobs: const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs'); await main(); - name: Install ripgrep - run: bash "${RUNNER_TEMP}/gh-aw/actions/install_ripgrep.sh" + timeout-minutes: 5 + run: timeout --foreground --kill-after=10s 4m bash "${RUNNER_TEMP}/gh-aw/actions/install_ripgrep.sh" - name: Install GitHub Copilot CLI run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.34 env: diff --git a/.github/workflows/smoke-gvisor-build-test.lock.yml b/.github/workflows/smoke-gvisor-build-test.lock.yml index 09b0f29c1..55aff07a2 100644 --- a/.github/workflows/smoke-gvisor-build-test.lock.yml +++ b/.github/workflows/smoke-gvisor-build-test.lock.yml @@ -542,7 +542,8 @@ jobs: const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs'); await main(); - name: Install ripgrep - run: bash "${RUNNER_TEMP}/gh-aw/actions/install_ripgrep.sh" + timeout-minutes: 5 + run: timeout --foreground --kill-after=10s 4m bash "${RUNNER_TEMP}/gh-aw/actions/install_ripgrep.sh" - name: Install GitHub Copilot CLI run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.34 env: diff --git a/.github/workflows/smoke-gvisor.lock.yml b/.github/workflows/smoke-gvisor.lock.yml index c9dd1c829..35cd8d86e 100644 --- a/.github/workflows/smoke-gvisor.lock.yml +++ b/.github/workflows/smoke-gvisor.lock.yml @@ -496,7 +496,8 @@ jobs: const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs'); await main(); - name: Install ripgrep - run: bash "${RUNNER_TEMP}/gh-aw/actions/install_ripgrep.sh" + timeout-minutes: 5 + run: timeout --foreground --kill-after=10s 4m bash "${RUNNER_TEMP}/gh-aw/actions/install_ripgrep.sh" - name: Install GitHub Copilot CLI run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.34 env: diff --git a/.github/workflows/smoke-otel-tracing.lock.yml b/.github/workflows/smoke-otel-tracing.lock.yml index 7d9178c2b..df20da149 100644 --- a/.github/workflows/smoke-otel-tracing.lock.yml +++ b/.github/workflows/smoke-otel-tracing.lock.yml @@ -537,7 +537,8 @@ jobs: const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs'); await main(); - name: Install ripgrep - run: bash "${RUNNER_TEMP}/gh-aw/actions/install_ripgrep.sh" + timeout-minutes: 5 + run: timeout --foreground --kill-after=10s 4m bash "${RUNNER_TEMP}/gh-aw/actions/install_ripgrep.sh" - name: Install GitHub Copilot CLI run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" env: diff --git a/.github/workflows/smoke-services.lock.yml b/.github/workflows/smoke-services.lock.yml index 681d0173c..f31e0b076 100644 --- a/.github/workflows/smoke-services.lock.yml +++ b/.github/workflows/smoke-services.lock.yml @@ -495,7 +495,8 @@ jobs: const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs'); await main(); - name: Install ripgrep - run: bash "${RUNNER_TEMP}/gh-aw/actions/install_ripgrep.sh" + timeout-minutes: 5 + run: timeout --foreground --kill-after=10s 4m bash "${RUNNER_TEMP}/gh-aw/actions/install_ripgrep.sh" - name: Install GitHub Copilot CLI run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" env: diff --git a/.github/workflows/smoke-sink-visibility-allowed.lock.yml b/.github/workflows/smoke-sink-visibility-allowed.lock.yml index b08b3956f..4e6da2462 100644 --- a/.github/workflows/smoke-sink-visibility-allowed.lock.yml +++ b/.github/workflows/smoke-sink-visibility-allowed.lock.yml @@ -429,7 +429,8 @@ jobs: const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs'); await main(); - name: Install ripgrep - run: bash "${RUNNER_TEMP}/gh-aw/actions/install_ripgrep.sh" + timeout-minutes: 5 + run: timeout --foreground --kill-after=10s 4m bash "${RUNNER_TEMP}/gh-aw/actions/install_ripgrep.sh" - name: Install GitHub Copilot CLI run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.34 env: diff --git a/.github/workflows/smoke-sink-visibility-blocked.lock.yml b/.github/workflows/smoke-sink-visibility-blocked.lock.yml index 8652fe3fb..c2a12d572 100644 --- a/.github/workflows/smoke-sink-visibility-blocked.lock.yml +++ b/.github/workflows/smoke-sink-visibility-blocked.lock.yml @@ -429,7 +429,8 @@ jobs: const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs'); await main(); - name: Install ripgrep - run: bash "${RUNNER_TEMP}/gh-aw/actions/install_ripgrep.sh" + timeout-minutes: 5 + run: timeout --foreground --kill-after=10s 4m bash "${RUNNER_TEMP}/gh-aw/actions/install_ripgrep.sh" - name: Install GitHub Copilot CLI run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.34 env: diff --git a/containers/agent/Dockerfile b/containers/agent/Dockerfile index 3e57beaf4..caa288a7b 100644 --- a/containers/agent/Dockerfile +++ b/containers/agent/Dockerfile @@ -20,6 +20,17 @@ RUN wget -q "https://github.com/cli/cli/archive/refs/tags/v${GH_VERSION}.tar.gz" go run ./script/build.go bin/gh \ && /tmp/cli-${GH_VERSION}/bin/gh --version +FROM ${BASE_IMAGE} AS one-shot-token-build + +COPY one-shot-token/one-shot-token.c /tmp/one-shot-token.c +RUN apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update && \ + apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 \ + install -y --no-install-recommends gcc libc6-dev binutils && \ + gcc -shared -fPIC -fvisibility=hidden -O2 -Wall -s \ + -U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0 \ + -o /tmp/one-shot-token.so /tmp/one-shot-token.c -ldl -lpthread && \ + strip --strip-unneeded /tmp/one-shot-token.so + FROM ${BASE_IMAGE} # Optionally switch to Azure apt mirror for faster package fetches in CI @@ -41,7 +52,9 @@ RUN if getent hosts azure.archive.ubuntu.com >/dev/null 2>&1; then \ echo "Azure apt mirror not reachable, using default archive.ubuntu.com"; \ fi -# Install required packages, Node.js 22.23.2, and npm 11.18.0 +# Install required and runner-parity packages, apply security upgrades, then install +# Node.js 22.23.2 and npm 11.18.0. Keeping apt work in one layer avoids repeatedly +# downloading package indexes during cold CI builds. # Note: Some packages may already exist in runner-like base images, apt handles this gracefully # apt_update_retry: retries up to 3 times with backoff; if all fail, reverts to archive.ubuntu.com RUN set -eux; \ @@ -56,12 +69,14 @@ RUN set -eux; \ sed -i -e 's|http://azure.archive.ubuntu.com|http://archive.ubuntu.com|g' \ -e 's|http://security.ubuntu.com|http://archive.ubuntu.com|g' {} + 2>/dev/null || true; \ fi; \ - rm -rf /var/lib/apt/lists/* && apt-get update; \ + rm -rf /var/lib/apt/lists/* && \ + apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update; \ }; \ apt_update_retry() { \ local i; for i in 1 2 3; do \ rm -rf /var/lib/apt/lists/*; \ - if apt-get update > /tmp/apt-update.log 2>&1; then \ + if apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 \ + update > /tmp/apt-update.log 2>&1; then \ cat /tmp/apt-update.log; \ if ! grep -q "Failed to fetch" /tmp/apt-update.log; then return 0; fi; \ else \ @@ -73,14 +88,24 @@ RUN set -eux; \ force_archive_mirror; \ }; \ apt_install_retry() { \ - apt-get install -y --no-install-recommends "$@" && return 0; \ + apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 \ + install -y --no-install-recommends "$@" && return 0; \ echo "apt-get install failed (likely mirror fetch timeout), forcing archive.ubuntu.com and retrying..." >&2; \ force_archive_mirror; \ - apt-get install -y --no-install-recommends "$@"; \ + apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 \ + install -y --no-install-recommends "$@"; \ + }; \ + apt_upgrade_retry() { \ + apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 \ + upgrade -y && return 0; \ + echo "apt-get upgrade failed (likely mirror fetch timeout), forcing archive.ubuntu.com and retrying..." >&2; \ + force_archive_mirror && \ + apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 upgrade -y; \ }; \ - PKGS="iptables curl ca-certificates git gnupg dnsutils net-tools netcat-openbsd libcap2-bin"; \ + PKGS="iptables curl ca-certificates git gnupg dnsutils net-tools netcat-openbsd libcap2-bin libgdiplus libev-dev libssl-dev php-intl php-gd python3"; \ apt_update_retry && \ apt_install_retry $PKGS && \ + apt_upgrade_retry && \ # Install gosu 1.19 from GitHub releases (compiled with Go 1.24.6 — avoids Ubuntu's # apt gosu which is compiled with Go 1.18 and carries 44 stdlib CVEs including Critical ones). # GO-2026-4337 (TLS session resumption in Go stdlib) is reported against this binary by @@ -171,89 +196,6 @@ RUN set -eux; \ COPY --from=gh-build /tmp/cli-2.97.0/bin/gh /usr/local/bin/gh RUN gh --version | grep -qE '^gh version 2\.97\.0' -# Install additional system packages for GitHub Actions runner parity -# These packages are commonly needed by workflows and avoid agents spending time installing them manually -# See: https://github.com/actions/runner-images/blob/main/images/ubuntu/Ubuntu2204-Readme.md -RUN set -eux; \ - force_archive_mirror() { \ - echo "Falling back to archive.ubuntu.com mirror..." >&2; \ - if [ -f /etc/apt/sources.list ]; then \ - sed -i 's|http://azure.archive.ubuntu.com|http://archive.ubuntu.com|g' /etc/apt/sources.list; \ - sed -i 's|http://security.ubuntu.com|http://archive.ubuntu.com|g' /etc/apt/sources.list 2>/dev/null || true; \ - fi; \ - if [ -d /etc/apt/sources.list.d ]; then \ - find /etc/apt/sources.list.d -name '*.sources' -exec \ - sed -i -e 's|http://azure.archive.ubuntu.com|http://archive.ubuntu.com|g' \ - -e 's|http://security.ubuntu.com|http://archive.ubuntu.com|g' {} + 2>/dev/null || true; \ - fi; \ - rm -rf /var/lib/apt/lists/* && apt-get update; \ - }; \ - apt_update_retry() { \ - local i; for i in 1 2 3; do \ - rm -rf /var/lib/apt/lists/*; \ - if apt-get update > /tmp/apt-update.log 2>&1; then \ - cat /tmp/apt-update.log; \ - if ! grep -q "Failed to fetch" /tmp/apt-update.log; then return 0; fi; \ - else \ - cat /tmp/apt-update.log; \ - fi; \ - echo "apt-get update attempt $i/3 failed or had fetch failures, retrying in $((i*10))s..." >&2; sleep $((i*10)); \ - done; \ - echo "All apt-get update retries failed, falling back to archive.ubuntu.com..." >&2; \ - force_archive_mirror; \ - }; \ - apt_install_retry() { \ - apt-get install -y --no-install-recommends "$@" && return 0; \ - echo "apt-get install failed (likely mirror fetch timeout), forcing archive.ubuntu.com and retrying..." >&2; \ - force_archive_mirror; \ - apt-get install -y --no-install-recommends "$@"; \ - }; \ - # python3 is commonly needed by workflows and is also required by the - # release-time seccomp regression check (scripts/ci/check-agent-seccomp-syscalls.sh), - # which uses python3+ctypes to fire name_to_handle_at/open_by_handle_at. - PARITY_PKGS="libgdiplus libev-dev libssl-dev php-intl php-gd python3"; \ - apt_update_retry && \ - apt_install_retry $PARITY_PKGS && \ - rm -rf /var/lib/apt/lists/* - -# Upgrade all packages to pick up security patches -# Addresses CVE-2023-44487 (HTTP/2 Rapid Reset) and other known vulnerabilities -# Retry logic handles transient mirror sync failures during apt-get update -RUN force_archive_mirror() { \ - echo "Falling back to archive.ubuntu.com mirror..." >&2; \ - if [ -f /etc/apt/sources.list ]; then \ - sed -i 's|http://azure.archive.ubuntu.com|http://archive.ubuntu.com|g' /etc/apt/sources.list; \ - sed -i 's|http://security.ubuntu.com|http://archive.ubuntu.com|g' /etc/apt/sources.list 2>/dev/null || true; \ - fi; \ - if [ -d /etc/apt/sources.list.d ]; then \ - find /etc/apt/sources.list.d -name '*.sources' -exec \ - sed -i -e 's|http://azure.archive.ubuntu.com|http://archive.ubuntu.com|g' \ - -e 's|http://security.ubuntu.com|http://archive.ubuntu.com|g' {} + 2>/dev/null || true; \ - fi; \ - rm -rf /var/lib/apt/lists/* && apt-get update; \ - }; \ - apt_update_retry() { \ - local i; for i in 1 2 3; do \ - rm -rf /var/lib/apt/lists/*; \ - if apt-get update > /tmp/apt-update.log 2>&1; then \ - cat /tmp/apt-update.log; \ - if ! grep -q "Failed to fetch" /tmp/apt-update.log; then return 0; fi; \ - else \ - cat /tmp/apt-update.log; \ - fi; \ - echo "apt-get update attempt $i/3 failed or had fetch failures, retrying in $((i*10))s..." >&2; sleep $((i*10)); \ - done; \ - echo "All apt-get update retries failed, falling back to archive.ubuntu.com..." >&2; \ - force_archive_mirror; \ - }; \ - apt_upgrade_retry() { \ - apt-get upgrade -y && return 0; \ - echo "apt-get upgrade failed (likely mirror fetch timeout), forcing archive.ubuntu.com and retrying..." >&2; \ - force_archive_mirror && apt-get upgrade -y; \ - }; \ - apt_update_retry && \ - apt_upgrade_retry && rm -rf /var/lib/apt/lists/* - # Create non-root user with UID/GID matching host user # This allows the user command to run with appropriate permissions # and prevents file ownership issues with mounted volumes @@ -287,43 +229,12 @@ COPY get-claude-key.sh /usr/local/bin/get-claude-key.sh COPY gh-cli-proxy-wrapper.sh /usr/local/bin/gh-cli-proxy-wrapper.sh RUN chmod +x /usr/local/bin/setup-iptables.sh /usr/local/bin/entrypoint.sh /usr/local/bin/pid-logger.sh /usr/local/bin/api-proxy-health-check.sh /usr/local/bin/get-claude-key.sh /usr/local/bin/gh-cli-proxy-wrapper.sh -# Copy pre-built one-shot-token library from rust-builder stage +# Copy the one-shot-token library from its isolated build stage. # This prevents tokens from being read multiple times (e.g., by malicious code) # Build flags: -fvisibility=hidden hides internal symbols, -s strips at link time # -U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0 disables glibc fortification macros (e.g. # __fprintf_chk) so the resulting .so loads on musl-based hosts (Alpine/ARC runners) -COPY one-shot-token/one-shot-token.c /tmp/one-shot-token.c -RUN set -eux; \ - force_archive_mirror() { \ - echo "Falling back to archive.ubuntu.com mirror..." >&2; \ - if [ -f /etc/apt/sources.list ]; then \ - sed -i 's|http://azure.archive.ubuntu.com|http://archive.ubuntu.com|g' /etc/apt/sources.list; \ - sed -i 's|http://security.ubuntu.com|http://archive.ubuntu.com|g' /etc/apt/sources.list 2>/dev/null || true; \ - fi; \ - if [ -d /etc/apt/sources.list.d ]; then \ - find /etc/apt/sources.list.d -name '*.sources' -exec \ - sed -i -e 's|http://azure.archive.ubuntu.com|http://archive.ubuntu.com|g' \ - -e 's|http://security.ubuntu.com|http://archive.ubuntu.com|g' {} + 2>/dev/null || true; \ - fi; \ - rm -rf /var/lib/apt/lists/* && apt-get update; \ - }; \ - apt_install_retry() { \ - apt-get install -y --no-install-recommends "$@" && return 0; \ - echo "apt-get install failed (likely mirror fetch timeout), forcing archive.ubuntu.com and retrying..." >&2; \ - force_archive_mirror; \ - apt-get install -y --no-install-recommends "$@"; \ - }; \ - BUILD_PKGS="gcc libc6-dev binutils"; \ - apt-get update && \ - apt_install_retry $BUILD_PKGS && \ - gcc -shared -fPIC -fvisibility=hidden -O2 -Wall -s \ - -U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0 \ - -o /usr/local/lib/one-shot-token.so /tmp/one-shot-token.c -ldl -lpthread && \ - strip --strip-unneeded /usr/local/lib/one-shot-token.so && \ - rm /tmp/one-shot-token.c && \ - apt-get remove -y $BUILD_PKGS && \ - apt-get autoremove -y && \ - rm -rf /var/lib/apt/lists/* +COPY --from=one-shot-token-build /tmp/one-shot-token.so /usr/local/lib/one-shot-token.so # Install Docker stub script that shows helpful error message # Docker-in-Docker support was removed in v0.9.1 diff --git a/docs/mount-policy.md b/docs/mount-policy.md index 892a5f72a..d1c9a2cf1 100644 --- a/docs/mount-policy.md +++ b/docs/mount-policy.md @@ -25,9 +25,10 @@ exercised only by its own tests. | --- | --- | --- | --- | | `system.directories.default` / `.sysroot` | allow (dirs) | compose (Docker + gVisor) | `system-mounts.ts` | | `system.etc` | allow (files) | compose (Docker + gVisor) | `etc-mounts.ts` | -| `home.toolSubdirs` | allow (dirs) | compose + sbx | `home-strategy.ts`, `sbx-manager.ts` | +| `home.toolSubdirs` | allow (dirs) | compose + sbx + microVM workspace tests | `home-strategy.ts`, `sbx-manager.ts`, `microvm/workspace.ts` | +| `home.narrowPaths` | narrow allow override | compose + sbx + microVM workspace tests | `home-strategy.ts`, `sbx-manager.ts`, `microvm/workspace.ts` | | `home.forbiddenSubdirs` | deny guard | compose + sbx | invariant tests | -| `credentials.entries` | deny (files/dirs) | compose + sbx | `credential-hiding.ts`, `sbx-manager.ts` | +| `credentials.entries` | deny (files/dirs) | compose + sbx + microVM workspace tests | `credential-hiding.ts`, `sbx-manager.ts`, `microvm/workspace.ts` | The `system.*` section is compose-only: sbx gets its system libraries from a guest image, not from host mounts. Cloud Hypervisor also boots from a guest @@ -42,8 +43,11 @@ mechanisms, but from the **same list**: then blanks each credential **file** with a `/dev/null` bind overlay (`credential-hiding.ts`). For a `dir` entry it masks the enumerated `files`; for a `file` entry it masks the path itself. -- **sbx microVM** mounts the `toolSubdirs` (plus `.copilot`/`.gemini`) wholesale, - because sbx positional mounts are directory-granular and can't overlay +- **Compose (Docker / gVisor)** and the **sbx microVM** replace parents listed in + `narrowPaths` with their explicit descendants. In particular, they mount + rootless tool directories under `~/.local` but never `~/.local` or + `~/.local/state` wholesale, keeping sandboxd's private CA and backing store + outside the guest. Sbx positional mounts are directory-granular and can't overlay `/dev/null` onto a nested path. Before `sbx create` it **moves** each credential `path` aside on the host (to a backup dir at the home root, never itself mounted) and **restores** it after teardown. It only touches entries whose diff --git a/docs/sbx-integration.md b/docs/sbx-integration.md index b4a675cd1..c06e45373 100644 --- a/docs/sbx-integration.md +++ b/docs/sbx-integration.md @@ -150,8 +150,9 @@ Three capability queries drive the rest of the codebase: `sbx daemon status` for diagnostics), then runs `sbx create --name shell [mounts...]`. It translates AWF's Docker-style `host:container:mode` mount strings into sbx's positional `path[:ro]` form, - deduplicates paths, and always adds `/tmp`, `/usr/local/bin`, and `$HOME` so - agent runtime files and installed CLIs (e.g. Copilot) are reachable. + deduplicates paths, and always adds `/tmp`, `/usr/local/bin`, and curated + `$HOME` tool paths so agent runtime files and installed CLIs are reachable + without exposing host-private state. - **`execInSandbox(name, cmd, opts)`** — runs `sbx exec` with optional `--workdir`, `--tty`, and `--env` flags, streams stdout/stderr, maps timeouts to exit code `124`, and returns the command's exit code. @@ -185,7 +186,7 @@ mounts where the host path maps to the identical path inside the VM** The generated command is: ```text -sbx create --name shell [extraMount...] /tmp /usr/local/bin $HOME +sbx create --name shell [extraMount...] /tmp /usr/local/bin [homeToolPath...] ``` (`shell` is sbx's generic agent image, which supplies the guest base OS.) @@ -205,14 +206,17 @@ What `createSandbox()` shares, in order: *narrow*: only `/usr/local/bin`, **not** `/usr`, `/lib`, `/lib64`, or `/opt`. - **`/tmp`** — agent runtime files (rendered prompts, logs). - **`$HOME` tool dirs** — a **curated whitelist** of writable agent dirs, not - the whole home directory. The manager mounts only the subdirs that exist on - the host from `HOME_TOOL_SUBDIRS` (`.cache`, `.config`, `.local`, `.azure`, - `.anthropic`, `.claude`, `.cargo`, `.rustup`, `.npm`, `.nvm`) plus the agent - state dirs `.copilot` and `.gemini`. Credential-store dirs such as `.aws`, + the whole home directory. The manager resolves `HOME_TOOL_PATHS` from the + central policy and mounts only paths that exist on the host. Most entries + are top-level dirs (`.cache`, `.config`, `.azure`, `.anthropic`, `.claude`, + `.cargo`, `.rustup`, `.npm`, `.nvm`, `.copilot`, `.gemini`), while `.local` + is narrowed to rootless tool paths such as `.local/bin`, `.local/lib`, and + `.local/share`. `.local/state` is never mounted because it can contain + sandboxd's private CA and microVM backing store. Credential-store dirs such as `.aws`, `.ssh`, `.docker`, `.kube`, and `.gnupg` are **never** whitelisted, - so they never enter the VM. Each whitelisted dir is mounted **wholesale** (as - a directory — sbx positional mounts cannot target an individual file, so its - loose files like `~/.copilot/mcp-config.json` are preserved). + so they never enter the VM. Each resolved path is mounted as a directory; + sbx positional mounts cannot target an individual file, so loose files like + `~/.copilot/mcp-config.json` are preserved. :::note `.azure` is a credential-bearing exception `.azure` is mounted to provide Azure CLI config and account metadata. However, diff --git a/guest/cloud-hypervisor/build-test-artifacts.sh b/guest/cloud-hypervisor/build-test-artifacts.sh index ff34e6d4c..6b77bef9f 100755 --- a/guest/cloud-hypervisor/build-test-artifacts.sh +++ b/guest/cloud-hypervisor/build-test-artifacts.sh @@ -58,8 +58,32 @@ download_verified() { local url=$1 local expected=$2 local destination=$3 - curl --fail --location --proto '=https' --tlsv1.2 "$url" --output "$destination" - printf '%s %s\n' "$expected" "$destination" | sha256sum --check --status + local partial="${destination}.part" + + rm -f "$destination" + curl \ + --fail \ + --location \ + --proto '=https' \ + --tlsv1.2 \ + --http1.1 \ + --connect-timeout 30 \ + --speed-limit 1024 \ + --speed-time 60 \ + --retry 5 \ + --retry-all-errors \ + --retry-delay 2 \ + --retry-max-time 900 \ + --continue-at - \ + "$url" \ + --output "$partial" + + if ! printf '%s %s\n' "$expected" "$partial" | sha256sum --check --status; then + echo "checksum verification failed for $url" >&2 + rm -f "$partial" + return 1 + fi + mv -- "$partial" "$destination" } # Cloud Hypervisor ships a single statically-linked release binary — no diff --git a/scripts/ci/apply-general-workflow-patches.ts b/scripts/ci/apply-general-workflow-patches.ts index bfeb315c7..b59fab2a7 100644 --- a/scripts/ci/apply-general-workflow-patches.ts +++ b/scripts/ci/apply-general-workflow-patches.ts @@ -28,6 +28,7 @@ import { cacheDateRestoreKeySentinel, issueDuplicationConclusionConcurrencyRegex, issueDuplicationConclusionConcurrencySentinel, + ripgrepInstallStepRegex, } from './workflow-patch-patterns'; import { buildLocalInstallSteps, @@ -51,6 +52,27 @@ export function applyGeneralWorkflowPatches( ): PatchResult { const log: string[] = []; + // Bound the generated installer in smoke workflows and the build-test workflow. + // install_ripgrep.sh uses apt-get update -qq without its own timeout, so a bad + // hosted-runner mirror otherwise leaves the whole agent job running for hours. + const shouldBoundRipgrepInstall = + /(?:^|[/\\])smoke-[^/\\]+\.lock\.yml$/.test(workflowPath) || + workflowPath.endsWith('build-test.lock.yml'); + if (shouldBoundRipgrepInstall) { + ripgrepInstallStepRegex.lastIndex = 0; + const ripgrepInstallMatches = content.match(ripgrepInstallStepRegex); + if (ripgrepInstallMatches) { + content = content.replace( + ripgrepInstallStepRegex, + (_match, indent: string) => + `${indent}- name: Install ripgrep\n` + + `${indent} timeout-minutes: 5\n` + + `${indent} run: timeout --foreground --kill-after=10s 4m bash "\${RUNNER_TEMP}/gh-aw/actions/install_ripgrep.sh"\n` + ); + log.push(` Bounded ${ripgrepInstallMatches.length} ripgrep install step(s)`); + } + } + // The enclave backend starts inside AWF, after mcpg. Keep it in the agent's // gateway config but exempt it from the eager startup connectivity check so // mcpg can rediscover it once AWF attaches and launches the backend. diff --git a/scripts/ci/cloud-hypervisor-ci-scripts.test.ts b/scripts/ci/cloud-hypervisor-ci-scripts.test.ts index ec9efc5f7..10c9e5a5f 100644 --- a/scripts/ci/cloud-hypervisor-ci-scripts.test.ts +++ b/scripts/ci/cloud-hypervisor-ci-scripts.test.ts @@ -4,6 +4,10 @@ import { execFileSync } from 'child_process'; const preflightPath = path.resolve(__dirname, 'cloud-hypervisor-host-preflight.sh'); const smokePath = path.resolve(__dirname, 'cloud-hypervisor-live-smoke.sh'); +const artifactBuildPath = path.resolve( + __dirname, + '../../guest/cloud-hypervisor/build-test-artifacts.sh' +); function shellcheckAvailable(): boolean { try { @@ -14,6 +18,23 @@ function shellcheckAvailable(): boolean { } } +describe('build-test-artifacts.sh', () => { + it('passes bash syntax check', () => { + expect(() => execFileSync('bash', ['-n', artifactBuildPath])).not.toThrow(); + }); + + it('resumes interrupted downloads with bounded retries and verifies before publishing', () => { + const source = fs.readFileSync(artifactBuildPath, 'utf-8'); + expect(source).toContain('--continue-at -'); + expect(source).toContain('--retry-all-errors'); + expect(source).toContain('--retry-max-time 900'); + expect(source).toContain('--http1.1'); + expect(source).toContain('local partial="${destination}.part"'); + expect(source).toContain('sha256sum --check --status'); + expect(source).toContain('mv -- "$partial" "$destination"'); + }); +}); + describe('cloud-hypervisor-host-preflight.sh', () => { it('passes bash syntax check', () => { expect(() => execFileSync('bash', ['-n', preflightPath])).not.toThrow(); diff --git a/scripts/ci/postprocess-smoke-workflows.test.ts b/scripts/ci/postprocess-smoke-workflows.test.ts index a8d7588b2..d68e44a87 100644 --- a/scripts/ci/postprocess-smoke-workflows.test.ts +++ b/scripts/ci/postprocess-smoke-workflows.test.ts @@ -26,6 +26,7 @@ import { copilotModelOverrideRegex, issueDuplicationConclusionConcurrencyRegex, issueDuplicationConclusionConcurrencySentinel, + ripgrepInstallStepRegex, } from './workflow-patch-patterns'; import { buildCopySessionStateStep } from './workflow-step-builders'; @@ -90,6 +91,37 @@ describe('installStepRegex', () => { }); }); +describe('ripgrepInstallStepRegex', () => { + it('matches the generated installer before a timeout is added', () => { + const input = + ' - name: Install ripgrep\n' + + ' run: bash "${RUNNER_TEMP}/gh-aw/actions/install_ripgrep.sh"\n'; + + expect(ripgrepInstallStepRegex.test(input)).toBe(true); + ripgrepInstallStepRegex.lastIndex = 0; + }); + + it('matches an installer that already has a step timeout', () => { + const input = + ' - name: Install ripgrep\n' + + ' timeout-minutes: 5\n' + + ' run: bash "${RUNNER_TEMP}/gh-aw/actions/install_ripgrep.sh"\n'; + + expect(ripgrepInstallStepRegex.test(input)).toBe(true); + ripgrepInstallStepRegex.lastIndex = 0; + }); + + it('matches the fully bounded installer for idempotent replacement', () => { + const input = + ' - name: Install ripgrep\n' + + ' timeout-minutes: 5\n' + + ' run: timeout --foreground --kill-after=10s 4m bash "${RUNNER_TEMP}/gh-aw/actions/install_ripgrep.sh"\n'; + + expect(ripgrepInstallStepRegex.test(input)).toBe(true); + ripgrepInstallStepRegex.lastIndex = 0; + }); +}); + // ── Duplicate Setup Node.js collapse regex test ─────────────────────────── // The backreference guarantees only byte-identical consecutive blocks collapse. diff --git a/scripts/ci/workflow-patch-patterns.ts b/scripts/ci/workflow-patch-patterns.ts index dae02a866..ae96f3bc6 100644 --- a/scripts/ci/workflow-patch-patterns.ts +++ b/scripts/ci/workflow-patch-patterns.ts @@ -12,6 +12,13 @@ export const installStepRegex = /^(\s*)- name: Install [Aa][Ww][Ff] binary\n\1\s*run: bash "?(?:\/opt\/gh-aw|\$\{RUNNER_TEMP\}\/gh-aw)\/actions\/install_awf_binary\.sh"? v[0-9.]+[^\n]*\n/m; export const installStepRegexGlobal = new RegExp(installStepRegex.source, 'gm'); +// Matches the generated ripgrep installer step with or without the timeout +// wrapper added by post-processing. +// The setup action falls back to a quiet apt-get update, which can otherwise hang +// for hours when a hosted runner's package mirror stops responding. +export const ripgrepInstallStepRegex = + /^(\s+)- name: Install ripgrep\n(?:\1 timeout-minutes: 5\n)?\1 run: (?:timeout --foreground --kill-after=10s 4m )?bash "\$\{RUNNER_TEMP\}\/gh-aw\/actions\/install_ripgrep\.sh"\n/gm; + // Collapse duplicate "Setup Node.js" steps: buildLocalInstallSteps injects a // Setup Node.js step but some workflows already emit an identical one immediately // before the install step. The backreference only matches byte-identical blocks. diff --git a/src/chroot-home-setup.ts b/src/chroot-home-setup.ts index 711b3c6a3..b2b0322fb 100644 --- a/src/chroot-home-setup.ts +++ b/src/chroot-home-setup.ts @@ -5,6 +5,7 @@ import { logger } from './logger'; import { getSafeHostUid, getSafeHostGid, getRealUserHome } from './host-env'; import { assertRealDirectory, createMissingOwnedDirectorySegments } from './fs-utils'; import { resolveRunnerToolCachePath } from './runner-tool-cache'; +import { HOME_TOOL_PATHS } from './config/mount-policy'; // Prepare a nested bind-mount destination inside the empty chroot home before // Docker sees it. Without this, Docker may create intermediate parents such as @@ -61,24 +62,32 @@ export function prepareChrootHomeMounts(config: WrapperConfig): void { fs.chownSync(emptyHomeDir, uid, gid); logger.debug(`Created chroot home directory: ${emptyHomeDir} (${uid}:${gid})`); - // Ensure source directories for home subdirectory mounts exist with correct ownership. - const hostHomeMountSourceDirs = [ - '.copilot', '.cache', '.config', '.local', - '.anthropic', '.claude', '.cargo', '.rustup', '.npm', '.nvm', - ...(config.geminiApiKey || config.googleApiKey ? ['.gemini'] : []), - ]; - for (const dir of hostHomeMountSourceDirs) { - const dirPath = path.join(effectiveHome, dir); - if (!fs.existsSync(dirPath)) { - fs.mkdirSync(dirPath, { recursive: true }); - fs.chownSync(dirPath, uid, gid); - logger.debug(`Created host home subdirectory: ${dirPath} (${uid}:${gid})`); - } else if (dir === '.gemini') { + // Ensure source directories and nested chroot mountpoints exist before Docker + // sees them, so it cannot create either side as root-owned. + const hostHomeMountSourcePaths = HOME_TOOL_PATHS.filter( + (toolPath) => + toolPath !== '.gemini' || Boolean(config.geminiApiKey || config.googleApiKey), + ); + for (const toolPath of hostHomeMountSourcePaths) { + const toolPathSource = path.join(effectiveHome, toolPath); + const existed = fs.existsSync(toolPathSource); + createMissingOwnedDirectorySegments(toolPathSource, uid, gid); + if (!existed) { + logger.debug(`Created host home tool path: ${toolPathSource} (${uid}:${gid})`); + } else if (toolPath === '.gemini') { // Repair existing .gemini ownership for Gemini/Vertex runs where prior // root-owned bind mounts can break atomic writes in the CLI. - fs.chownSync(dirPath, uid, gid); - logger.debug(`Fixed host home subdirectory ownership: ${dirPath} (${uid}:${gid})`); + fs.chownSync(toolPathSource, uid, gid); + logger.debug(`Fixed host home tool path ownership: ${toolPathSource} (${uid}:${gid})`); } + + const chrootToolPath = prepareChrootHomeMountpoint( + emptyHomeDir, + toolPath, + uid, + gid, + ); + logger.debug(`Prepared chroot home tool mountpoint: ${chrootToolPath} (${uid}:${gid})`); } // Source-side prep: this only applies when the config file explicitly names diff --git a/src/config/mount-policy-validation.test.ts b/src/config/mount-policy-validation.test.ts index f1de9daf0..559077cc1 100644 --- a/src/config/mount-policy-validation.test.ts +++ b/src/config/mount-policy-validation.test.ts @@ -38,6 +38,7 @@ const validJson = { }, home: { toolSubdirs: ['.cache', '.config'], + narrowPaths: {}, forbiddenSubdirs: ['.ssh', '.aws'], }, credentials: { @@ -231,6 +232,38 @@ describe('mount-policy validate – assertHomeSubdirArray', () => { }); }); +describe('mount-policy validate – narrowPaths', () => { + it('throws when a narrowed parent is not in toolSubdirs', async () => { + const bad = { + ...validJson, + home: { ...validJson.home, narrowPaths: { '.local': ['.local/bin'] } }, + }; + await expect(loadModule(bad)).rejects.toThrow( + 'home.narrowPaths key must also appear in home.toolSubdirs', + ); + }); + + it('throws when a narrowed path is not a descendant of its parent', async () => { + const bad = { + ...validJson, + home: { ...validJson.home, narrowPaths: { '.cache': ['.config/tool'] } }, + }; + await expect(loadModule(bad)).rejects.toThrow( + 'home.narrowPaths[".cache"] entries must be descendants of .cache', + ); + }); + + it('throws when a narrowed path list is empty', async () => { + const bad = { + ...validJson, + home: { ...validJson.home, narrowPaths: { '.cache': [] } }, + }; + await expect(loadModule(bad)).rejects.toThrow( + 'home.narrowPaths[".cache"] must not be empty', + ); + }); +}); + // ─── parseCredentials ───────────────────────────────────────────────────────── describe('mount-policy validate – parseCredentials', () => { diff --git a/src/config/mount-policy.test.ts b/src/config/mount-policy.test.ts index d5de47789..0cb3c85b8 100644 --- a/src/config/mount-policy.test.ts +++ b/src/config/mount-policy.test.ts @@ -1,10 +1,11 @@ import { mountPolicy, HOME_TOOL_SUBDIRS, + HOME_TOOL_PATHS, HOME_FORBIDDEN_SUBDIRS, CREDENTIAL_ENTRIES, credentialFilesToHide, - credentialEntriesUnderMountedParents, + credentialEntriesUnderMountedPaths, systemDirectories, etcAllowlist, } from './mount-policy'; @@ -48,6 +49,20 @@ describe('mount-policy', () => { expect(new Set(HOME_TOOL_SUBDIRS).size).toBe(HOME_TOOL_SUBDIRS.length); }); + it('narrows .local mounts to tool paths outside .local/state', () => { + expect(HOME_TOOL_PATHS).toEqual( + expect.arrayContaining([ + '.local/bin', + '.local/lib', + '.local/lib64', + '.local/share', + '.local/pipx', + ]), + ); + expect(HOME_TOOL_PATHS).not.toContain('.local'); + expect(HOME_TOOL_PATHS.some((entry) => entry.startsWith('.local/state'))).toBe(false); + }); + it('home.forbiddenSubdirs entries are simple relative names without traversal', () => { for (const dir of HOME_FORBIDDEN_SUBDIRS) { expect(dir).not.toBe(''); @@ -113,10 +128,10 @@ describe('mount-policy', () => { }); }); - describe('credentialEntriesUnderMountedParents', () => { - it('includes only entries whose top-level parent is mounted', () => { + describe('credentialEntriesUnderMountedPaths', () => { + it('includes only entries that overlap mounted paths', () => { const mounted = new Set(['.config', '.cargo', '.claude', '.copilot', '.gemini', '.azure']); - const entries = credentialEntriesUnderMountedParents(mounted); + const entries = credentialEntriesUnderMountedPaths(mounted); const paths = entries.map((e) => e.path); expect(paths).toContain('.config/gh'); @@ -134,8 +149,16 @@ describe('mount-policy', () => { expect(paths).not.toContain('.npmrc'); }); + it('excludes credential entries under narrowed-away paths', () => { + const entries = credentialEntriesUnderMountedPaths( + new Set(['.local/bin', '.local/lib', '.local/share']), + ); + + expect(entries.map((entry) => entry.path)).not.toContain('.local/state/sandboxes'); + }); + it('returns nothing when no parents are mounted', () => { - expect(credentialEntriesUnderMountedParents(new Set())).toHaveLength(0); + expect(credentialEntriesUnderMountedPaths(new Set())).toHaveLength(0); }); }); diff --git a/src/config/mount-policy.ts b/src/config/mount-policy.ts index e0b144292..3fbc19501 100644 --- a/src/config/mount-policy.ts +++ b/src/config/mount-policy.ts @@ -46,6 +46,7 @@ interface MountPolicy { }; readonly home: { readonly toolSubdirs: readonly string[]; + readonly narrowPaths: Readonly>; readonly forbiddenSubdirs: readonly string[]; }; readonly credentials: readonly CredentialEntry[]; @@ -82,6 +83,47 @@ function assertHomeSubdirArray(value: unknown, label: string): readonly string[] return arr; } +function parseNarrowPaths( + value: unknown, + toolSubdirs: readonly string[], +): Readonly> { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + fail('home.narrowPaths must be an object'); + } + + const result: Record = {}; + for (const [parent, rawPaths] of Object.entries(value)) { + if (!toolSubdirs.includes(parent)) { + fail(`home.narrowPaths key must also appear in home.toolSubdirs: ${parent}`); + } + const label = `home.narrowPaths[${JSON.stringify(parent)}]`; + const paths = assertStringArray(rawPaths, label); + if (paths.length === 0) { + fail(`${label} must not be empty`); + } + const seen = new Set(); + for (const child of paths) { + if ( + child.length === 0 || + child.startsWith('/') || + child.startsWith('~') || + child.includes('..') || + !child.startsWith(`${parent}/`) + ) { + fail( + `${label} entries must be descendants of ${parent}: ${child}`, + ); + } + if (seen.has(child)) { + fail(`${label} has duplicate entry: ${child}`); + } + seen.add(child); + } + result[parent] = paths; + } + return result; +} + /** * Validates an array of absolute host paths: each entry must start with `/`, * must not be empty, must not contain `..`, and must be unique. @@ -172,6 +214,7 @@ function validate(input: unknown): MountPolicy { fail('home is required'); } + const toolSubdirs = assertHomeSubdirArray(home.toolSubdirs, 'home.toolSubdirs'); return { system: { directories: { @@ -181,7 +224,8 @@ function validate(input: unknown): MountPolicy { etc: assertAbsolutePathArray(system.etc, 'system.etc'), }, home: { - toolSubdirs: assertHomeSubdirArray(home.toolSubdirs, 'home.toolSubdirs'), + toolSubdirs, + narrowPaths: parseNarrowPaths(home.narrowPaths, toolSubdirs), forbiddenSubdirs: assertHomeSubdirArray(home.forbiddenSubdirs, 'home.forbiddenSubdirs'), }, credentials: parseCredentials(p.credentials), @@ -197,6 +241,14 @@ export const mountPolicy: MountPolicy = validate(rawPolicy); */ export const HOME_TOOL_SUBDIRS: readonly string[] = mountPolicy.home.toolSubdirs; +/** + * `$HOME`-relative directories exposed to agents. Policy overrides replace + * sensitive wholesale parents with explicit safe descendants. + */ +export const HOME_TOOL_PATHS: readonly string[] = HOME_TOOL_SUBDIRS.flatMap( + (subdir) => mountPolicy.home.narrowPaths[subdir] ?? [subdir], +); + /** * `$HOME` subdirectories whose primary purpose is storing credentials. These * must NEVER appear in {@link HOME_TOOL_SUBDIRS}; the export exists so tests can @@ -229,16 +281,24 @@ export function credentialFilesToHide(): string[] { /** * Credential entries the sbx backend should move aside before `sbx create`: - * those whose top-level parent directory is one of the wholesale-mounted home - * dirs in {@link mountedTopLevelParents}. Entries under never-mounted dirs (e.g. - * `.ssh`, `.aws`) are excluded because they never enter the microVM anyway. + * those whose path overlaps one of the mounted home paths. Entries under + * never-mounted or narrowed-away paths are excluded because they never enter + * the microVM. */ -export function credentialEntriesUnderMountedParents( - mountedTopLevelParents: ReadonlySet, +export function credentialEntriesUnderMountedPaths( + mountedPaths: ReadonlySet, ): CredentialEntry[] { return mountPolicy.credentials.filter((entry) => { - const top = entry.path.split('/')[0]; - return mountedTopLevelParents.has(top); + for (const mountedPath of mountedPaths) { + if ( + entry.path === mountedPath || + entry.path.startsWith(`${mountedPath}/`) || + mountedPath.startsWith(`${entry.path}/`) + ) { + return true; + } + } + return false; }); } diff --git a/src/config/sandbox-mount-policy.json b/src/config/sandbox-mount-policy.json index d5cc6e67c..b8cb7a6dc 100644 --- a/src/config/sandbox-mount-policy.json +++ b/src/config/sandbox-mount-policy.json @@ -9,7 +9,7 @@ "etc": ["/etc/ssl", "/etc/ca-certificates", "/etc/pki/ca-trust/extracted", "/etc/pki/tls/certs", "/etc/alternatives", "/etc/ld.so.cache", "/etc/nsswitch.conf"] }, "home": { - "$comment": "Agent $HOME exposure. `toolSubdirs` is the ALLOW list: tool caches, language toolchains and agent state the agent legitimately needs. `forbiddenSubdirs` is a DENY guard: dirs whose primary purpose is storing credentials and which must NEVER be added to the allow list. Compose mounts an empty home + binds toolSubdirs on top; sbx mounts toolSubdirs wholesale instead of the whole $HOME. EXCEPTION: `.azure` is credential-bearing — it is intentionally mounted to provide Azure CLI config and account metadata, but its live token caches (msal_token_cache.bin, msal_token_cache.json, accessTokens.json, service_principal_entries.json) are masked by the credentials deny list so agents cannot read host auth tokens directly. Azure API auth must use the api-proxy's sidecar-only OIDC exchange or another trusted external service; ADO MCP may use its separate ADO_MCP_AUTH_TOKEN env var.", + "$comment": "Agent $HOME exposure. `toolSubdirs` is the ALLOW list: tool caches, language toolchains and agent state the agent legitimately needs. `narrowPaths` replaces a wholesale mount with explicit safe descendants for every runtime; this keeps `.local/state`, including sandboxd's private runtime state, outside the agent while retaining rootless tools. `forbiddenSubdirs` is a DENY guard: dirs whose primary purpose is storing credentials and which must NEVER be added to the allow list. Compose mounts an empty home + binds the resolved tool paths on top. EXCEPTION: `.azure` is credential-bearing — it is intentionally mounted to provide Azure CLI config and account metadata, but its live token caches (msal_token_cache.bin, msal_token_cache.json, accessTokens.json, service_principal_entries.json) are masked by the credentials deny list so agents cannot read host auth tokens directly. Azure API auth must use the api-proxy's sidecar-only OIDC exchange or another trusted external service; ADO MCP may use its separate ADO_MCP_AUTH_TOKEN env var.", "toolSubdirs": [ ".cache", ".config", @@ -24,6 +24,15 @@ ".copilot", ".gemini" ], + "narrowPaths": { + ".local": [ + ".local/bin", + ".local/lib", + ".local/lib64", + ".local/share", + ".local/pipx" + ] + }, "forbiddenSubdirs": [ ".aws", ".ssh", @@ -59,6 +68,7 @@ { "path": ".gemini/oauth_creds.json", "type": "file", "reason": "Gemini CLI OAuth tokens" }, { "path": ".gemini/google_accounts.json", "type": "file", "reason": "Gemini CLI account identity" }, { "path": ".gemini/access_tokens.json", "type": "file", "reason": "Gemini CLI cached access tokens" }, + { "path": ".local/state/sandboxes", "type": "dir", "reason": "sandboxd private CA keys and microVM backing store" }, { "path": ".config/gh", "type": "dir", "files": ["hosts.yml"], "reason": "GitHub CLI OAuth token" }, { "path": ".config/gcloud", "type": "dir", "files": ["credentials.db", "access_tokens.db", "application_default_credentials.json"], "reason": "Google Cloud SDK credentials" }, { "path": ".config/doctl", "type": "dir", "files": ["config.yaml"], "reason": "DigitalOcean CLI token" }, diff --git a/src/docker-manager-write-configs.test.ts b/src/docker-manager-write-configs.test.ts index 7ef68230e..b0f7c18d7 100644 --- a/src/docker-manager-write-configs.test.ts +++ b/src/docker-manager-write-configs.test.ts @@ -160,12 +160,14 @@ describe('docker-manager writeConfigs', () => { await writeConfigsAllowingFailure(config); const expectedDirs = [ - '.copilot', '.cache', '.config', '.local', + '.copilot', '.cache', '.config', + '.local/bin', '.local/lib', '.local/lib64', '.local/share', '.local/pipx', '.anthropic', '.claude', '.cargo', '.rustup', '.npm', '.nvm', ]; for (const dir of expectedDirs) { expect(fs.existsSync(path.join(fakeHome, dir))).toBe(true); } + expect(fs.existsSync(path.join(fakeHome, '.local/state'))).toBe(false); expect(fs.existsSync(path.join(fakeHome, '.gemini'))).toBe(false); } finally { if (originalHome !== undefined) { diff --git a/src/enclave/mount-policy.ts b/src/enclave/mount-policy.ts index b15b129d9..a2f0483d6 100644 --- a/src/enclave/mount-policy.ts +++ b/src/enclave/mount-policy.ts @@ -1,6 +1,6 @@ import * as fs from 'fs'; import * as path from 'path'; -import { etcAllowlist, HOME_TOOL_SUBDIRS, systemDirectories } from '../config/mount-policy'; +import { etcAllowlist, HOME_TOOL_PATHS, systemDirectories } from '../config/mount-policy'; import { getRealUserHome } from '../host-identity'; import type { WrapperConfig } from '../types'; import { applyHostPathPrefixToVolumes } from '../services/host-path-prefix'; @@ -119,9 +119,9 @@ function collectAgentVisiblePaths( ...etcAllowlist().map((source) => ({ label: 'Compose /etc mount', source })), { label: 'Compose identity mount', source: '/etc/passwd' }, { label: 'Compose identity mount', source: '/etc/group' }, - ...HOME_TOOL_SUBDIRS.map((subdir) => ({ - label: `home tool directory ${subdir}`, - source: path.join(home, subdir), + ...HOME_TOOL_PATHS.map((toolPath) => ({ + label: `home tool directory ${toolPath}`, + source: path.join(home, toolPath), })), { label: 'runner tool cache fallback', source: path.join(home, 'work', '_tool') }, ]; diff --git a/src/microvm/workspace.test.ts b/src/microvm/workspace.test.ts index 9d4aedc24..49555e858 100644 --- a/src/microvm/workspace.test.ts +++ b/src/microvm/workspace.test.ts @@ -35,8 +35,12 @@ describe('microVM workspace images', () => { await fs.chmod(path.join(workspace, 'bin', 'run'), 0o755); await fs.symlink('bin/run', path.join(workspace, 'run')); await fs.mkdir(path.join(home, '.config', 'gh'), { recursive: true }); + await fs.mkdir(path.join(home, '.local', 'bin'), { recursive: true }); + await fs.mkdir(path.join(home, '.local', 'state', 'sandboxes'), { recursive: true }); await fs.writeFile(path.join(home, '.config', 'safe'), 'keep'); await fs.writeFile(path.join(home, '.config', 'gh', 'hosts.yml'), 'secret'); + await fs.writeFile(path.join(home, '.local', 'bin', 'tool'), 'allowed'); + await fs.writeFile(path.join(home, '.local', 'state', 'sandboxes', 'ca.pem'), 'secret'); await fs.writeFile(baseRootfs, 'rootfs'); await fs.writeFile(supervisor, 'binary'); const commands: Array<{ command: string; args: readonly string[] }> = []; @@ -77,6 +81,13 @@ describe('microVM workspace images', () => { await expect(fs.access( path.join(image.stagingDirectory, 'workspace', '.awf-home', '.config', 'gh'), )).rejects.toThrow(); + expect(await fs.readFile( + path.join(image.stagingDirectory, 'workspace', '.awf-home', '.local', 'bin', 'tool'), + 'utf8', + )).toBe('allowed'); + await expect(fs.access( + path.join(image.stagingDirectory, 'workspace', '.awf-home', '.local', 'state'), + )).rejects.toThrow(); expect(commands.map(({ command }) => command)).toEqual([ 'mke2fs', 'debugfs', 'debugfs', 'debugfs', 'e2fsck', ]); diff --git a/src/microvm/workspace.ts b/src/microvm/workspace.ts index fe6782188..a1b094629 100644 --- a/src/microvm/workspace.ts +++ b/src/microvm/workspace.ts @@ -4,7 +4,7 @@ import * as path from 'path'; import execa from 'execa'; import { CREDENTIAL_ENTRIES, - HOME_TOOL_SUBDIRS, + HOME_TOOL_PATHS, } from '../config/mount-policy'; import { MicrovmRootfsPreparer } from './rootfs'; @@ -262,8 +262,16 @@ export class MicrovmWorkspaceImage { private async copyAllowedHomeState(): Promise { const excluded = CREDENTIAL_ENTRIES.map((entry) => normalizeRelative(entry.path)); - for (const subdir of HOME_TOOL_SUBDIRS) { - const source = path.join(this.config.homePath, subdir); + const homeDestinationRoot = path.join( + this.stagingDirectory, + 'workspace', + '.awf-home', + ); + await fs.mkdir(homeDestinationRoot, { recursive: true, mode: 0o700 }); + await applySafeOwnership(homeDestinationRoot, this.config.uid, this.config.gid); + + for (const toolPath of HOME_TOOL_PATHS) { + const source = path.join(this.config.homePath, toolPath); let stat; try { stat = await fs.lstat(source); @@ -274,14 +282,12 @@ export class MicrovmWorkspaceImage { if (!stat.isDirectory() || stat.isSymbolicLink()) { throw new Error(`Allowed home state must be a real directory: ${source}`); } - const destination = path.join( - this.stagingDirectory, - 'workspace', - '.awf-home', - subdir, - ); - await fs.mkdir(destination, { recursive: true, mode: 0o700 }); - await applySafeOwnership(destination, this.config.uid, this.config.gid); + let destination = homeDestinationRoot; + for (const segment of toolPath.split('/')) { + destination = path.join(destination, segment); + await fs.mkdir(destination, { recursive: true, mode: 0o700 }); + await applySafeOwnership(destination, this.config.uid, this.config.gid); + } await copySafeTree( source, destination, diff --git a/src/sbx-manager.test.ts b/src/sbx-manager.test.ts index a1c25e226..84eedd8dd 100644 --- a/src/sbx-manager.test.ts +++ b/src/sbx-manager.test.ts @@ -33,6 +33,14 @@ jest.mock('fs', () => { renameSync: jest.fn(() => undefined), mkdirSync: jest.fn(() => undefined), rmSync: jest.fn(() => undefined), + lstatSync: jest.fn(() => ({ + isDirectory: () => true, + isSymbolicLink: () => false, + })), + statSync: jest.fn(() => ({ + isDirectory: () => true, + })), + realpathSync: jest.fn((p: fs.PathLike) => String(p)), }; }); @@ -41,6 +49,9 @@ const mockedReaddirSync = fs.readdirSync as jest.Mock; const mockedRenameSync = fs.renameSync as jest.Mock; const mockedMkdirSync = fs.mkdirSync as jest.Mock; const mockedRmSync = fs.rmSync as jest.Mock; +const mockedLstatSync = fs.lstatSync as jest.Mock; +const mockedStatSync = fs.statSync as jest.Mock; +const mockedRealpathSync = jest.mocked(fs.realpathSync); const mockedLogger = jest.mocked(logger); @@ -150,6 +161,24 @@ describe('sbx-manager', () => { mockedReaddirSync.mockReturnValue([]); mockedRenameSync.mockReset(); mockedRenameSync.mockReturnValue(undefined); + mockedLstatSync.mockReset(); + mockedLstatSync.mockImplementation((p: fs.PathLike) => { + if (!mockedExistsSync(p)) { + throw Object.assign(new Error(`ENOENT: no such file or directory, lstat '${String(p)}'`), { + code: 'ENOENT', + }); + } + return { + isDirectory: () => true, + isSymbolicLink: () => false, + }; + }); + mockedStatSync.mockReset(); + mockedStatSync.mockReturnValue({ + isDirectory: () => true, + }); + mockedRealpathSync.mockReset(); + mockedRealpathSync.mockImplementation((p: fs.PathLike) => String(p)); // Ensure no scrubbed state leaks between tests. restoreHomeCredentials(); mockedRenameSync.mockReset(); @@ -266,6 +295,29 @@ describe('sbx-manager', () => { expect(args).not.toContain(`${homePath}/.docker`); }); + it('never exposes sandboxd state through the sbx .local mount', async () => { + const homePath = process.env.HOME || '/home/runner'; + mockedExistsSync.mockImplementation((p: fs.PathLike) => [ + `${homePath}/.local`, + `${homePath}/.local/bin`, + `${homePath}/.local/share`, + `${homePath}/.local/state`, + `${homePath}/.local/state/sandboxes`, + ].includes(String(p))); + mockExecaFn + .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }) + .mockResolvedValueOnce({ exitCode: 0, stdout: 'Created sandbox', stderr: '' }); + + await createSandbox({ workspaceDir: '/workspace', squidIp: '172.30.0.10' }); + + const args: string[] = mockExecaFn.mock.calls[1][1]; + expect(args).toContain(`${homePath}/.local/bin`); + expect(args).toContain(`${homePath}/.local/share`); + expect(args).not.toContain(`${homePath}/.local`); + expect(args).not.toContain(`${homePath}/.local/state`); + expect(args).not.toContain(`${homePath}/.local/state/sandboxes`); + }); + it('mounts credential-nesting tool dirs wholesale and scrubs nested secrets before create', async () => { const homePath = process.env.HOME || '/home/runner'; const parents = [ @@ -301,6 +353,37 @@ describe('sbx-manager', () => { restoreHomeCredentials(); }); + it('refuses symlinked home tool dirs before passing mounts to sbx', async () => { + const homePath = process.env.HOME || '/home/runner'; + mockedExistsSync.mockImplementation((p: fs.PathLike) => ( + String(p) === `${homePath}/.local/bin` + )); + mockedLstatSync.mockImplementation((p: fs.PathLike) => { + if (!mockedExistsSync(p)) { + throw Object.assign(new Error(`ENOENT: no such file or directory, lstat '${String(p)}'`), { + code: 'ENOENT', + }); + } + if (String(p) === `${homePath}/.local/bin`) { + return { + isDirectory: () => false, + isSymbolicLink: () => true, + }; + } + return { + isDirectory: () => true, + isSymbolicLink: () => false, + }; + }); + mockExecaFn.mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }); + + await expect(createSandbox({ + workspaceDir: '/workspace', + squidIp: '172.30.0.10', + })).rejects.toThrow(`Refusing to use symlink as directory: ${homePath}/.local/bin`); + expect(mockExecaFn).toHaveBeenCalledTimes(1); + }); + it('mounts ~/.config wholesale and scrubs nested credential dirs before create', async () => { const homePath = process.env.HOME || '/home/runner'; const secrets = [ diff --git a/src/sbx-manager.ts b/src/sbx-manager.ts index 59d7e123b..8a77415f3 100644 --- a/src/sbx-manager.ts +++ b/src/sbx-manager.ts @@ -27,8 +27,11 @@ import * as fs from 'fs'; import * as path from 'path'; import { copyEnvEntries } from './env-utils'; import { logger } from './logger'; -import { HOME_TOOL_SUBDIRS } from './services/agent-volumes/home-whitelist'; -import { credentialEntriesUnderMountedParents } from './config/mount-policy'; +import { + credentialEntriesUnderMountedPaths, + HOME_TOOL_PATHS, +} from './config/mount-policy'; +import { assertRealDirectory } from './fs-utils'; import { getRealUserHome } from './host-identity'; /** Name prefix for AWF-managed sandboxes. */ @@ -120,12 +123,14 @@ let credentialBackupRoot: string | undefined; * credential overlays; the credential list comes from the central mount policy * so the two backends can't drift. */ -function scrubHomeCredentials(homePath: string): void { +function scrubHomeCredentials( + homePath: string, + mountedHomePaths: ReadonlySet = new Set(HOME_TOOL_PATHS), +): void { scrubbedCredentials = []; credentialBackupRoot = undefined; - const mountedParents = new Set(HOME_TOOL_SUBDIRS); - for (const entry of credentialEntriesUnderMountedParents(mountedParents)) { + for (const entry of credentialEntriesUnderMountedPaths(mountedHomePaths)) { const original = path.join(homePath, entry.path); if (!fs.existsSync(original)) continue; @@ -159,6 +164,34 @@ function scrubHomeCredentials(homePath: string): void { } } +function resolveExistingHomeToolDirectory( + homePath: string, + toolPath: string, +): { source: string; relativeHomePath?: string } | undefined { + const source = path.join(homePath, toolPath); + try { + assertRealDirectory(source); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return undefined; + } + throw error; + } + + const resolvedSource = fs.realpathSync(source); + const resolvedHome = fs.realpathSync(homePath); + const relativeHomePath = path.relative(resolvedHome, resolvedSource); + const isUnderHome = Boolean(relativeHomePath) && + !relativeHomePath.startsWith('..') && + !path.isAbsolute(relativeHomePath); + return { + source: resolvedSource, + relativeHomePath: isUnderHome + ? relativeHomePath.split(path.sep).join('/') + : undefined, + }; +} + /** * Restores any credential paths that {@link scrubHomeCredentials} moved aside. * Idempotent and non-throwing; safe to call even when nothing was scrubbed. @@ -275,9 +308,12 @@ export async function createSandbox(config: { // positional (host path == guest path) and cannot express the per-file // /dev/null credential overlays that compose mode uses (see // credential-hiding.ts), so the only way to keep host secrets out of the VM - // is to curate which $HOME subdirs are mounted. The central mount policy - // (HOME_TOOL_SUBDIRS) lists the allowed tool-state dirs including agent-state - // dirs (.copilot, .gemini). Credential stores such as ~/.aws, ~/.ssh, + // is to curate which $HOME paths are mounted. The central mount policy + // (HOME_TOOL_PATHS) lists allowed tool-state paths including agent-state + // dirs (.copilot, .gemini). Sensitive wholesale parents can be replaced with + // narrow descendants: ~/.local exposes rootless tool paths but not + // ~/.local/state, where sandboxd stores its CA key and microVM backing store. + // Credential stores such as ~/.aws, ~/.ssh, // ~/.docker, ~/.kube, ~/.gnupg, ~/.netrc and ~/.gitconfig are never // whitelisted, so they never enter the sandbox. Only paths that exist on the // host are mounted, because sbx requires the mount source to exist. @@ -297,19 +333,24 @@ export async function createSandbox(config: { // diverge under sudo (e.g. /root vs /home/alice), mounting .local at a path // the guest's $HOME never points at and hiding a rootless-installed binary. const homePath = getRealUserHome(); - for (const subdir of HOME_TOOL_SUBDIRS) { - const hostSubdir = `${homePath}/${subdir}`; - if (seenPaths.has(hostSubdir)) continue; - if (!fs.existsSync(hostSubdir)) continue; - seenPaths.add(hostSubdir); - args.push(hostSubdir); + const mountedHomePaths = new Set(); + for (const toolPath of HOME_TOOL_PATHS) { + const resolved = resolveExistingHomeToolDirectory(homePath, toolPath); + if (!resolved) continue; + const hostToolPath = resolved.source; + if (seenPaths.has(hostToolPath)) continue; + seenPaths.add(hostToolPath); + args.push(hostToolPath); + if (resolved.relativeHomePath) { + mountedHomePaths.add(resolved.relativeHomePath); + } } logger.info(`[sbx] Running: sbx ${args.join(' ')}`); // Move known credential stores out of the wholesale-mounted home dirs before // the sandbox exists, and remember them so they can be restored on teardown. - scrubHomeCredentials(homePath); + scrubHomeCredentials(homePath, mountedHomePaths); // Do NOT pass a custom `env` to sbx create. The sanitized env (which strips // vars matching TOKEN, SECRET, KEY, etc.) also strips variables the sbx CLI diff --git a/src/services/agent-volumes/home-strategy.test.ts b/src/services/agent-volumes/home-strategy.test.ts index 6a3b38e14..082a4001b 100644 --- a/src/services/agent-volumes/home-strategy.test.ts +++ b/src/services/agent-volumes/home-strategy.test.ts @@ -55,6 +55,21 @@ describe('buildHomeMounts', () => { expect(mounts).toContain('/home/runner/.azure:/host/home/runner/.azure:rw'); }); + it('mounts safe ~/.local tool paths without exposing sandboxd state', () => { + (fs.existsSync as jest.Mock).mockImplementation(() => false); + + const mounts = buildHomeMounts(makeParams()); + + expect(mounts).toContain( + '/home/runner/.local/bin:/host/home/runner/.local/bin:rw', + ); + expect(mounts).toContain( + '/home/runner/.local/share:/host/home/runner/.local/share:rw', + ); + expect(mounts).not.toContain('/home/runner/.local:/host/home/runner/.local:rw'); + expect(mounts.some((mount) => mount.includes('/.local/state'))).toBe(false); + }); + describe('~/.copilot access error handling', () => { it('includes error.message in warning when accessSync throws an Error instance', () => { mockExistsForCopilot(); diff --git a/src/services/agent-volumes/home-strategy.ts b/src/services/agent-volumes/home-strategy.ts index 100436d81..90baba090 100644 --- a/src/services/agent-volumes/home-strategy.ts +++ b/src/services/agent-volumes/home-strategy.ts @@ -3,7 +3,7 @@ import * as path from 'path'; import { logger } from '../../logger'; import { resolveRunnerToolCachePath } from '../../runner-tool-cache'; import { WrapperConfig } from '../../types'; -import { HOME_TOOL_SUBDIRS } from './home-whitelist'; +import { HOME_TOOL_PATHS } from './home-whitelist'; interface HomeMountsParams { config: WrapperConfig; @@ -43,10 +43,10 @@ function buildToolDirectoryMounts(params: HomeMountsParams): string[] { mounts.push(`${sessionStatePath}:/host${effectiveHome}/.copilot/session-state:rw`); mounts.push(`${agentLogsPath}:/host${effectiveHome}/.copilot/logs:rw`); - for (const subdir of HOME_TOOL_SUBDIRS) { - if (subdir === '.copilot') continue; // handled specially above (existence check + session-state/logs sub-mounts) - if (subdir === '.gemini' && !config.geminiApiKey && !config.googleApiKey) continue; // only mount when Gemini/Vertex credentials are present - mounts.push(`${effectiveHome}/${subdir}:/host${effectiveHome}/${subdir}:rw`); + for (const toolPath of HOME_TOOL_PATHS) { + if (toolPath === '.copilot') continue; // handled specially above (existence check + session-state/logs sub-mounts) + if (toolPath === '.gemini' && !config.geminiApiKey && !config.googleApiKey) continue; // only mount when Gemini/Vertex credentials are present + mounts.push(`${effectiveHome}/${toolPath}:/host${effectiveHome}/${toolPath}:rw`); } const runnerToolCacheDir = resolveRunnerToolCachePath(config, effectiveHome); diff --git a/src/services/agent-volumes/home-whitelist.test.ts b/src/services/agent-volumes/home-whitelist.test.ts index 0e0ca57df..fc6f111e8 100644 --- a/src/services/agent-volumes/home-whitelist.test.ts +++ b/src/services/agent-volumes/home-whitelist.test.ts @@ -1,4 +1,4 @@ -import { HOME_TOOL_SUBDIRS, HOME_FORBIDDEN_SUBDIRS } from './home-whitelist'; +import { HOME_TOOL_PATHS, HOME_TOOL_SUBDIRS, HOME_FORBIDDEN_SUBDIRS } from './home-whitelist'; describe('home-whitelist (mount-policy shim)', () => { it('re-exports the shared home allow list', () => { @@ -16,6 +16,14 @@ describe('home-whitelist (mount-policy shim)', () => { ); }); + it('replaces the wholesale .local mount with safe tool paths', () => { + expect(HOME_TOOL_PATHS).toEqual( + expect.arrayContaining(['.local/bin', '.local/lib', '.local/share']), + ); + expect(HOME_TOOL_PATHS).not.toContain('.local'); + expect(HOME_TOOL_PATHS).not.toContain('.local/state'); + }); + it('never whitelists a directory that is on the forbidden deny list', () => { for (const dir of HOME_FORBIDDEN_SUBDIRS) { expect(HOME_TOOL_SUBDIRS as readonly string[]).not.toContain(dir); diff --git a/src/services/agent-volumes/home-whitelist.ts b/src/services/agent-volumes/home-whitelist.ts index 8c488890a..8240252f0 100644 --- a/src/services/agent-volumes/home-whitelist.ts +++ b/src/services/agent-volumes/home-whitelist.ts @@ -5,18 +5,21 @@ * allow list so existing importers keep working; prefer importing from * `config/mount-policy` directly in new code. * - * `HOME_TOOL_SUBDIRS` is the canonical whitelist of `$HOME` subdirectories that - * agents legitimately need (tool caches, language toolchains, agent state), - * shared by both sandbox backends: + * `HOME_TOOL_PATHS` is the resolved whitelist of `$HOME` paths that agents + * legitimately need (tool caches, language toolchains, agent state), shared by + * both sandbox backends. Sensitive parents may be replaced with narrow children: * * - **Compose / chroot mode** (`home-strategy.ts`) mounts an empty home volume - * and bind-mounts these subdirs on top, then blanks known credential files + * and bind-mounts these paths on top, then blanks known credential files * with `/dev/null` overlays (`credential-hiding.ts`, driven by the policy). - * - **sbx microVM mode** (`sbx-manager.ts`) mounts these subdirs wholesale - * instead of the whole `$HOME`, then moves policy credential paths aside - * before `sbx create`. + * - **sbx microVM mode** (`sbx-manager.ts`) mounts these paths instead of the + * whole `$HOME`, then moves policy credential paths aside before `sbx create`. * * SECURITY: never add a directory whose primary purpose is storing credentials * — those belong in the policy's `home.forbiddenSubdirs` deny guard. */ -export { HOME_TOOL_SUBDIRS, HOME_FORBIDDEN_SUBDIRS } from '../../config/mount-policy'; +export { + HOME_TOOL_PATHS, + HOME_TOOL_SUBDIRS, + HOME_FORBIDDEN_SUBDIRS, +} from '../../config/mount-policy'; diff --git a/src/workdir-setup.test.ts b/src/workdir-setup.test.ts index c54d811e8..637d816cf 100644 --- a/src/workdir-setup.test.ts +++ b/src/workdir-setup.test.ts @@ -502,6 +502,16 @@ describe('prepareChrootHomeMounts (sub-function)', () => { workdirSetupTestHelpers.prepareChrootHomeMounts(buildConfig()); expect(fs.existsSync(geminiDir)).toBe(false); }); + + it('refuses existing symlinked nested home tool paths', () => { + const sandboxState = path.join(fixture.tempDir, '.local', 'state', 'sandboxes'); + const localBin = path.join(fixture.tempDir, '.local', 'bin'); + fs.mkdirSync(sandboxState, { recursive: true }); + fs.symlinkSync(sandboxState, localBin); + + expect(() => workdirSetupTestHelpers.prepareChrootHomeMounts(buildConfig())) + .toThrow(`Refusing to use symlink as directory: ${localBin}`); + }); }); describe('ensureDirectory EACCES diagnostic', () => {