diff --git a/tests/e2e/claude_code/_publisher_unit_tests/test_run_daily_pins_resolver_version.py b/tests/e2e/claude_code/_publisher_unit_tests/test_run_daily_pins_resolver_version.py index 25d04f7c244..6c83d29c14d 100644 --- a/tests/e2e/claude_code/_publisher_unit_tests/test_run_daily_pins_resolver_version.py +++ b/tests/e2e/claude_code/_publisher_unit_tests/test_run_daily_pins_resolver_version.py @@ -20,6 +20,10 @@ flow in run_daily.sh: 4. The install's bin dir reaches PATH only inside the two `env -i` blocks that spawn `claude` (probe and pytest), never at script scope where later git/gh/curl/uv steps run with the token env. + 5. The npm install and the probe additionally run inside an + unprivileged user+pid namespace with a fresh /proc: env scrubbing + alone is not a boundary because same-uid package code can read + the secret-bearing parent's /proc//environ. The resolve/install/probe block is extracted out of run_daily.sh and executed with a stub resolver and a fake `npm`, mirroring the fake-curl @@ -156,6 +160,18 @@ def _build_harness(tmp_path: Path, installed_version: str) -> PinHarness: ) fake_npm.chmod(0o755) + fake_unshare = fake_bin / "unshare" + fake_unshare.write_text( + textwrap.dedent( + """\ + #!/usr/bin/env bash + while [[ "${1:-}" == --* ]]; do shift; done + exec "$@" + """ + ) + ) + fake_unshare.chmod(0o755) + script = ( _PREAMBLE + f'WORKDIR="{workdir}"\n' @@ -293,6 +309,38 @@ def test_static_npm_bin_dir_scoped_to_probe_and_pytest_env_blocks() -> None: ) +def test_static_npm_install_and_probe_run_in_user_pid_namespace() -> None: + body = RUN_DAILY.read_text() + unshare_cmd = "unshare --user --map-current-user --pid --fork --mount-proc" + assert "unshare" in _required_commands(body), ( + "run_daily.sh must require unshare up front and die early on " + "kernels that cannot create unprivileged user namespaces, " + "instead of degrading to unsandboxed package execution." + ) + lines = _executable_lines(_extract_pin_snippet()) + assert lines.count(unshare_cmd) == 2, ( + "both package-code execution points (npm install and the claude " + "probe) must run inside the user+pid namespace; `env -i` alone " + "is not a boundary because same-uid package code can read the " + "secret-bearing parent's /proc//environ." + ) + resolver_call = _anchor(lines, "pr_gate_version_resolver.py") + npm_call = _anchor(lines, "npm install --prefix") + assert unshare_cmd in lines[resolver_call:npm_call], ( + "the npm install (whose lifecycle scripts run package code) must " + "be unshare-wrapped" + ) + probe_block = _executable_lines( + _extract_block( + body, "PROBED_CLAUDE_VERSION=", '[[ -n "${PROBED_CLAUDE_VERSION}" ]]' + ) + ) + assert _anchor(probe_block, unshare_cmd) < _anchor(probe_block, "claude --version"), ( + "the installed claude binary must be spawned inside the " + "user+pid namespace" + ) + + def _required_commands(body: str) -> tuple[str, ...]: marker = "for cmd in " start = _anchor(body, marker) + len(marker) diff --git a/tests/e2e/claude_code/cron_vm/run_daily.sh b/tests/e2e/claude_code/cron_vm/run_daily.sh index 4186d6a3767..19fbcea4fb7 100755 --- a/tests/e2e/claude_code/cron_vm/run_daily.sh +++ b/tests/e2e/claude_code/cron_vm/run_daily.sh @@ -22,7 +22,7 @@ # rather than spawning a new one. If the JSON is byte-identical to the # docs branch, we skip the push entirely. # -# Required commands on $PATH: git, uv, gh, jq, curl, npm, python3. +# Required commands on $PATH: git, uv, gh, jq, curl, npm, python3, unshare. # Required state: ~/litellm/litellm checked out (this file lives in it), # $WORKTREE is created on first run, gh is already authenticated. # @@ -90,10 +90,13 @@ trap cleanup EXIT INT TERM log() { printf '==> %s\n' "$*" >&2; } die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; } -for cmd in git uv gh jq curl npm python3; do +for cmd in git uv gh jq curl npm python3 unshare; do command -v "${cmd}" >/dev/null 2>&1 || die "missing required command: ${cmd}" done +unshare --user --map-current-user --pid --fork --mount-proc true 2>/dev/null \ + || die "cannot create an unprivileged user+pid namespace (unshare --user --pid); refusing to run npm package code without one" + # Publishing is from a fork (agent-shin/litellm-docs) so neither the cron # host nor the bot identity needs write access to BerriAI/litellm-docs. We # require the fork token up front -- failing 30 minutes into a run because @@ -173,7 +176,13 @@ log "resolved litellm: ${LITELLM_VERSION}" # the proxy or test harness ever starts. Resolve, install, and probe # under `env -i` with the same minimal allowlist the pytest step below # uses (the matrix run itself goes through cli_driver.py, which already -# scrubs the CLI env). +# scrubs the CLI env). The env scrub alone is not a boundary, though: +# package code still runs as the same uid and could read the secrets +# straight out of this script's /proc//environ. The two points +# that execute package code (npm install with its lifecycle scripts, +# and the installed `claude` binary) therefore also run inside an +# unprivileged user+pid namespace with a freshly mounted /proc, in +# which the secret-bearing parent process does not exist. # # These steps also run under a fresh empty HOME instead of the runtime # user's real $HOME. `ProtectHome=read-only` in the systemd unit @@ -206,6 +215,7 @@ env -i \ LANG="${LANG:-C.UTF-8}" \ LC_ALL="${LC_ALL:-}" \ TMPDIR="${TMPDIR:-/tmp}" \ + unshare --user --map-current-user --pid --fork --mount-proc \ npm install --prefix "${CLAUDE_CLI_PREFIX}" "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \ || die "npm install of @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION} failed" CLAUDE_CLI_BIN="${CLAUDE_CLI_PREFIX}/node_modules/.bin" @@ -218,6 +228,7 @@ PROBED_CLAUDE_VERSION="$(env -i \ LANG="${LANG:-C.UTF-8}" \ LC_ALL="${LC_ALL:-}" \ TMPDIR="${TMPDIR:-/tmp}" \ + unshare --user --map-current-user --pid --fork --mount-proc \ claude --version 2>/dev/null \ | grep -oE '[0-9]+\.[0-9]+\.[0-9]+([.-][A-Za-z0-9.-]+)?' \ | head -n1 || true)"