mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
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:
parent
d23f919e2c
commit
eebf94132f
2 changed files with 62 additions and 3 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue