fix(claude_code): run npm package code in an unprivileged user+pid namespace

env -i scrubs the environment but is not a boundary: a malicious npm
lifecycle script or the installed claude binary runs as the same uid
and can read the secret-bearing parent's /proc/<pid>/environ directly.
The two package-code execution points (npm install and the claude
version probe) now run under unshare --user --map-current-user --pid
--fork --mount-proc inside the existing env -i allowlists, giving
package code a fresh /proc in which the parent does not exist. The
resolver stays outside the namespace since it is our own stdlib code.
unshare joins the required-commands loop and a preflight namespace
check dies with a clear diagnostic on kernels that restrict
unprivileged user namespaces instead of silently degrading. The
extracted-block tests execute with a fake unshare stub and a new
structural test pins the wrapping and the preflight requirement.
This commit is contained in:
mateo-berri 2026-07-16 09:21:54 -07:00
parent d23f919e2c
commit eebf94132f
2 changed files with 62 additions and 3 deletions

View file

@ -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/<pid>/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/<pid>/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)

View file

@ -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/<pid>/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)"